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,
}