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>
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user