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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user