Files
TheriapolisV3/Theriapolis.Tests/Combat/DamageDeterminismTests.cs
T

117 lines
4.3 KiB
C#
Raw Normal View History

using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;
namespace Theriapolis.Tests.Combat;
/// <summary>
/// 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.
/// </summary>
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<Combatant> 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();
}
}