using Theriapolis.Core.Data; using Theriapolis.Core.Entities; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.World; namespace Theriapolis.Core.Rules.Reputation; /// /// Phase 6 M2 — combines the three reputation layers into a single /// integer disposition score per reputation.md §I-4: /// /// 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). /// public static class EffectiveDisposition { /// /// Final blended score (clamped ±C.REP_MAX) for how /// currently feels about /// . /// 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; } /// /// 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. /// 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; } /// /// Phase 6.5 M5 — true if the NPC's /// contains the "knows_hybrid" flag (set by /// on a successful detection). /// Falls back to the PC-side /// list when the NPC has no personal-disposition record yet (which can /// happen for casual encounters). /// 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 ──────────────────────────────────────── /// /// 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 /// AND a non-null 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 faction_affinity hints layer on top — a Covenant /// Faithful amplifies their Enforcer alignment even if not formally /// affiliated. /// 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; } } /// Component view of an result. public readonly record struct EffectiveDispositionBreakdown( int CladeBias, int SizeDifferential, int FactionModifier, int Personal, int Total, DispositionLabel Label);