Implement deterministic simulation spine

This commit is contained in:
2026-04-16 11:29:41 +02:00
parent 8f5721462d
commit 5f11dfcdc5
41 changed files with 1406 additions and 203 deletions

View File

@@ -5,12 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Exclude>[SideScrollerGame.Sim]SideScrollerGame.Sim.Math.*,[SideScrollerGame.Sim]SideScrollerGame.Sim.SimulationDefaults</Exclude>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="coverlet.msbuild" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />

View File

@@ -0,0 +1,19 @@
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationConfigTests
{
[Fact]
public void Default_UsesRepositoryDefaults()
{
Assert.Equal(SimulationDefaults.DefaultTicksPerSecond, SimulationConfig.Default.TicksPerSecond);
Assert.Equal(VerificationMode.None, SimulationConfig.Default.VerificationMode);
}
[Fact]
public void Constructor_RejectsNonPositiveTicksPerSecond()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new SimulationConfig(0, VerificationMode.None));
}
}

View File

@@ -0,0 +1,119 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Replay;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationReplayTests
{
[Fact]
public void SameSeedAndActions_ProduceSameHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
TickActionBatch[] batches =
[
SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)),
SimulationTestFactory.CreateTick(2, new AimAxisChanged(new(1), 3, 4)),
SimulationTestFactory.CreateTick(3, new ButtonChanged(new(1), InputButton.Dash, true))
];
Simulation left = new(definition, config, 42);
Simulation right = new(definition, config, 42);
var leftHashes = ImmutableArray.CreateBuilder<int>();
var rightHashes = ImmutableArray.CreateBuilder<int>();
foreach (var batch in batches)
{
leftHashes.Add(left.Step(batch).StateHash);
rightHashes.Add(right.Step(batch).StateHash);
}
Assert.Equal(leftHashes.ToImmutable(), rightHashes.ToImmutable());
}
[Fact]
public void DifferentSeeds_ProduceDifferentHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
var batch = TickActionBatch.Empty(1);
Simulation left = new(definition, config, 1);
Simulation right = new(definition, config, 2);
Assert.NotEqual(left.Step(batch).StateHash, right.Step(batch).StateHash);
}
[Fact]
public void ReplayRecord_RoundTripsAndReplaysExpectedHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 9);
ReplayRecorder recorder = new();
TickActionBatch[] batches =
[
SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, 0), new AimAxisChanged(new(1), 5, 6)),
SimulationTestFactory.CreateTick(2, new ButtonChanged(new(1), InputButton.FireSecondary, true)),
SimulationTestFactory.CreateTick(3, new WeaponSlotSelected(new(1), 4))
];
foreach (var batch in batches)
{
var result = simulation.Step(batch);
recorder.Append(batch, result);
}
var replay = recorder.Build(definition, config, 9);
var payload = ReplayRecordSerializer.Serialize(replay);
var loadedReplay = ReplayRecordSerializer.Deserialize(payload);
var replayedHashes = ReplayPlayer.Play(loadedReplay, definition, config);
Assert.Equal(loadedReplay.Ticks.Select(static tick => tick.ExpectedStateHash).ToImmutableArray(), replayedHashes);
}
[Fact]
public void ReplayPlayer_RejectsMismatchedDefinition()
{
var config = SimulationTestFactory.CreateConfig();
var definition = SimulationTestFactory.CreateGameDefinition();
ReplayRecord replay = new(123, 9, config.TicksPerSecond, ImmutableArray<RecordedTick>.Empty);
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(replay, definition, config));
Assert.Contains("content hash", exception.Message.ToLowerInvariant());
}
[Fact]
public void ReplayPlayer_RejectsMismatchedTickRate()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var replay = new ReplayRecorder().Build(definition, SimulationTestFactory.CreateConfig(), 9) with { TicksPerSecond = 30 };
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(replay, definition, SimulationTestFactory.CreateConfig()));
Assert.Contains("tick rate", exception.Message.ToLowerInvariant());
}
[Fact]
public void ReplayPlayer_RejectsDivergentHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 55);
ReplayRecorder recorder = new();
var batch = SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0));
var result = simulation.Step(batch);
recorder.Append(batch, result);
var replay = recorder.Build(definition, config, 55);
var divergentReplay = replay with { Ticks = ImmutableArray.Create(replay.Ticks[0] with { ExpectedStateHash = replay.Ticks[0].ExpectedStateHash + 1 }) };
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(divergentReplay, definition, config));
Assert.Contains("diverged", exception.Message.ToLowerInvariant());
}
}

View File

@@ -0,0 +1,38 @@
using SideScrollerGame.Sim.Input;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationSerializationTests
{
[Fact]
public void SaveStateLoadState_PreservesStateAndNextStepHash()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation original = new(definition, config, 17);
original.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1)));
var bytes = original.SaveState();
var loaded = Simulation.LoadState(bytes, definition, config);
Assert.Equal(original.CurrentTick, loaded.CurrentTick);
Assert.Equal(original.CurrentSnapshot.StateHash, loaded.CurrentSnapshot.StateHash);
var nextBatch = SimulationTestFactory.CreateTick(2, new ButtonChanged(new(1), InputButton.FirePrimary, true));
var originalHash = original.Step(nextBatch).StateHash;
var loadedHash = loaded.Step(nextBatch).StateHash;
Assert.Equal(originalHash, loadedHash);
}
[Fact]
public void Constructor_NormalizesZeroSeed()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 0);
simulation.Step(TickActionBatch.Empty(1));
Assert.NotEqual(0UL, simulation.CurrentState.RandomState);
}
}

