using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Combat; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; using Xunit; namespace Theriapolis.Tests.Combat; /// /// Phase 5 plan §5: same (worldSeed, encounterId, rollSequence) → identical /// dice outcomes across runs. Save/load can resume mid-combat by re-creating /// the encounter and replaying through its rollCount. /// public sealed class DamageDeterminismTests { private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory)); [Fact] public void EncounterSeed_IsXorOfWorldSeedRngCombatAndEncounterId() { var enc = new Encounter(worldSeed: 0xABCDUL, encounterId: 0x1234UL, MakeOne()); Assert.Equal(0xABCDUL ^ Theriapolis.Core.C.RNG_COMBAT ^ 0x1234UL, enc.EncounterSeed); } [Fact] public void SameInputs_SameDiceSequence() { var a = new Encounter(0xCAFEUL, 1, MakeOne()); var b = new Encounter(0xCAFEUL, 1, MakeOne()); for (int i = 0; i < 100; i++) Assert.Equal(a.RollD20(), b.RollD20()); } [Fact] public void DifferentEncounterIds_DivergeImmediately() { var a = new Encounter(0xCAFEUL, 1, MakeOne()); var b = new Encounter(0xCAFEUL, 2, MakeOne()); bool anyDifferent = false; for (int i = 0; i < 20; i++) if (a.RollD20() != b.RollD20()) { anyDifferent = true; break; } Assert.True(anyDifferent, "Different encounter ids should produce different dice streams."); } [Fact] public void ResumeRolls_SkipsForwardThroughDiceStream() { var a = new Encounter(0xCAFEUL, 1, MakeOne()); var b = new Encounter(0xCAFEUL, 1, MakeOne()); // Burn some rolls on `a` and capture the next 5. for (int i = 0; i < 10; i++) a.RollD20(); int rollCountSnapshot = a.RollCount; // includes initiative rolls consumed by the ctor int[] expected = new int[5]; for (int i = 0; i < 5; i++) expected[i] = a.RollD20(); // Resume `b` to the same total rollcount and capture the same window. b.ResumeRolls(rollCountSnapshot); int[] actual = new int[5]; for (int i = 0; i < 5; i++) actual[i] = b.RollD20(); Assert.Equal(expected, actual); Assert.Equal(rollCountSnapshot + 5, b.RollCount); } [Fact] public void Resolver_FullScenario_IsDeterministicAcrossRuns() { // Run the same scripted scenario twice and expect identical logs. var log1 = RunScriptedScenario(seed: 0xABCDEFUL); var log2 = RunScriptedScenario(seed: 0xABCDEFUL); Assert.Equal(log1, log2); } [Fact] public void Resolver_DifferentSeeds_ProduceDifferentLogs() { var log1 = RunScriptedScenario(seed: 1UL); var log2 = RunScriptedScenario(seed: 2UL); Assert.NotEqual(log1, log2); } private List MakeOne() => new() { Combatant.FromNpcTemplate(_content.Npcs.Templates.First(t => t.Id == "brigand_footpad"), id: 1, new Vec2(0, 0)), }; private string RunScriptedScenario(ulong seed) { var brigand = _content.Npcs.Templates.First(t => t.Id == "brigand_footpad"); var wolf = _content.Npcs.Templates.First(t => t.Id == "wolf"); var hero = Combatant.FromNpcTemplate(brigand with { DefaultAllegiance = "player" }, id: 1, new Vec2(0, 0)); var foe = Combatant.FromNpcTemplate(wolf, id: 2, new Vec2(1, 0)); var enc = new Encounter(seed, 1, new[] { hero, foe }); for (int round = 0; round < 10 && !enc.IsOver; round++) { for (int t = 0; t < enc.Participants.Count && !enc.IsOver; t++) { var actor = enc.CurrentActor; if (actor.IsAlive && !actor.IsDown) { var target = actor.Id == hero.Id ? foe : hero; if (target.IsAlive && !target.IsDown) Resolver.AttemptAttack(enc, actor, target, actor.AttackOptions[0]); } enc.EndTurn(); } } var sb = new System.Text.StringBuilder(); foreach (var entry in enc.Log) sb.AppendLine($"R{entry.Round} T{entry.Turn} [{entry.Type}] {entry.Message}"); return sb.ToString(); } }