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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,213 @@
using Theriapolis.Core.Entities;
using Theriapolis.Core.Rules.Reputation;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Phase 6.5 M5 — hybrid passing detection.
///
/// When a hybrid PC (with <see cref="HybridState.PassingActive"/> = true)
/// interacts with an NPC who has a scent-detection capability (Canid clade
/// "Superior Scent" or any Scent-Broker class), the NPC rolls a WIS save
/// at <see cref="C.HYBRID_DETECTION_DC"/> against the PC's CHA Deception
/// counter-roll.
///
/// Outcomes:
/// <see cref="DetectionResult.Pass"/> — PC remains hidden; treated as presenting clade.
/// <see cref="DetectionResult.Detected"/> — NPC sees through the cover; their bias
/// profile's <c>HybridBias</c> applies from now on.
///
/// Once detected by a specific NPC, the flag is permanent for that NPC
/// (per <c>theriapolis-rpg-clades.md</c> "Optional: Passing"). Other NPCs
/// roll independently — no per-settlement propagation in M5 (Phase 8
/// scent simulation may extend).
/// </summary>
public static class PassingCheck
{
/// <summary>
/// Roll detection for one NPC × PC interaction. Determinism:
/// <c>seed = worldSeed ^ C.RNG_PASSING ^ npcId ^ encounterIdx</c>.
/// Same seed → same outcome; mid-game saves resume identically.
///
/// <paramref name="npcMemoryFlags"/> is consulted upfront — if the NPC
/// already detected this PC in a prior interaction, the result is
/// <see cref="DetectionResult.Detected"/> with no fresh roll. The
/// caller writes the <c>"knows_hybrid"</c> tag into the NPC's
/// <see cref="PersonalDisposition.Memory"/> on first detection.
/// </summary>
public static DetectionResult Roll(
Character pc,
NpcActor npc,
ICollection<string> npcMemoryFlags,
ulong seed)
{
// Non-hybrids never trigger detection.
if (pc.Hybrid is null) return DetectionResult.NotApplicable;
// Already detected? Permanent for this NPC.
if (npcMemoryFlags.Contains("knows_hybrid"))
return DetectionResult.PreviouslyDetected;
// Not actively passing? The PC isn't trying to hide; detection
// happens trivially. Marks the NPC as knowing.
if (!pc.Hybrid.PassingActive)
return DetectionResult.NotPassing;
// Deep-cover scent mask suppresses all detection — even Superior Scent.
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.DeepCover)
return DetectionResult.MaskSuppressed;
// Military mask: auto-suppress for non-Canid NPCs; Canids still roll
// (Superior Scent overrides anything below deep cover).
bool npcHasSuperiorScent = NpcHasSuperiorScent(npc);
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.Military && !npcHasSuperiorScent)
return DetectionResult.MaskSuppressed;
// The detection mechanic: NPC WIS save vs the PC's CHA Deception
// counter-roll. NPCs without scent capability never detect.
if (!CanNpcDetectScent(npc)) return DetectionResult.NoCapability;
var rng = new SeededRng(seed);
// NPC rolls 1d20 + WIS mod against DC = pc Deception DC + (basic mask
// gives PC advantage, which we model as +5 to the DC the NPC must beat).
int npcWis = NpcWisMod(npc);
int npcRoll = (int)(rng.NextUInt64() % 20) + 1;
int npcTotal = npcRoll + npcWis;
int pcCha = pc.Abilities.ModFor(AbilityId.CHA);
int pcProf = pc.ProficiencyBonus;
int pcDecRoll = (int)(rng.NextUInt64() % 20) + 1;
// Proficient in Deception? Add prof bonus; otherwise just CHA mod.
bool deceptionProf = pc.SkillProficiencies.Contains(SkillId.Deception);
int pcTotal = pcDecRoll + pcCha + (deceptionProf ? pcProf : 0);
// Basic mask shifts the contest in PC's favour (advantage = +5
// approximation for a single non-rerolled compare).
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.Basic) pcTotal += 5;
// NPC must meet or exceed the DC AND beat the PC's deception
// contest to detect. (Either failing means PC stays hidden.)
bool npcMeetsDc = npcTotal >= C.HYBRID_DETECTION_DC;
bool pcBeatsCheck = pcTotal >= C.HYBRID_DECEPTION_DC;
bool detected = npcMeetsDc && !pcBeatsCheck;
return detected ? DetectionResult.Detected : DetectionResult.Pass;
}
/// <summary>
/// True for NPCs who have *any* scent-reading capability. Phase 6.5 M5:
/// canid-clade NPCs (Superior Scent) and scent-broker-flavoured roles.
/// Generic / non-canid / non-scent-broker NPCs never roll detection.
/// </summary>
public static bool CanNpcDetectScent(NpcActor npc)
{
if (NpcHasSuperiorScent(npc)) return true;
// Phase 6.5 M5 simplification: non-canid NPCs don't detect by
// default. A Phase 8 scent-broker NPC role could extend this with
// a tag check on `npc.Resident?.Traits` — out of scope for M5.
return false;
}
/// <summary>True if the NPC's clade is Canid (granting Superior Scent).</summary>
public static bool NpcHasSuperiorScent(NpcActor npc)
{
string? clade = npc.Resident?.Clade;
return string.Equals(clade, "canidae", System.StringComparison.OrdinalIgnoreCase);
}
/// <summary>NPC's WIS modifier — derived from template if present, otherwise default 0.</summary>
private static int NpcWisMod(NpcActor npc)
{
if (npc.Template is null) return 0;
// Templates store ability scores as a string-keyed dict on the def.
return npc.Template.AbilityScores.TryGetValue("WIS", out int wis)
? AbilityScores.Mod(wis)
: 0;
}
/// <summary>
/// Convenience: roll detection AND apply side effects on a positive
/// outcome. Writes the <c>"knows_hybrid"</c> memory tag to the NPC's
/// <see cref="PersonalDisposition.Memory"/>, mirrors the discovery in
/// <see cref="HybridState.NpcsWhoKnow"/>, and appends a
/// <see cref="RepEventKind.HybridDetected"/> event to the ledger.
///
/// Returns the same <see cref="DetectionResult"/> the underlying
/// <see cref="Roll"/> produced. Call sites that want to inspect the
/// outcome before applying side effects can use <see cref="Roll"/>
/// directly; this helper is the common-case one-liner.
/// </summary>
public static DetectionResult RollAndApply(
Character pc,
NpcActor npc,
Reputation.PlayerReputation rep,
long worldClockSeconds,
ulong seed)
{
if (pc.Hybrid is null) return DetectionResult.NotApplicable;
// Pull (or seed) the personal-disposition record so the roll sees
// the existing memory state.
var personal = string.IsNullOrEmpty(npc.RoleTag)
? null
: rep.PersonalFor(npc.RoleTag);
var memoryFlags = (ICollection<string>?)personal?.Memory ?? new HashSet<string>();
var result = Roll(pc, npc, memoryFlags, seed);
if (result == DetectionResult.Detected || result == DetectionResult.NotPassing)
{
// Write the detection through to all the places that care.
pc.Hybrid.NpcsWhoKnow.Add(npc.Id);
personal?.Memory.Add("knows_hybrid");
// Log a per-NPC HybridDetected event. Personal-only — no
// faction propagation in M5 (Phase 8 scent simulation can
// extend). Magnitude is 0 because the *bias* shift is
// applied via the bias-profile lookup in EffectiveDisposition,
// not via the personal-disposition delta.
var ev = new Reputation.RepEvent
{
Kind = Reputation.RepEventKind.HybridDetected,
RoleTag = npc.RoleTag ?? "",
Magnitude = 0,
Note = $"detected hybrid ({pc.Hybrid.SireClade}/{pc.Hybrid.DamClade})",
TimestampSeconds = worldClockSeconds,
};
rep.Ledger.Append(ev);
personal?.Apply(ev);
}
return result;
}
}
/// <summary>
/// Phase 6.5 M5 — outcome of one detection roll. The caller (typically the
/// dialogue runner) inspects this to apply the appropriate side effects.
/// </summary>
public enum DetectionResult : byte
{
/// <summary>PC is not a hybrid; no detection mechanic applies.</summary>
NotApplicable,
/// <summary>Hybrid detected on a prior interaction; flag still set.</summary>
PreviouslyDetected,
/// <summary>PC is hybrid but not actively passing — no roll, NPC knows immediately.</summary>
NotPassing,
/// <summary>NPC lacks scent-reading capability; passing automatic.</summary>
NoCapability,
/// <summary>Active scent mask blocked detection without a roll.</summary>
MaskSuppressed,
/// <summary>Detection roll succeeded; NPC sees through the cover.</summary>
Detected,
/// <summary>Detection roll failed; PC remains hidden in this interaction.</summary>
Pass,
}