View File

@@ -0,0 +1,46 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Runtime;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationStateTests
{
[Fact]
public void PlayerState_CloneAndButtonReleasePreserveIndependentState()
{
PlayerState player = new(new(1), new(3, 4), 1, 2, 5, 6, 7, 0);
player.SetButton(InputButton.Dash, true);
var clone = player.Clone();
player.SetButton(InputButton.Dash, false);
Assert.NotEqual(player.ButtonMask, clone.ButtonMask);
Assert.Equal(7, clone.SelectedWeaponSlot);
Assert.Equal(3, clone.Position.m_X.ToIntRound());
Assert.Equal(4, clone.Position.m_Y.ToIntRound());
}
[Fact]
public void SimulationState_CloneCreatesDeepCopy()
{
SimulationState original = new(4, 9, 123UL, 456UL, ImmutableArray.Create(new PlayerState(new(1), new(1, 2), 3, 4, 5, 6, 7, 8)));
var clone = original.Clone();
original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9);
Assert.Equal(4, clone.Tick);
Assert.Equal(9, clone.Seed);
Assert.Equal((ulong)123, clone.RandomState);
Assert.Equal((ulong)456, clone.LastRandomValue);
Assert.Equal(3, clone.GetRequiredPlayer(new(1)).MoveAxisX);
}
[Fact]
public void SimulationState_AcceptsDefaultPlayerArray()
{
SimulationState state = new(0, 1, 1UL, 0UL, default);
Assert.Empty(state.Players);
}
}

View File

@@ -0,0 +1,103 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationStepTests
{
private sealed record UnsupportedAction : SimulationAction;
[Fact]
public void Step_AdvancesTickSnapshotsAndMovement()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, -1), new AimAxisChanged(new(1), 30, 40), new ButtonChanged(new(1), InputButton.Jump, true), new WeaponSlotSelected(new(1), 3)));
Assert.Equal(1, simulation.CurrentTick);
Assert.Equal(0, simulation.PreviousSnapshot.Tick);
Assert.Equal(0, result.PreviousSnapshot.Tick);
Assert.Equal(1, result.CurrentSnapshot.Tick);
Assert.Single(result.Events);
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(12, player.Position.m_X.ToIntRound());
Assert.Equal(19, player.Position.m_Y.ToIntRound());
Assert.Equal(30, player.AimAxisX);
Assert.Equal(40, player.AimAxisY);
Assert.Equal(3, player.SelectedWeaponSlot);
Assert.NotEqual(0, player.ButtonMask);
Assert.Equal(result.StateHash, simulation.CurrentSnapshot.StateHash);
Assert.NotEqual(0UL, simulation.CurrentSnapshot.LastRandomValue);
}
[Fact]
public void Step_RejectsUnexpectedTickNumbers()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(TickActionBatch.Empty(2)));
Assert.Contains("Expected tick 1", exception.Message);
}
[Fact]
public void Step_RejectsUnknownPlayers()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(99), 1, 0))));
Assert.Contains("Unknown player id 99", exception.Message);
}
[Fact]
public void Step_RejectsUnsupportedActions()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<NotSupportedException>(() => simulation.Step(SimulationTestFactory.CreateTick(1, new UnsupportedAction())));
Assert.Contains("Unsupported action type", exception.Message);
}
[Fact]
public void Constructor_RejectsDuplicatePlayers()
{
GameDefinition definition = new(ImmutableArray.Create(new(new(1), new(0, 0)), new PlayerDefinition(new(1), new(2, 3))));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("Duplicate player id 1", exception.Message);
}
[Fact]
public void Constructor_RejectsNullDefinition()
{
var exception = Assert.Throws<ArgumentNullException>(() => new Simulation(null!, SimulationTestFactory.CreateConfig(), 1));
Assert.Equal("gameDefinition", exception.ParamName);
}
[Fact]
public void Constructor_RejectsNullConfig()
{
var exception = Assert.Throws<ArgumentNullException>(() => new Simulation(SimulationTestFactory.CreateGameDefinition(), null!, 1));
Assert.Equal("config", exception.ParamName);
}
[Fact]
public void LoadState_RejectsNullDefinition()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 2);
var bytes = simulation.SaveState();
var exception = Assert.Throws<ArgumentNullException>(() => Simulation.LoadState(bytes, null!, config));
Assert.Equal("gameDefinition", exception.ParamName);
}
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
internal static class SimulationTestFactory
{
public static GameDefinition CreateGameDefinition()
{
return new(ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20))));
}
public static SimulationConfig CreateConfig(VerificationMode verificationMode = VerificationMode.None)
{
return new(SimulationDefaults.DefaultTicksPerSecond, verificationMode);
}
public static TickActionBatch CreateTick(int tick, params SimulationAction[] actions)
{
return new(tick, ImmutableArray.Create(actions));
}
}

View File

@@ -0,0 +1,27 @@
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationVerificationTests
{
[Fact]
public void RoundTripStateMode_AllowsStepping()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(VerificationMode.RoundTripState), 22);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(1, result.CurrentSnapshot.Tick);
}
[Fact]
public void RoundTripAndStepCloneMode_AllowsStepping()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(VerificationMode.RoundTripAndStepClone), 23);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true)));
Assert.Equal(1, result.CurrentSnapshot.Tick);
}
}