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>
117 lines
4.3 KiB
C#
117 lines
4.3 KiB
C#
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();
|
|
}
|
|
}
|