b451f83174
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>
210 lines
8.5 KiB
C#
210 lines
8.5 KiB
C#
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);
|