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>
378 lines
14 KiB
C#
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 };
|
|
}
|
|
}
|