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