Files
TheriapolisV3/Theriapolis.Core/Rules/Character/PassingCheck.cs
T
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

214 lines
9.0 KiB
C#
Raw 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.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,
}