Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

210 lines
8.5 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);