using Theriapolis.Core.Entities; using Theriapolis.Core.Rules.Reputation; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; namespace Theriapolis.Core.Rules.Character; /// /// Phase 6.5 M5 — hybrid passing detection. /// /// When a hybrid PC (with = 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 against the PC's CHA Deception /// counter-roll. /// /// Outcomes: /// — PC remains hidden; treated as presenting clade. /// — NPC sees through the cover; their bias /// profile's HybridBias applies from now on. /// /// Once detected by a specific NPC, the flag is permanent for that NPC /// (per theriapolis-rpg-clades.md "Optional: Passing"). Other NPCs /// roll independently — no per-settlement propagation in M5 (Phase 8 /// scent simulation may extend). /// public static class PassingCheck { /// /// Roll detection for one NPC × PC interaction. Determinism: /// seed = worldSeed ^ C.RNG_PASSING ^ npcId ^ encounterIdx. /// Same seed → same outcome; mid-game saves resume identically. /// /// is consulted upfront — if the NPC /// already detected this PC in a prior interaction, the result is /// with no fresh roll. The /// caller writes the "knows_hybrid" tag into the NPC's /// on first detection. /// public static DetectionResult Roll( Character pc, NpcActor npc, ICollection 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; } /// /// 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. /// 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; } /// True if the NPC's clade is Canid (granting Superior Scent). public static bool NpcHasSuperiorScent(NpcActor npc) { string? clade = npc.Resident?.Clade; return string.Equals(clade, "canidae", System.StringComparison.OrdinalIgnoreCase); } /// NPC's WIS modifier — derived from template if present, otherwise default 0. 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; } /// /// Convenience: roll detection AND apply side effects on a positive /// outcome. Writes the "knows_hybrid" memory tag to the NPC's /// , mirrors the discovery in /// , and appends a /// event to the ledger. /// /// Returns the same the underlying /// produced. Call sites that want to inspect the /// outcome before applying side effects can use /// directly; this helper is the common-case one-liner. /// 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?)personal?.Memory ?? new HashSet(); 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; } } /// /// Phase 6.5 M5 — outcome of one detection roll. The caller (typically the /// dialogue runner) inspects this to apply the appropriate side effects. /// public enum DetectionResult : byte { /// PC is not a hybrid; no detection mechanic applies. NotApplicable, /// Hybrid detected on a prior interaction; flag still set. PreviouslyDetected, /// PC is hybrid but not actively passing — no roll, NPC knows immediately. NotPassing, /// NPC lacks scent-reading capability; passing automatic. NoCapability, /// Active scent mask blocked detection without a roll. MaskSuppressed, /// Detection roll succeeded; NPC sees through the cover. Detected, /// Detection roll failed; PC remains hidden in this interaction. Pass, }