Files

210 lines
8.5 KiB
C#
Raw Permalink Normal View History

using Theriapolis.Core.Data;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.World;
namespace Theriapolis.Core.Rules.Reputation;
/// <summary>
/// Phase 6 M2 — combines the three reputation layers into a single
/// integer disposition score per <c>reputation.md §I-4</c>:
///
/// EffectiveDisposition(npc, pc) =
/// CladeBiasFor(npc.BiasProfile, pc.Clade, sizeDiff(npc, pc))
/// + FactionWeightedSum(npc.Faction, pc.FactionStandings)
/// + PersonalDisposition(npc.Id)
///
/// Computed lazily — the inputs change too often (faction propagation,
/// time decay) to justify caching. Computation is O(1).
/// </summary>
public static class EffectiveDisposition
{
/// <summary>
/// Final blended score (clamped <c>±C.REP_MAX</c>) for how
/// <paramref name="npc"/> currently feels about
/// <paramref name="pc"/>.
/// </summary>
public static int For(
NpcActor npc,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState? world = null,
ulong worldSeed = 0)
{
return Breakdown(npc, pc, rep, content, world, worldSeed).Total;
}
/// <summary>
/// Componentised view used by the disposition tooltip + reputation
/// screen. Each field carries the contribution of one layer so the
/// UI can answer "why does so-and-so hate me?" without re-deriving.
/// </summary>
public static EffectiveDispositionBreakdown Breakdown(
NpcActor npc,
Rules.Character.Character pc,
PlayerReputation rep,
ContentResolver content,
WorldState? world = null,
ulong worldSeed = 0)
{
int cladeBias = ResolveCladeBias(npc, pc, content);
int sizeBias = SizeDifferentialModifier(npc, pc, content);
int factionMod = ResolveFactionMod(npc, rep, content, world, worldSeed);
int personal = ResolvePersonal(npc, rep);
int total = System.Math.Clamp(cladeBias + sizeBias + factionMod + personal,
C.REP_MIN, C.REP_MAX);
return new EffectiveDispositionBreakdown(
cladeBias, sizeBias, factionMod, personal, total,
DispositionLabels.For(total));
}
// ── Layer 1 — Clade bias ──────────────────────────────────────────────
private static int ResolveCladeBias(NpcActor npc, Rules.Character.Character pc, ContentResolver content)
{
if (string.IsNullOrEmpty(npc.BiasProfileId)) return 0;
if (!content.BiasProfiles.TryGetValue(npc.BiasProfileId, out var profile)) return 0;
// Phase 6.5 M5 — hybrid bias layering. When the PC is hybrid AND
// this NPC has personally detected the hybrid status (memory tag
// "knows_hybrid"), the profile's HybridBias modifier is added to
// the clade-bias. Pre-detection, the PC reads as their presenting
// (dominant) clade and HybridBias is *not* applied.
int bias = profile.CladeBias.TryGetValue(pc.Clade.Id, out int b) ? b : 0;
if (pc.IsHybrid && NpcKnowsPlayerIsHybrid(npc, pc))
bias += profile.HybridBias;
return bias;
}
/// <summary>
/// Phase 6.5 M5 — true if the NPC's <see cref="PersonalDisposition.Memory"/>
/// contains the <c>"knows_hybrid"</c> flag (set by
/// <see cref="Rules.Character.PassingCheck"/> on a successful detection).
/// Falls back to the PC-side <see cref="Rules.Character.HybridState.NpcsWhoKnow"/>
/// list when the NPC has no personal-disposition record yet (which can
/// happen for casual encounters).
/// </summary>
public static bool NpcKnowsPlayerIsHybrid(NpcActor npc, Rules.Character.Character pc)
{
if (pc.Hybrid is null) return false;
if (pc.Hybrid.NpcsWhoKnow.Contains(npc.Id)) return true;
// The NPC's PersonalDisposition lives on the player-rep dictionary;
// this call site doesn't have access. The PC-side NpcsWhoKnow set
// is the authoritative mirror written by PassingCheck after every
// detection — sufficient for the disposition layer to consult.
return false;
}
private static int SizeDifferentialModifier(NpcActor npc, Rules.Character.Character pc, ContentResolver content)
{
// Size index: Small=1, Medium=2, MediumLarge=3, Large=4. Differential is pc.size npc.size.
if (npc.Resident is null) return 0;
if (string.IsNullOrEmpty(npc.Resident.Species)) return 0;
if (!content.Species.TryGetValue(npc.Resident.Species, out var npcSpecies)) return 0;
int npcIdx = SizeIndex(SizeExtensions.FromJson(npcSpecies.Size));
int pcIdx = SizeIndex(pc.Size);
int diff = pcIdx - npcIdx;
// Per reputation.md §I-1 size differential table.
return diff switch
{
0 => 0,
1 => -3,
2 => -8,
3 => -8,
-1 => 2,
-2 => 5,
_ => 5,
};
}
private static int SizeIndex(SizeCategory s) => s switch
{
SizeCategory.Tiny => 0,
SizeCategory.Small => 1,
SizeCategory.Medium => 2,
SizeCategory.MediumLarge => 3,
SizeCategory.Large => 4,
SizeCategory.Huge => 5,
_ => 2,
};
// ── Layer 2 — Faction modifier ────────────────────────────────────────
/// <summary>
/// Derived modifier from the player's faction standings, weighted by
/// how much *this NPC* cares about each faction.
///
/// Phase 6 M5: when the NPC has a <see cref="NpcActor.HomeSettlementId"/>
/// AND a non-null <paramref name="world"/> is supplied, the local
/// (post-propagation, post-decay) standing in their settlement is used
/// instead of the global standing. Otherwise falls back to the M2
/// global lookup.
///
/// Bias-profile <c>faction_affinity</c> hints layer on top — a Covenant
/// Faithful amplifies their Enforcer alignment even if not formally
/// affiliated.
/// </summary>
private static int ResolveFactionMod(
NpcActor npc, PlayerReputation rep, ContentResolver content,
WorldState? world, ulong worldSeed)
{
float total = 0f;
// Resolve the NPC's home settlement (if any) for local-standing lookups.
Settlement? home = null;
if (world is not null && npc.HomeSettlementId is { } hid)
{
foreach (var s in world.Settlements)
if (s.Id == hid) { home = s; break; }
}
// Half-magnitude weight for the NPC's own affiliation.
if (!string.IsNullOrEmpty(npc.FactionId))
{
int standing = home is not null
? RepPropagation.LocalStandingFor(npc.FactionId, home, worldSeed, rep.Ledger, content.Factions)
: rep.Factions.Get(npc.FactionId);
total += standing * 0.5f;
}
// Bias-profile faction-affinity layering: Covenant Faithful npcs
// care about the Enforcers' standing even if not affiliated.
if (!string.IsNullOrEmpty(npc.BiasProfileId)
&& content.BiasProfiles.TryGetValue(npc.BiasProfileId, out var profile))
{
foreach (var (factionId, affinity) in profile.FactionAffinity)
{
int standing = home is not null
? RepPropagation.LocalStandingFor(factionId, home, worldSeed, rep.Ledger, content.Factions)
: rep.Factions.Get(factionId);
// Smaller weight than direct affiliation (×0.25) so the bias
// profile colours rather than dominates.
total += standing * (affinity / 100f) * 0.25f;
}
}
return (int)System.Math.Round(total);
}
// ── Layer 3 — Personal disposition ────────────────────────────────────
private static int ResolvePersonal(NpcActor npc, PlayerReputation rep)
{
if (string.IsNullOrEmpty(npc.RoleTag)) return 0;
return rep.Personal.TryGetValue(npc.RoleTag, out var p) ? p.Score : 0;
}
}
/// <summary>Component view of an <see cref="EffectiveDisposition"/> result.</summary>
public readonly record struct EffectiveDispositionBreakdown(
int CladeBias,
int SizeDifferential,
int FactionModifier,
int Personal,
int Total,
DispositionLabel Label);