Files
TheriapolisV3/Theriapolis.Tests/Rules/PassingDetectionTests.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

378 lines
14 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<string>(), 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<string> { "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<string>(), 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<string>(), 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<string>(), 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<string>(), 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<string>(), seed: 0x1234);
var b = PassingCheck.Roll(pc, npc, new HashSet<string>(), 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<string>(), seed: 1UL);
bool sawDifferent = false;
for (ulong s = 2; s <= 50; s++)
{
var r = PassingCheck.Roll(pc, npc, new HashSet<string>(), 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<string>(), seed: s) == DetectionResult.Pass)
maskedPasses++;
if (PassingCheck.Roll(pcUnmasked, npc, new HashSet<string>(), 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 };
}
}