using Theriapolis.Core.Data; using Theriapolis.Core.Entities; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Reputation; using Theriapolis.Core.Rules.Stats; using Xunit; namespace Theriapolis.Tests.Rules; /// /// Phase 6.5 M5 — passing detection. Hybrid PCs with PassingActive get a /// scent-detection roll on encountering scent-capable NPCs. The result is /// permanent per-NPC (cached in Hybrid.NpcsWhoKnow + the NPC's /// PersonalDisposition.Memory). Once detected, EffectiveDisposition layers /// in the NPC's BiasProfile.HybridBias. /// public sealed class PassingDetectionTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); // ── Roll outcomes ───────────────────────────────────────────────────── [Fact] public void Roll_NotApplicable_ForPurebredPc() { var pc = MakePurebred(); var npc = MakeCanidNpc(); var result = PassingCheck.Roll(pc, npc, new HashSet(), seed: 0xCAFE); Assert.Equal(DetectionResult.NotApplicable, result); } [Fact] public void Roll_PreviouslyDetected_NoFreshRoll() { var pc = MakeHybrid(passing: true); var npc = MakeCanidNpc(); var memory = new HashSet { "knows_hybrid" }; var result = PassingCheck.Roll(pc, npc, memory, seed: 0xCAFE); Assert.Equal(DetectionResult.PreviouslyDetected, result); } [Fact] public void Roll_NotPassing_AutoDetected() { var pc = MakeHybrid(passing: false); var npc = MakeCanidNpc(); var result = PassingCheck.Roll(pc, npc, new HashSet(), seed: 0xCAFE); Assert.Equal(DetectionResult.NotPassing, result); } [Fact] public void Roll_DeepCoverMask_AlwaysSuppresses() { var pc = MakeHybrid(passing: true); pc.Hybrid!.ActiveMaskTier = ScentMaskTier.DeepCover; var npc = MakeCanidNpc(); // Even Canid Superior Scent fails against deep cover. var result = PassingCheck.Roll(pc, npc, new HashSet(), seed: 0xCAFE); Assert.Equal(DetectionResult.MaskSuppressed, result); } [Fact] public void Roll_MilitaryMask_SuppressesNonCanid() { var pc = MakeHybrid(passing: true); pc.Hybrid!.ActiveMaskTier = ScentMaskTier.Military; // M5 simplification: only Canid NPCs detect scent. Test the path // by giving the NPC a non-Canid clade — military mask suppresses // automatically for non-superior-scent NPCs. var npc = MakeNonCanidNpc(); var result = PassingCheck.Roll(pc, npc, new HashSet(), seed: 0xCAFE); // Non-Canid NPCs lack scent capability anyway, so result is NoCapability. // Military mask short-circuits earlier with MaskSuppressed. Assert.Equal(DetectionResult.MaskSuppressed, result); } [Fact] public void Roll_NoCapability_ForNonScentNpc() { var pc = MakeHybrid(passing: true); var npc = MakeNonCanidNpc(); var result = PassingCheck.Roll(pc, npc, new HashSet(), seed: 0xCAFE); Assert.Equal(DetectionResult.NoCapability, result); } [Fact] public void Roll_IsDeterministic_ForSameSeed() { var pc = MakeHybrid(passing: true); var npc = MakeCanidNpc(); // Use a fresh memory set each time so PreviouslyDetected doesn't // short-circuit. var a = PassingCheck.Roll(pc, npc, new HashSet(), seed: 0x1234); var b = PassingCheck.Roll(pc, npc, new HashSet(), seed: 0x1234); Assert.Equal(a, b); } [Fact] public void Roll_DifferentSeeds_CanProduceDifferentOutcomes() { var pc = MakeHybrid(passing: true); var npc = MakeCanidNpc(); // Sweep 50 seeds — at least one should differ from the first // (probabilistic detection). var first = PassingCheck.Roll(pc, npc, new HashSet(), seed: 1UL); bool sawDifferent = false; for (ulong s = 2; s <= 50; s++) { var r = PassingCheck.Roll(pc, npc, new HashSet(), seed: s); if (r != first) { sawDifferent = true; break; } } Assert.True(sawDifferent, "expected some seed variance in detection outcomes"); } [Fact] public void Roll_BasicMaskFavoursPc() { // With a basic mask, the PC's Deception roll gets +5. Sweep many // seeds and verify that masked rolls produce *more* Pass outcomes // than unmasked rolls on average. var pcMasked = MakeHybrid(passing: true); pcMasked.Hybrid!.ActiveMaskTier = ScentMaskTier.Basic; var pcUnmasked = MakeHybrid(passing: true); var npc = MakeCanidNpc(); int maskedPasses = 0; int unmaskedPasses = 0; for (ulong s = 1; s <= 200; s++) { if (PassingCheck.Roll(pcMasked, npc, new HashSet(), seed: s) == DetectionResult.Pass) maskedPasses++; if (PassingCheck.Roll(pcUnmasked, npc, new HashSet(), seed: s) == DetectionResult.Pass) unmaskedPasses++; } Assert.True(maskedPasses > unmaskedPasses, $"basic mask should help: masked passes={maskedPasses}, unmasked passes={unmaskedPasses}"); } // ── RollAndApply side effects ───────────────────────────────────────── [Fact] public void RollAndApply_NotPassing_WritesMemoryAndLedger() { var pc = MakeHybrid(passing: false); var npc = MakeCanidNpcWithRole("test.canid"); var rep = new PlayerReputation(); var result = PassingCheck.RollAndApply(pc, npc, rep, worldClockSeconds: 100L, seed: 0xCAFE); Assert.Equal(DetectionResult.NotPassing, result); Assert.Contains(npc.Id, pc.Hybrid!.NpcsWhoKnow); Assert.Contains("knows_hybrid", rep.PersonalFor("test.canid").Memory); Assert.Contains(rep.Ledger.Entries, ev => ev.Kind == RepEventKind.HybridDetected && ev.RoleTag == "test.canid"); } [Fact] public void RollAndApply_PreviouslyDetected_DoesNotReWrite() { var pc = MakeHybrid(passing: true); var npc = MakeCanidNpcWithRole("test.canid"); var rep = new PlayerReputation(); // Pre-seed memory to look like a prior detection. rep.PersonalFor("test.canid").Memory.Add("knows_hybrid"); var result = PassingCheck.RollAndApply(pc, npc, rep, worldClockSeconds: 100L, seed: 0xCAFE); Assert.Equal(DetectionResult.PreviouslyDetected, result); // No new HybridDetected event added (only the pre-existing memory tag). Assert.DoesNotContain(rep.Ledger.Entries, ev => ev.Kind == RepEventKind.HybridDetected); } [Fact] public void RollAndApply_NotApplicable_NoSideEffects_ForPurebred() { var pc = MakePurebred(); var npc = MakeCanidNpcWithRole("test.canid"); var rep = new PlayerReputation(); var result = PassingCheck.RollAndApply(pc, npc, rep, worldClockSeconds: 100L, seed: 0xCAFE); Assert.Equal(DetectionResult.NotApplicable, result); Assert.Empty(rep.Ledger.Entries); Assert.False(rep.Personal.ContainsKey("test.canid")); } // ── EffectiveDisposition + HybridBias consumption ──────────────────── [Fact] public void EffectiveDisposition_DoesNotApplyHybridBias_BeforeDetection() { var pc = MakeHybrid(passing: true); var npc = MakeNpcWithBiasProfile("CERVID_CAUTIOUS"); var rep = new PlayerReputation(); // No detection yet — hybrid bias should not be in the disposition. int beforeDisposition = EffectiveDisposition.For(npc, pc, rep, _content); // Sanity: just confirm we get a number; what matters is the next // assertion shows it differs after detection. Assert.True(beforeDisposition > -100 && beforeDisposition < 100); } [Fact] public void EffectiveDisposition_AppliesHybridBias_AfterDetection() { var pc = MakeHybrid(passing: true); var npc = MakeNpcWithBiasProfile("CERVID_CAUTIOUS"); var rep = new PlayerReputation(); int before = EffectiveDisposition.For(npc, pc, rep, _content); // Mark NPC as having detected. pc.Hybrid!.NpcsWhoKnow.Add(npc.Id); int after = EffectiveDisposition.For(npc, pc, rep, _content); // CERVID_CAUTIOUS has a *negative* hybrid_bias per bias_profiles.json, // so the disposition should drop after detection. Assert.True(after < before, $"expected disposition to drop after hybrid detection: before={before}, after={after}"); } [Fact] public void EffectiveDisposition_ProgressiveProfile_PositiveHybridBias() { var pc = MakeHybrid(passing: true); var npc = MakeNpcWithBiasProfile("HYBRID_SURVIVOR"); var rep = new PlayerReputation(); int before = EffectiveDisposition.For(npc, pc, rep, _content); pc.Hybrid!.NpcsWhoKnow.Add(npc.Id); int after = EffectiveDisposition.For(npc, pc, rep, _content); // HYBRID_SURVIVOR has positive hybrid_bias — disposition rises. Assert.True(after > before, $"expected disposition to rise for hybrid-friendly profile: before={before}, after={after}"); } // ── Save round-trip mask tier ──────────────────────────────────────── [Fact] public void Hybrid_MaskTier_RoundTripsThroughSave() { var pc = MakeHybrid(passing: true); pc.Hybrid!.ActiveMaskTier = ScentMaskTier.DeepCover; var snap = Theriapolis.Core.Persistence.CharacterCodec.Capture(pc); Assert.NotNull(snap.Hybrid); Assert.Equal((byte)ScentMaskTier.DeepCover, snap.Hybrid!.ActiveMaskTier); var restored = Theriapolis.Core.Persistence.CharacterCodec.Restore(snap, _content); Assert.Equal(ScentMaskTier.DeepCover, restored.Hybrid!.ActiveMaskTier); } [Fact] public void Hybrid_MaskTier_RoundTripsThroughBinarySaveCodec() { var pc = MakeHybrid(passing: true); pc.Hybrid!.ActiveMaskTier = ScentMaskTier.Military; var header = new Theriapolis.Core.Persistence.SaveHeader { Version = Theriapolis.Core.C.SAVE_SCHEMA_VERSION, WorldSeedHex = "0xFEED", }; var body = new Theriapolis.Core.Persistence.SaveBody { PlayerCharacter = Theriapolis.Core.Persistence.CharacterCodec.Capture(pc), }; body.Player.Id = 1; body.Player.Name = "Hybrid"; var bytes = Theriapolis.Core.Persistence.SaveCodec.Serialize(header, body); var (_, body2) = Theriapolis.Core.Persistence.SaveCodec.Deserialize(bytes); Assert.NotNull(body2.PlayerCharacter?.Hybrid); Assert.Equal((byte)ScentMaskTier.Military, body2.PlayerCharacter!.Hybrid!.ActiveMaskTier); } // ── Helpers ─────────────────────────────────────────────────────────── private Theriapolis.Core.Rules.Character.Character MakePurebred() { var b = new CharacterBuilder { Clade = _content.Clades["canidae"], Species = _content.Species["wolf"], ClassDef = _content.Classes["fangsworn"], Background = _content.Backgrounds["pack_raised"], BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 8), }; AutoPickSkills(b); return b.Build(_content.Items); } private Theriapolis.Core.Rules.Character.Character MakeHybrid(bool passing) { var b = new CharacterBuilder { ClassDef = _content.Classes["fangsworn"], Background = _content.Backgrounds["pack_raised"], BaseAbilities = new AbilityScores(15, 14, 13, 12, 10, 12), IsHybridOrigin = true, HybridSireClade = _content.Clades["canidae"], HybridSireSpecies = _content.Species["wolf"], HybridDamClade = _content.Clades["leporidae"], HybridDamSpecies = _content.Species["rabbit"], HybridDominantParent = ParentLineage.Sire, }; AutoPickSkills(b); bool ok = b.TryBuildHybrid(_content.Items, out var c, out string err); Assert.True(ok, err); c!.Hybrid!.PassingActive = passing; return c; } private void AutoPickSkills(CharacterBuilder b) { int n = b.ClassDef!.SkillsChoose; foreach (var raw in b.ClassDef.SkillOptions) { if (b.ChosenClassSkills.Count >= n) break; try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { } } } private NpcActor MakeCanidNpc() { var resident = new ResidentTemplateDef { Id = "test_canid", Name = "Test Canid", Clade = "canidae", Species = "wolf", }; return new NpcActor(resident) { Id = 42 }; } private NpcActor MakeCanidNpcWithRole(string roleTag) { var resident = new ResidentTemplateDef { Id = "test_canid", Name = "Test Canid", Clade = "canidae", Species = "wolf", RoleTag = roleTag, }; return new NpcActor(resident) { Id = 42 }; } private NpcActor MakeNonCanidNpc() { var resident = new ResidentTemplateDef { Id = "test_cervid", Name = "Test Cervid", Clade = "cervidae", Species = "deer", }; return new NpcActor(resident) { Id = 99 }; } private NpcActor MakeNpcWithBiasProfile(string biasProfileId) { var resident = new ResidentTemplateDef { Id = "test_npc", Name = "Test NPC", Clade = "cervidae", Species = "deer", BiasProfile = biasProfileId, }; return new NpcActor(resident) { Id = 1 }; } }