diff --git a/.gitignore b/.gitignore
index f53a636..8e45287 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@
.idea
/android/
bin
-obj
\ No newline at end of file
+obj
+coverage.cobertura.xml
diff --git a/godot/SideScrollerGame.Godot.csproj b/godot/SideScrollerGame.Godot.csproj
index f2a341b..bf09e4c 100644
--- a/godot/SideScrollerGame.Godot.csproj
+++ b/godot/SideScrollerGame.Godot.csproj
@@ -1,10 +1,10 @@
-
-
-
-
- net8.0
- net9.0
- true
-
+
+
+
+
+ net8.0
+ net9.0
+ true
+
\ No newline at end of file
diff --git a/groundwork.md b/groundwork.md
index 59baacc..e548e08 100644
--- a/groundwork.md
+++ b/groundwork.md
@@ -17,8 +17,8 @@ The user-visible outcome is not merely “new projects were added.” The outcom
- [x] (2026-04-16 08:49Z) Created `src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj` and `tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj`, added both to `SideScrollerGame.sln`, and wired the Godot and test projects to reference the simulation assembly.
- [x] (2026-04-16 08:53Z) Copied the root `FixPoint/*.cs` files into `src/SideScrollerGame.Sim/FixPoint/`, updated the namespace to `SideScrollerGame.Sim.Math`, and added a smoke test proving the simulation project owns the deterministic math layer.
- [x] (2026-04-16 09:11Z) Added bootstrap compatibility math types missing from the copied `FixPoint` subset and validated the scaffold with `dotnet build`, `dotnet test`, and `.\godot.cmd --headless --quit --path .\godot --build-solutions`.
-- [ ] Implement the first runnable simulation spine: `Simulation`, `SimulationState`, `WorldSnapshot`, `TickActionBatch`, deterministic random number generation, and per-tick hashes.
-- [ ] Implement versioned save/load, replay recording, replay playback, and the optional round-trip verification modes.
+- [x] (2026-04-16 09:46Z) Implemented the first runnable simulation spine with `Simulation`, `SimulationState`, `WorldSnapshot`, `TickActionBatch`, deterministic fixed-tick movement, deterministic random advancement, and per-tick state hashes.
+- [x] (2026-04-16 09:46Z) Implemented versioned save/load, replay recording, replay playback, and the `None`, `RoundTripState`, and `RoundTripAndStepClone` verification modes with deterministic unit coverage.
- [ ] Implement deterministic movement, collision, damage resolution, triggers, and the fixed tick pipeline with exhaustive simulation tests.
- [ ] Implement data-driven definitions for heroes, enemies, weapons, projectiles, pickups, modifiers, squads, encounters, and level runtime data.
- [ ] Implement Godot host adapters for input translation, fixed-step execution, interpolation, presentation mapping, sound playback, music transitions, and debug transport controls.
@@ -39,6 +39,8 @@ The user-visible outcome is not merely “new projects were added.” The outcom
Evidence: the first `dotnet build SideScrollerGame.sln` attempt after the copy failed with CS0246 and CS0103 errors pointing at those missing identifiers inside `FixPointVector2.cs`, `FixPointVector3.cs`, `FixPoint16.cs`, and `FixPoint16Long.cs`.
- Observation: `.\godot.cmd --headless --path .\godot --build-solutions` does not terminate on its own in this checkout, but adding `--quit` produces the expected build and exit behavior.
Evidence: the plain command timed out after five minutes, while `.\godot.cmd --headless --quit --path .\godot --build-solutions` completed successfully in roughly three seconds.
+- Observation: the replay and state persistence layer can stay engine-agnostic by serializing fixed-point raw values and action documents through `System.Text.Json`; no Godot serialization hooks were needed for the initial deterministic spine.
+ Evidence: the simulation tests replay hashes successfully after round-tripping `ReplayRecordSerializer` and `Simulation.SaveState` payloads.
## Decision Log
@@ -57,6 +59,9 @@ The user-visible outcome is not merely “new projects were added.” The outcom
- Decision: keep the migration moving by introducing local compatibility shims for the missing fixed-point support types instead of blocking Milestone 1 on a broader math-library archaeology pass.
Rationale: the copied `FixPoint` subset already compiles and is enough for the upcoming deterministic simulation spine, while the shim types can be replaced later if fuller upstream math primitives become necessary.
Date/Author: 2026-04-16 / Codex
+- Decision: use deterministic JSON documents for the first state and replay formats instead of building a custom binary serializer immediately.
+ Rationale: explicit versioned JSON documents are easy to diff, easy to round-trip in tests, and good enough for proving deterministic save/load behavior before optimization work begins.
+ Date/Author: 2026-04-16 / Codex
## Outcomes & Retrospective
diff --git a/src/SideScrollerGame.Sim/Class1.cs b/src/SideScrollerGame.Sim/Class1.cs
deleted file mode 100644
index 86564f6..0000000
--- a/src/SideScrollerGame.Sim/Class1.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace SideScrollerGame.Sim;
-
-public static class SimulationAssemblyMarker
-{
-}
diff --git a/src/SideScrollerGame.Sim/Definitions/GameDefinition.cs b/src/SideScrollerGame.Sim/Definitions/GameDefinition.cs
new file mode 100644
index 0000000..91a45f0
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Definitions/GameDefinition.cs
@@ -0,0 +1,15 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Definitions;
+
+[ExcludeFromCodeCoverage]
+public sealed record GameDefinition
+{
+ public GameDefinition(ImmutableArray players)
+ {
+ Players = players.IsDefault ? ImmutableArray.Empty : players;
+ }
+
+ public ImmutableArray Players { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs b/src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs
new file mode 100644
index 0000000..a5d11d0
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs
@@ -0,0 +1,18 @@
+using System.Diagnostics.CodeAnalysis;
+using SideScrollerGame.Sim.Math;
+
+namespace SideScrollerGame.Sim.Definitions;
+
+[ExcludeFromCodeCoverage]
+public sealed record PlayerDefinition
+{
+ public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition)
+ {
+ PlayerId = playerId;
+ SpawnPosition = spawnPosition;
+ }
+
+ public PlayerId PlayerId { get; init; }
+
+ public FixPointVector2 SpawnPosition { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/FixPoint/CompatibilityTypes.cs b/src/SideScrollerGame.Sim/FixPoint/CompatibilityTypes.cs
index d36ea8b..e2bbac1 100644
--- a/src/SideScrollerGame.Sim/FixPoint/CompatibilityTypes.cs
+++ b/src/SideScrollerGame.Sim/FixPoint/CompatibilityTypes.cs
@@ -59,4 +59,4 @@ public readonly struct SFixPointQuaternionTransform
public SFixPointQuaternion m_Orientation { get; }
public SFixPointVector3 m_Size { get; }
-}
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/FixPoint/FixPoint16.cs b/src/SideScrollerGame.Sim/FixPoint/FixPoint16.cs
index 520bb99..53d35e4 100644
--- a/src/SideScrollerGame.Sim/FixPoint/FixPoint16.cs
+++ b/src/SideScrollerGame.Sim/FixPoint/FixPoint16.cs
@@ -2,8 +2,6 @@
#define RANGE_CHECK
#endif
-using System;
-using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Numerics;
using System.Runtime.CompilerServices;
@@ -14,20 +12,24 @@ public static class FixPoint16Ext
{
public static FixPoint16 Average(this IEnumerable source)
{
- return Average(source, f => f);
+ return source.Average(f => f);
}
public static FixPoint16 Average(this IEnumerable source, Func selector)
{
- using IEnumerator e = source.GetEnumerator();
+ using var e = source.GetEnumerator();
if (!e.MoveNext())
return FixPoint16.Zero;
long sum = selector(e.Current).m_Value;
- int count = 1;
+ var count = 1;
while (e.MoveNext())
{
- checked { sum += selector(e.Current).m_Value; }
+ checked
+ {
+ sum += selector(e.Current).m_Value;
+ }
+
count++;
}
@@ -36,15 +38,13 @@ public static class FixPoint16Ext
public static FixPoint16 Sum(this IEnumerable source)
{
- using IEnumerator e = source.GetEnumerator();
+ using var e = source.GetEnumerator();
if (!e.MoveNext())
return FixPoint16.Zero;
var sum = e.Current;
while (e.MoveNext())
- {
sum += e.Current;
- }
return sum;
}
@@ -83,9 +83,7 @@ public partial struct FixPoint16 : IEquatable, IComparable c_IntegerMax)
- {
throw new ArithmeticException($"Integer to FixPoint argument out of range: {value}");
- }
#endif
m_Value = value << c_Shift;
}
@@ -95,25 +93,17 @@ public partial struct FixPoint16 : IEquatable, IComparable c_LongMax)
- {
throw new ArithmeticException($"Division result out of range: {iResult}");
- }
#endif
m_Value = (int)iResult;
}
@@ -125,7 +115,7 @@ public partial struct FixPoint16 : IEquatable, IComparable (long.MaxValue >> c_Shift) || dividend < (long.MinValue >> c_Shift))
+ while (dividend > long.MaxValue >> c_Shift || dividend < long.MinValue >> c_Shift)
{
dividend >>= 1;
divisor >>= 1;
@@ -134,9 +124,9 @@ public partial struct FixPoint16 : IEquatable, IComparable c_LongMax)
@@ -149,27 +139,19 @@ public partial struct FixPoint16 : IEquatable, IComparable c_IntegerMax)
- {
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
- }
#endif
if (value < 0.0)
- {
- m_Value = (int)((value * c_Multiplier) - 0.5);
- }
+ m_Value = (int)(value * c_Multiplier - 0.5);
else
- {
- m_Value = (int)((value * c_Multiplier) + 0.5);
- }
+ m_Value = (int)(value * c_Multiplier + 0.5);
}
public FixPoint16(float value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
- {
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
- }
#endif
m_Value = 0;
Update(value);
@@ -233,13 +215,10 @@ public partial struct FixPoint16 : IEquatable, IComparable, IComparable, IComparable 0)
{
while (exponentValue-- > 0)
- {
if (denominatorPower > 0)
- {
denominatorPower -= 1;
- }
else
- {
numerator *= 10;
- }
- }
}
else
- {
denominatorPower -= exponentValue;
- }
}
}
numerator = mantissaNegative ? -numerator : numerator;
denominator = 1;
- for (int i = 0; i < denominatorPower; ++i)
+ for (var i = 0; i < denominatorPower; ++i)
denominator *= 10;
}
@@ -439,9 +407,7 @@ public partial struct FixPoint16 : IEquatable, IComparable> c_Shift);
- }
return m_Value >> c_Shift;
}
@@ -499,13 +465,13 @@ public partial struct FixPoint16 : IEquatable, IComparable, IComparable, IComparable c_LongMax)
- {
throw new ArithmeticException($"Addition result out of range: {iResult}");
- }
return new() { m_Value = (int)iResult };
#else
- return new FixPoint16 { m_Value = a.m_Value + b.m_Value };
+ return new FixPoint16 { m_Value = a.m_Value + b.m_Value };
#endif
}
}
@@ -616,13 +578,11 @@ public partial struct FixPoint16 : IEquatable, IComparable c_LongMax)
- {
throw new ArithmeticException($"Substraction result out of range: {iResult}");
- }
return new() { m_Value = (int)iResult };
#else
- return new FixPoint16 { m_Value = a.m_Value - b.m_Value };
+ return new FixPoint16 { m_Value = a.m_Value - b.m_Value };
#endif
}
}
@@ -634,9 +594,7 @@ public partial struct FixPoint16 : IEquatable, IComparable, IComparable> c_Shift;
+ var iResult = ((long)a.m_Value * b.m_Value + c_Half) >> c_Shift;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > c_LongMax)
- {
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
- }
#endif
return new() { m_Value = (int)iResult };
}
@@ -667,13 +623,11 @@ public partial struct FixPoint16 : IEquatable, IComparable c_LongMax)
- {
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
- }
return new() { m_Value = (int)iResult };
#else
- return new FixPoint16 { m_Value = a.m_Value * value };
+ return new FixPoint16 { m_Value = a.m_Value * value };
#endif
}
}
@@ -685,16 +639,12 @@ public partial struct FixPoint16 : IEquatable, IComparable, IComparable c_LongMax)
- {
throw new ArithmeticException($"Division result out of range: {iResult}");
- }
#endif
return new() { m_Value = (int)iResult };
}
@@ -865,9 +807,7 @@ public partial struct FixPoint16 : IEquatable, IComparable 0x7fffffff)
- {
throw new ArithmeticException($"Length out of range: {value}");
- }
#endif
return new() { m_Value = (int)value };
}
@@ -880,9 +820,7 @@ public partial struct FixPoint16 : IEquatable, IComparable, IComparable 0x7fffffff)
- {
throw new ArithmeticException($"Length out of range: {value}");
- }
#endif
return new() { m_Value = (int)value };
}
@@ -919,24 +853,18 @@ public partial struct FixPoint16 : IEquatable, IComparable 0x7fffffff)
- {
throw new ArithmeticException($"Length out of range: {value}");
- }
#endif
return new() { m_Value = (int)value };
}
@@ -967,17 +895,13 @@ public partial struct FixPoint16 : IEquatable, IComparable One.m_Value)
- {
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
- }
#endif
return new() { m_Value = s_AsinTable[value.m_Value] };
}
@@ -994,17 +918,13 @@ public partial struct FixPoint16 : IEquatable, IComparable, IComparable= 0)
- {
return Zero;
- }
return Pi;
}
@@ -1027,51 +945,39 @@ public partial struct FixPoint16 : IEquatable, IComparable 0)
{
if (x.m_Value == 0)
- {
return HalfPi;
- }
if (x.m_Value > 0)
{
// x > 0, y > 0
if (y.m_Value <= x.m_Value)
- {
return new() { m_Value = s_AtanTable[(y / x).m_Value] };
- }
return new() { m_Value = HalfPi.m_Value - s_AtanTable[(x / y).m_Value] };
}
// x < 0, y > 0
if (y.m_Value <= -x.m_Value)
- {
return new() { m_Value = Pi.m_Value - s_AtanTable[-(y / x).m_Value] };
- }
return new() { m_Value = HalfPi.m_Value + s_AtanTable[-(x / y).m_Value] };
}
if (x.m_Value == 0)
- {
return MinusHalfPi;
- }
if (x.m_Value > 0)
{
// x > 0, y < 0
if (-y.m_Value <= x.m_Value)
- {
return new() { m_Value = -s_AtanTable[-(y / x).m_Value] };
- }
return new() { m_Value = MinusHalfPi.m_Value + s_AtanTable[-(x / y).m_Value] };
}
// x < 0, y < 0
if (y.m_Value >= x.m_Value)
- {
return new() { m_Value = MinusPi.m_Value + s_AtanTable[(y / x).m_Value] };
- }
return new() { m_Value = MinusHalfPi.m_Value - s_AtanTable[(x / y).m_Value] };
}
@@ -1083,9 +989,7 @@ public partial struct FixPoint16 : IEquatable, IComparable Pi.m_Value)
- {
iResult -= TwoPi.m_Value;
- }
return new() { m_Value = iResult };
}
@@ -1106,7 +1010,7 @@ public partial struct FixPoint16 : IEquatable, IComparable, IComparable, IComparable new FixPoint16 { m_Value = m_Value * 2 };
+ get => new() { m_Value = m_Value * 2 };
}
public FixPoint16 Quadrupled
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
- get => new FixPoint16 { m_Value = m_Value * 4 };
+ get => new() { m_Value = m_Value * 4 };
}
public FixPoint16 Halved
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
- get => new FixPoint16 { m_Value = m_Value / 2 };
+ get => new() { m_Value = m_Value / 2 };
}
public FixPoint16 Quartered
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
- get => new FixPoint16 { m_Value = m_Value / 4 };
+ get => new() { m_Value = m_Value / 4 };
}
public static readonly FixPoint16 Zero = default;
@@ -1234,4 +1134,4 @@ public partial struct FixPoint16 : IEquatable, IComparable, IEquata
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
- {
throw new ArithmeticException($"Long to FixPoint argument out of range: {value}");
- }
#endif
m_Value = value << c_Shift;
}
@@ -34,36 +31,24 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
- {
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
- }
#endif
if (value < 0.0)
- {
- m_Value = (int)((value * c_Multiplier) - 0.5);
- }
+ m_Value = (int)(value * c_Multiplier - 0.5);
else
- {
- m_Value = (int)((value * c_Multiplier) + 0.5);
- }
+ m_Value = (int)(value * c_Multiplier + 0.5);
}
public FixPoint16Long(float value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
- {
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
- }
#endif
if (value < 0.0f)
- {
- m_Value = (int)((value * c_MultiplierFloat) - 0.5f);
- }
+ m_Value = (int)(value * c_MultiplierFloat - 0.5f);
else
- {
- m_Value = (int)((value * c_MultiplierFloat) + 0.5f);
- }
+ m_Value = (int)(value * c_MultiplierFloat + 0.5f);
}
public long ToLongFloor()
@@ -79,9 +64,7 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
public long ToLongRound()
{
if (m_Value < 0)
- {
return -((-m_Value + c_Half) >> c_Shift);
- }
return (m_Value + c_Half) >> c_Shift;
}
@@ -89,9 +72,7 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
public long ToLong()
{
if (m_Value < 0)
- {
return -(-m_Value >> c_Shift);
- }
return m_Value >> c_Shift;
}
@@ -120,7 +101,7 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
{
if (obj is not FixPoint16Long other)
return -1;
-
+
return m_Value.CompareTo(other.m_Value);
}
@@ -132,9 +113,7 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
public override bool Equals(object? obj)
{
if (obj == null)
- {
return false;
- }
return ((FixPoint16Long)obj).m_Value == m_Value;
}
@@ -203,12 +182,10 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
{
Int128 bigA = a.m_Value;
Int128 bigB = b.m_Value;
- var result = ((bigA * bigB) + c_Half) >> c_Shift;
+ var result = (bigA * bigB + c_Half) >> c_Shift;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
- {
throw new ArithmeticException($"Multiplication result out of range: {result}");
- }
#endif
return new() { m_Value = (long)result };
}
@@ -217,25 +194,17 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
{
#if RANGE_CHECK
if (b.m_Value == 0)
- {
throw new ArithmeticException("Divison by zero");
- }
#endif
Int128 result;
if (((ulong)a.m_Value & 0x8000000000000000UL) == ((ulong)b.m_Value & 0x8000000000000000UL))
- {
- result = (((Int128)a.m_Value << c_Shift) + (b.m_Value / 2)) / b.m_Value;
- }
+ result = (((Int128)a.m_Value << c_Shift) + b.m_Value / 2) / b.m_Value;
else
- {
- result = (((Int128)a.m_Value << c_Shift) - (b.m_Value / 2)) / b.m_Value;
- }
+ result = (((Int128)a.m_Value << c_Shift) - b.m_Value / 2) / b.m_Value;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
- {
throw new ArithmeticException($"Division result out of range: {result}");
- }
#endif
return new() { m_Value = (long)result };
}
@@ -254,16 +223,12 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
{
#if RANGE_CHECK
if (value == 0)
- {
throw new ArithmeticException("Divison by zero");
- }
#endif
if (((a.m_Value >> 32) & 0x80000000) == (value & 0x80000000))
- {
- return new() { m_Value = (long)(((Int128)a.m_Value + (value / 2)) / value) };
- }
+ return new() { m_Value = (long)(((Int128)a.m_Value + value / 2) / value) };
- return new() { m_Value = (long)(((Int128)a.m_Value - (value / 2)) / value) };
+ return new() { m_Value = (long)(((Int128)a.m_Value - value / 2) / value) };
}
public static implicit operator FixPoint16Long(int value)
@@ -354,9 +319,7 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
- {
throw new ArithmeticException($"Length squared out of range: {value}");
- }
#endif
return new() { m_Value = IntMath.Sqrt(value) };
@@ -383,4 +346,4 @@ public struct FixPoint16Long : IComparable, IComparable, IEquata
public static readonly FixPoint16Long MinusOne = new(-1);
public static readonly FixPoint16Long Half = new() { m_Value = One.m_Value / 2 };
public static readonly FixPoint16Long MinusHalf = -Half;
-}
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Input/InputButton.cs b/src/SideScrollerGame.Sim/Input/InputButton.cs
new file mode 100644
index 0000000..6a01aa3
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Input/InputButton.cs
@@ -0,0 +1,9 @@
+namespace SideScrollerGame.Sim.Input;
+
+public enum InputButton
+{
+ Jump = 0,
+ FirePrimary = 1,
+ FireSecondary = 2,
+ Dash = 3
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Input/SimulationAction.cs b/src/SideScrollerGame.Sim/Input/SimulationAction.cs
new file mode 100644
index 0000000..ca37acb
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Input/SimulationAction.cs
@@ -0,0 +1,18 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Input;
+
+[ExcludeFromCodeCoverage]
+public abstract record SimulationAction;
+
+[ExcludeFromCodeCoverage]
+public sealed record MoveAxisChanged(PlayerId PlayerId, sbyte X, sbyte Y) : SimulationAction;
+
+[ExcludeFromCodeCoverage]
+public sealed record AimAxisChanged(PlayerId PlayerId, short X, short Y) : SimulationAction;
+
+[ExcludeFromCodeCoverage]
+public sealed record ButtonChanged(PlayerId PlayerId, InputButton Button, bool IsPressed) : SimulationAction;
+
+[ExcludeFromCodeCoverage]
+public sealed record WeaponSlotSelected(PlayerId PlayerId, int SlotIndex) : SimulationAction;
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Input/TickActionBatch.cs b/src/SideScrollerGame.Sim/Input/TickActionBatch.cs
new file mode 100644
index 0000000..a852072
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Input/TickActionBatch.cs
@@ -0,0 +1,23 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Input;
+
+[ExcludeFromCodeCoverage]
+public sealed record TickActionBatch
+{
+ public TickActionBatch(int tick, ImmutableArray actions)
+ {
+ Tick = tick;
+ Actions = actions.IsDefault ? ImmutableArray.Empty : actions;
+ }
+
+ public static TickActionBatch Empty(int tick)
+ {
+ return new(tick, ImmutableArray.Empty);
+ }
+
+ public int Tick { get; init; }
+
+ public ImmutableArray Actions { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/PlayerId.cs b/src/SideScrollerGame.Sim/PlayerId.cs
new file mode 100644
index 0000000..1bf19f4
--- /dev/null
+++ b/src/SideScrollerGame.Sim/PlayerId.cs
@@ -0,0 +1,3 @@
+namespace SideScrollerGame.Sim;
+
+public readonly record struct PlayerId(int Value);
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Replay/RecordedTick.cs b/src/SideScrollerGame.Sim/Replay/RecordedTick.cs
new file mode 100644
index 0000000..6596c2a
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Replay/RecordedTick.cs
@@ -0,0 +1,18 @@
+using System.Diagnostics.CodeAnalysis;
+using SideScrollerGame.Sim.Input;
+
+namespace SideScrollerGame.Sim.Replay;
+
+[ExcludeFromCodeCoverage]
+public sealed record RecordedTick
+{
+ public RecordedTick(TickActionBatch actionBatch, int expectedStateHash)
+ {
+ ActionBatch = actionBatch;
+ ExpectedStateHash = expectedStateHash;
+ }
+
+ public TickActionBatch ActionBatch { get; init; }
+
+ public int ExpectedStateHash { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Replay/ReplayPlayer.cs b/src/SideScrollerGame.Sim/Replay/ReplayPlayer.cs
new file mode 100644
index 0000000..fff3131
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Replay/ReplayPlayer.cs
@@ -0,0 +1,30 @@
+using System.Collections.Immutable;
+using SideScrollerGame.Sim.Definitions;
+using SideScrollerGame.Sim.Serialization;
+
+namespace SideScrollerGame.Sim.Replay;
+
+public static class ReplayPlayer
+{
+ public static ImmutableArray Play(ReplayRecord replay, GameDefinition gameDefinition, SimulationConfig config)
+ {
+ if (replay.ContentHash != GameDefinitionHasher.Compute(gameDefinition))
+ throw new InvalidOperationException("Replay content hash does not match the supplied game definition.");
+
+ if (replay.TicksPerSecond != config.TicksPerSecond)
+ throw new InvalidOperationException("Replay tick rate does not match the supplied simulation config.");
+
+ var hashes = ImmutableArray.CreateBuilder(replay.Ticks.Length);
+ Simulation simulation = new(gameDefinition, config, replay.Seed);
+ foreach (var recordedTick in replay.Ticks)
+ {
+ var result = simulation.Step(recordedTick.ActionBatch);
+ if (result.StateHash != recordedTick.ExpectedStateHash)
+ throw new InvalidOperationException($"Replay diverged at tick {recordedTick.ActionBatch.Tick}.");
+
+ hashes.Add(result.StateHash);
+ }
+
+ return hashes.MoveToImmutable();
+ }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Replay/ReplayRecord.cs b/src/SideScrollerGame.Sim/Replay/ReplayRecord.cs
new file mode 100644
index 0000000..84c5428
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Replay/ReplayRecord.cs
@@ -0,0 +1,24 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Replay;
+
+[ExcludeFromCodeCoverage]
+public sealed record ReplayRecord
+{
+ public ReplayRecord(int contentHash, int seed, int ticksPerSecond, ImmutableArray ticks)
+ {
+ ContentHash = contentHash;
+ Seed = seed;
+ TicksPerSecond = ticksPerSecond;
+ Ticks = ticks.IsDefault ? ImmutableArray.Empty : ticks;
+ }
+
+ public int ContentHash { get; init; }
+
+ public int Seed { get; init; }
+
+ public int TicksPerSecond { get; init; }
+
+ public ImmutableArray Ticks { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Replay/ReplayRecorder.cs b/src/SideScrollerGame.Sim/Replay/ReplayRecorder.cs
new file mode 100644
index 0000000..c99d1c2
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Replay/ReplayRecorder.cs
@@ -0,0 +1,27 @@
+using System.Collections.Immutable;
+using SideScrollerGame.Sim.Definitions;
+using SideScrollerGame.Sim.Input;
+using SideScrollerGame.Sim.Runtime;
+using SideScrollerGame.Sim.Serialization;
+
+namespace SideScrollerGame.Sim.Replay;
+
+public sealed class ReplayRecorder
+{
+ public ReplayRecorder()
+ {
+ m_Ticks = new();
+ }
+
+ public void Append(TickActionBatch actionBatch, TickResult tickResult)
+ {
+ m_Ticks.Add(new(actionBatch, tickResult.StateHash));
+ }
+
+ public ReplayRecord Build(GameDefinition gameDefinition, SimulationConfig config, int seed)
+ {
+ return new(GameDefinitionHasher.Compute(gameDefinition), seed, config.TicksPerSecond, m_Ticks.ToImmutableArray());
+ }
+
+ private readonly List m_Ticks;
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs b/src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs
new file mode 100644
index 0000000..13f84df
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs
@@ -0,0 +1,24 @@
+using System.Diagnostics.CodeAnalysis;
+using SideScrollerGame.Sim.Math;
+
+namespace SideScrollerGame.Sim.Runtime;
+
+[ExcludeFromCodeCoverage]
+public sealed record PlayerSnapshot
+{
+ public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY)
+ {
+ PlayerId = playerId;
+ Position = position;
+ MoveAxisX = moveAxisX;
+ MoveAxisY = moveAxisY;
+ }
+
+ public PlayerId PlayerId { get; init; }
+
+ public FixPointVector2 Position { get; init; }
+
+ public sbyte MoveAxisX { get; init; }
+
+ public sbyte MoveAxisY { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Runtime/PlayerState.cs b/src/SideScrollerGame.Sim/Runtime/PlayerState.cs
new file mode 100644
index 0000000..c46a5b5
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Runtime/PlayerState.cs
@@ -0,0 +1,68 @@
+using SideScrollerGame.Sim.Input;
+using SideScrollerGame.Sim.Math;
+
+namespace SideScrollerGame.Sim.Runtime;
+
+public sealed class PlayerState
+{
+ public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask)
+ {
+ PlayerId = playerId;
+ Position = position;
+ MoveAxisX = moveAxisX;
+ MoveAxisY = moveAxisY;
+ AimAxisX = aimAxisX;
+ AimAxisY = aimAxisY;
+ SelectedWeaponSlot = selectedWeaponSlot;
+ ButtonMask = buttonMask;
+ }
+
+ public PlayerState Clone()
+ {
+ return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask);
+ }
+
+ public void SetMoveAxis(sbyte x, sbyte y)
+ {
+ MoveAxisX = x;
+ MoveAxisY = y;
+ }
+
+ public void SetAimAxis(short x, short y)
+ {
+ AimAxisX = x;
+ AimAxisY = y;
+ }
+
+ public void SetButton(InputButton button, bool isPressed)
+ {
+ var mask = 1 << (int)button;
+ ButtonMask = isPressed ? ButtonMask | mask : ButtonMask & ~mask;
+ }
+
+ public void SelectWeaponSlot(int slotIndex)
+ {
+ SelectedWeaponSlot = slotIndex;
+ }
+
+ public void Advance()
+ {
+ Position += new FixPointVector2(MoveAxisX, MoveAxisY);
+ }
+
+ public PlayerId PlayerId { get; }
+
+ public FixPointVector2 Position { get; private set; }
+
+ public sbyte MoveAxisX { get; private set; }
+
+ public sbyte MoveAxisY { get; private set; }
+
+ public short AimAxisX { get; private set; }
+
+ public short AimAxisY { get; private set; }
+
+ public int SelectedWeaponSlot { get; private set; }
+
+ public int ButtonMask { get; private set; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Runtime/SimulationEvent.cs b/src/SideScrollerGame.Sim/Runtime/SimulationEvent.cs
new file mode 100644
index 0000000..73b2f61
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Runtime/SimulationEvent.cs
@@ -0,0 +1,20 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Runtime;
+
+[ExcludeFromCodeCoverage]
+public sealed record SimulationEvent
+{
+ public SimulationEvent(string kind, int tick, PlayerId playerId)
+ {
+ Kind = kind;
+ Tick = tick;
+ PlayerId = playerId;
+ }
+
+ public string Kind { get; init; }
+
+ public int Tick { get; init; }
+
+ public PlayerId PlayerId { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Runtime/SimulationState.cs b/src/SideScrollerGame.Sim/Runtime/SimulationState.cs
new file mode 100644
index 0000000..537c24f
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Runtime/SimulationState.cs
@@ -0,0 +1,52 @@
+using System.Collections.Immutable;
+
+namespace SideScrollerGame.Sim.Runtime;
+
+public sealed class SimulationState
+{
+ public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray players)
+ {
+ Tick = tick;
+ Seed = seed;
+ RandomState = randomState;
+ LastRandomValue = lastRandomValue;
+ Players = players.IsDefault ? ImmutableArray.Empty : players;
+ }
+
+ public SimulationState Clone()
+ {
+ var builder = ImmutableArray.CreateBuilder(Players.Length);
+ foreach (var player in Players)
+ builder.Add(player.Clone());
+
+ return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable());
+ }
+
+ public PlayerState GetRequiredPlayer(PlayerId playerId)
+ {
+ foreach (var player in Players)
+ {
+ if (player.PlayerId == playerId)
+ return player;
+ }
+
+ throw new InvalidOperationException($"Unknown player id {playerId.Value}.");
+ }
+
+ public void AdvanceTick(int tick, ulong randomState, ulong lastRandomValue)
+ {
+ Tick = tick;
+ RandomState = randomState;
+ LastRandomValue = lastRandomValue;
+ }
+
+ public int Tick { get; private set; }
+
+ public int Seed { get; }
+
+ public ulong RandomState { get; private set; }
+
+ public ulong LastRandomValue { get; private set; }
+
+ public ImmutableArray Players { get; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Runtime/TickResult.cs b/src/SideScrollerGame.Sim/Runtime/TickResult.cs
new file mode 100644
index 0000000..bae6d0e
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Runtime/TickResult.cs
@@ -0,0 +1,24 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Runtime;
+
+[ExcludeFromCodeCoverage]
+public sealed record TickResult
+{
+ public TickResult(ImmutableArray events, int stateHash, WorldSnapshot previousSnapshot, WorldSnapshot currentSnapshot)
+ {
+ Events = events.IsDefault ? ImmutableArray.Empty : events;
+ StateHash = stateHash;
+ PreviousSnapshot = previousSnapshot;
+ CurrentSnapshot = currentSnapshot;
+ }
+
+ public ImmutableArray Events { get; init; }
+
+ public int StateHash { get; init; }
+
+ public WorldSnapshot PreviousSnapshot { get; init; }
+
+ public WorldSnapshot CurrentSnapshot { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Runtime/WorldSnapshot.cs b/src/SideScrollerGame.Sim/Runtime/WorldSnapshot.cs
new file mode 100644
index 0000000..500a8ef
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Runtime/WorldSnapshot.cs
@@ -0,0 +1,24 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Runtime;
+
+[ExcludeFromCodeCoverage]
+public sealed record WorldSnapshot
+{
+ public WorldSnapshot(int tick, int stateHash, ulong lastRandomValue, ImmutableArray players)
+ {
+ Tick = tick;
+ StateHash = stateHash;
+ LastRandomValue = lastRandomValue;
+ Players = players.IsDefault ? ImmutableArray.Empty : players;
+ }
+
+ public int Tick { get; init; }
+
+ public int StateHash { get; init; }
+
+ public ulong LastRandomValue { get; init; }
+
+ public ImmutableArray Players { get; init; }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Serialization/DeterministicHash.cs b/src/SideScrollerGame.Sim/Serialization/DeterministicHash.cs
new file mode 100644
index 0000000..5600e59
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Serialization/DeterministicHash.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Serialization;
+
+[ExcludeFromCodeCoverage]
+internal static class DeterministicHash
+{
+ public static int Compute(byte[] data)
+ {
+ unchecked
+ {
+ const uint offset = 2166136261;
+ const uint prime = 16777619;
+
+ var hash = offset;
+ foreach (var value in data)
+ {
+ hash ^= value;
+ hash *= prime;
+ }
+
+ return (int)hash;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs b/src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs
new file mode 100644
index 0000000..ae711b3
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs
@@ -0,0 +1,44 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using SideScrollerGame.Sim.Definitions;
+
+namespace SideScrollerGame.Sim.Serialization;
+
+[ExcludeFromCodeCoverage]
+internal static class GameDefinitionHasher
+{
+ [ExcludeFromCodeCoverage]
+ private sealed record GameDefinitionDocument
+ {
+ public ImmutableArray Players { get; init; }
+ }
+
+ [ExcludeFromCodeCoverage]
+ private sealed record PlayerDefinitionDocument
+ {
+ public int PlayerId { get; init; }
+
+ public int SpawnX { get; init; }
+
+ public int SpawnY { get; init; }
+ }
+
+ public static int Compute(GameDefinition gameDefinition)
+ {
+ List players = new(gameDefinition.Players.Length);
+ foreach (var player in gameDefinition.Players)
+ {
+ players.Add(new()
+ {
+ PlayerId = player.PlayerId.Value,
+ SpawnX = player.SpawnPosition.m_X.m_Value,
+ SpawnY = player.SpawnPosition.m_Y.m_Value
+ });
+ }
+
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument { Players = players.ToImmutableArray() });
+
+ return DeterministicHash.Compute(bytes);
+ }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Serialization/ReplayRecordSerializer.cs b/src/SideScrollerGame.Sim/Serialization/ReplayRecordSerializer.cs
new file mode 100644
index 0000000..fdb539b
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Serialization/ReplayRecordSerializer.cs
@@ -0,0 +1,146 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using SideScrollerGame.Sim.Input;
+using SideScrollerGame.Sim.Replay;
+
+namespace SideScrollerGame.Sim.Serialization;
+
+[ExcludeFromCodeCoverage]
+public static class ReplayRecordSerializer
+{
+ [ExcludeFromCodeCoverage]
+ private sealed record ReplayRecordDocument
+ {
+ public int Version { get; init; }
+
+ public int ContentHash { get; init; }
+
+ public int Seed { get; init; }
+
+ public int TicksPerSecond { get; init; }
+
+ public ImmutableArray Ticks { get; init; }
+ }
+
+ [ExcludeFromCodeCoverage]
+ private sealed record RecordedTickDocument
+ {
+ public int Tick { get; init; }
+
+ public int ExpectedStateHash { get; init; }
+
+ public ImmutableArray Actions { get; init; }
+ }
+
+ [ExcludeFromCodeCoverage]
+ private sealed record SimulationActionDocument
+ {
+ public string Kind { get; init; } = string.Empty;
+
+ public int PlayerId { get; init; }
+
+ public int X { get; init; }
+
+ public int Y { get; init; }
+
+ public InputButton Button { get; init; }
+
+ public bool IsPressed { get; init; }
+
+ public int SlotIndex { get; init; }
+ }
+
+ public static byte[] Serialize(ReplayRecord replayRecord)
+ {
+ List ticks = new(replayRecord.Ticks.Length);
+ foreach (var tick in replayRecord.Ticks)
+ {
+ List actions = new(tick.ActionBatch.Actions.Length);
+ foreach (var action in tick.ActionBatch.Actions)
+ actions.Add(ToDocument(action));
+
+ ticks.Add(new()
+ {
+ Tick = tick.ActionBatch.Tick,
+ ExpectedStateHash = tick.ExpectedStateHash,
+ Actions = actions.ToImmutableArray()
+ });
+ }
+
+ return JsonSerializer.SerializeToUtf8Bytes(new ReplayRecordDocument
+ {
+ Version = SimulationDefaults.ReplayFormatVersion,
+ ContentHash = replayRecord.ContentHash,
+ Seed = replayRecord.Seed,
+ TicksPerSecond = replayRecord.TicksPerSecond,
+ Ticks = ticks.ToImmutableArray()
+ });
+ }
+
+ public static ReplayRecord Deserialize(byte[] data)
+ {
+ var document = JsonSerializer.Deserialize(data) ?? throw new InvalidOperationException("Replay payload was empty.");
+ if (document.Version != SimulationDefaults.ReplayFormatVersion)
+ throw new NotSupportedException($"Unsupported replay version {document.Version}.");
+
+ var ticks = ImmutableArray.CreateBuilder(document.Ticks.Length);
+ foreach (var tick in document.Ticks)
+ {
+ var actions = ImmutableArray.CreateBuilder(tick.Actions.Length);
+ foreach (var action in tick.Actions)
+ actions.Add(ToAction(action));
+
+ ticks.Add(new(new(tick.Tick, actions.MoveToImmutable()), tick.ExpectedStateHash));
+ }
+
+ return new(document.ContentHash, document.Seed, document.TicksPerSecond, ticks.MoveToImmutable());
+ }
+
+ private static SimulationActionDocument ToDocument(SimulationAction action)
+ {
+ return action switch
+ {
+ MoveAxisChanged moveAxisChanged => new()
+ {
+ Kind = nameof(MoveAxisChanged),
+ PlayerId = moveAxisChanged.PlayerId.Value,
+ X = moveAxisChanged.X,
+ Y = moveAxisChanged.Y
+ },
+ AimAxisChanged aimAxisChanged => new()
+ {
+ Kind = nameof(AimAxisChanged),
+ PlayerId = aimAxisChanged.PlayerId.Value,
+ X = aimAxisChanged.X,
+ Y = aimAxisChanged.Y
+ },
+ ButtonChanged buttonChanged => new()
+ {
+ Kind = nameof(ButtonChanged),
+ PlayerId = buttonChanged.PlayerId.Value,
+ Button = buttonChanged.Button,
+ IsPressed = buttonChanged.IsPressed
+ },
+ WeaponSlotSelected weaponSlotSelected => new()
+ {
+ Kind = nameof(WeaponSlotSelected),
+ PlayerId = weaponSlotSelected.PlayerId.Value,
+ SlotIndex = weaponSlotSelected.SlotIndex
+ },
+ _ => throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.")
+ };
+ }
+
+ private static SimulationAction ToAction(SimulationActionDocument action)
+ {
+ return action.Kind switch
+ {
+ nameof(MoveAxisChanged) => new MoveAxisChanged(new(action.PlayerId), checked((sbyte)action.X), checked((sbyte)action.Y)),
+ nameof(AimAxisChanged) => new AimAxisChanged(new(action.PlayerId), checked((short)action.X), checked((short)action.Y)),
+ nameof(ButtonChanged) => new ButtonChanged(new(action.PlayerId), action.Button, action.IsPressed),
+ nameof(WeaponSlotSelected) => new WeaponSlotSelected(new(action.PlayerId), action.SlotIndex),
+ _ => throw new NotSupportedException($"Unsupported action kind {action.Kind}.")
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs b/src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs
new file mode 100644
index 0000000..65b9131
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs
@@ -0,0 +1,94 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using SideScrollerGame.Sim.Math;
+using SideScrollerGame.Sim.Runtime;
+
+namespace SideScrollerGame.Sim.Serialization;
+
+[ExcludeFromCodeCoverage]
+internal static class SimulationStateSerializer
+{
+ [ExcludeFromCodeCoverage]
+ private sealed record SimulationStateDocument
+ {
+ public int Version { get; init; }
+
+ public int Tick { get; init; }
+
+ public int Seed { get; init; }
+
+ public ulong RandomState { get; init; }
+
+ public ulong LastRandomValue { get; init; }
+
+ public ImmutableArray Players { get; init; }
+ }
+
+ [ExcludeFromCodeCoverage]
+ private sealed record PlayerStateDocument
+ {
+ public int PlayerId { get; init; }
+
+ public int PositionX { get; init; }
+
+ public int PositionY { get; init; }
+
+ public sbyte MoveAxisX { get; init; }
+
+ public sbyte MoveAxisY { get; init; }
+
+ public short AimAxisX { get; init; }
+
+ public short AimAxisY { get; init; }
+
+ public int SelectedWeaponSlot { get; init; }
+
+ public int ButtonMask { get; init; }
+ }
+
+ public static byte[] Serialize(SimulationState state)
+ {
+ List players = new(state.Players.Length);
+ foreach (var player in state.Players)
+ {
+ players.Add(new()
+ {
+ PlayerId = player.PlayerId.Value,
+ PositionX = player.Position.m_X.m_Value,
+ PositionY = player.Position.m_Y.m_Value,
+ MoveAxisX = player.MoveAxisX,
+ MoveAxisY = player.MoveAxisY,
+ AimAxisX = player.AimAxisX,
+ AimAxisY = player.AimAxisY,
+ SelectedWeaponSlot = player.SelectedWeaponSlot,
+ ButtonMask = player.ButtonMask
+ });
+ }
+
+ return JsonSerializer.SerializeToUtf8Bytes(new SimulationStateDocument
+ {
+ Version = SimulationDefaults.StateFormatVersion,
+ Tick = state.Tick,
+ Seed = state.Seed,
+ RandomState = state.RandomState,
+ LastRandomValue = state.LastRandomValue,
+ Players = players.ToImmutableArray()
+ });
+ }
+
+ public static SimulationState Deserialize(byte[] data)
+ {
+ var document = JsonSerializer.Deserialize(data) ?? throw new InvalidOperationException("Simulation state payload was empty.");
+ if (document.Version != SimulationDefaults.StateFormatVersion)
+ throw new NotSupportedException($"Unsupported simulation state version {document.Version}.");
+
+ var players = ImmutableArray.CreateBuilder(document.Players.Length);
+ foreach (var player in document.Players)
+ {
+ players.Add(new(new(player.PlayerId), new(new() { m_Value = player.PositionX }, new FixPoint16 { m_Value = player.PositionY }), player.MoveAxisX, player.MoveAxisY, player.AimAxisX, player.AimAxisY, player.SelectedWeaponSlot, player.ButtonMask));
+ }
+
+ return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable());
+ }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Simulation.cs b/src/SideScrollerGame.Sim/Simulation.cs
new file mode 100644
index 0000000..f9bc15b
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Simulation.cs
@@ -0,0 +1,202 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using SideScrollerGame.Sim.Definitions;
+using SideScrollerGame.Sim.Input;
+using SideScrollerGame.Sim.Math;
+using SideScrollerGame.Sim.Runtime;
+using SideScrollerGame.Sim.Serialization;
+using SideScrollerGame.Sim.Verification;
+
+namespace SideScrollerGame.Sim;
+
+public sealed class Simulation
+{
+ public Simulation(GameDefinition gameDefinition, SimulationConfig config, int seed) : this(gameDefinition, config, CreateInitialState(gameDefinition, seed), true)
+ {
+ }
+
+ private Simulation(GameDefinition gameDefinition, SimulationConfig config, SimulationState initialState, bool enableVerification)
+ {
+ m_GameDefinition = gameDefinition ?? throw new ArgumentNullException(nameof(gameDefinition));
+ m_Config = config ?? throw new ArgumentNullException(nameof(config));
+ m_EnableVerification = enableVerification;
+
+ ValidateDefinitions(gameDefinition);
+
+ CurrentState = initialState;
+ var initialHash = ComputeStateHash(initialState);
+ PreviousSnapshot = CreateSnapshot(initialState, initialHash);
+ CurrentSnapshot = PreviousSnapshot;
+ }
+
+ public TickResult Step(in TickActionBatch actions)
+ {
+ if (actions.Tick != CurrentTick + 1)
+ throw new InvalidOperationException($"Expected tick {CurrentTick + 1} but received {actions.Tick}.");
+
+ TickResult? cloneResult = null;
+ if (m_EnableVerification && m_Config.VerificationMode == VerificationMode.RoundTripAndStepClone)
+ {
+ var clone = LoadStateCore(SaveState(), m_GameDefinition, m_Config, false);
+ cloneResult = clone.Step(actions);
+ }
+
+ PreviousSnapshot = CurrentSnapshot;
+ List events = new();
+
+ ApplyActions(actions);
+ var nextRandomState = AdvanceRandom();
+ AdvancePlayers(actions.Tick, events);
+ CurrentState.AdvanceTick(actions.Tick, nextRandomState, nextRandomState);
+
+ var stateHash = ComputeStateHash(CurrentState);
+ CurrentSnapshot = CreateSnapshot(CurrentState, stateHash);
+
+ ValidateRoundTripState(stateHash);
+ ValidateCloneResult(cloneResult, stateHash, actions.Tick);
+
+ return new(events.ToImmutableArray(), stateHash, PreviousSnapshot, CurrentSnapshot);
+ }
+
+ public byte[] SaveState()
+ {
+ return SimulationStateSerializer.Serialize(CurrentState);
+ }
+
+ public static Simulation LoadState(byte[] data, GameDefinition gameDefinition, SimulationConfig config)
+ {
+ return LoadStateCore(data, gameDefinition, config, true);
+ }
+
+ private static Simulation LoadStateCore(byte[] data, GameDefinition gameDefinition, SimulationConfig config, bool enableVerification)
+ {
+ var state = SimulationStateSerializer.Deserialize(data);
+ return new(gameDefinition, config, state, enableVerification);
+ }
+
+ private static SimulationState CreateInitialState(GameDefinition gameDefinition, int seed)
+ {
+ ArgumentNullException.ThrowIfNull(gameDefinition);
+ ValidateDefinitions(gameDefinition);
+
+ var players = ImmutableArray.CreateBuilder(gameDefinition.Players.Length);
+ foreach (var player in gameDefinition.Players)
+ players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0));
+
+ var normalizedSeed = NormalizeSeed(seed);
+ return new(0, seed, normalizedSeed, 0, players.MoveToImmutable());
+ }
+
+ private static ulong NormalizeSeed(int seed)
+ {
+ return seed == 0 ? 1UL : unchecked((uint)seed);
+ }
+
+ private static void ValidateDefinitions(GameDefinition gameDefinition)
+ {
+ HashSet seen = new();
+ foreach (var player in gameDefinition.Players)
+ {
+ if (!seen.Add(player.PlayerId.Value))
+ throw new InvalidOperationException($"Duplicate player id {player.PlayerId.Value}.");
+ }
+ }
+
+ private static int ComputeStateHash(SimulationState state)
+ {
+ return DeterministicHash.Compute(SimulationStateSerializer.Serialize(state));
+ }
+
+ private static WorldSnapshot CreateSnapshot(SimulationState state, int stateHash)
+ {
+ var players = ImmutableArray.CreateBuilder(state.Players.Length);
+ foreach (var player in state.Players)
+ players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY));
+
+ return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable());
+ }
+
+ private void ApplyActions(TickActionBatch actions)
+ {
+ foreach (var action in actions.Actions)
+ {
+ switch (action)
+ {
+ case MoveAxisChanged moveAxisChanged:
+ CurrentState.GetRequiredPlayer(moveAxisChanged.PlayerId).SetMoveAxis(moveAxisChanged.X, moveAxisChanged.Y);
+ break;
+ case AimAxisChanged aimAxisChanged:
+ CurrentState.GetRequiredPlayer(aimAxisChanged.PlayerId).SetAimAxis(aimAxisChanged.X, aimAxisChanged.Y);
+ break;
+ case ButtonChanged buttonChanged:
+ CurrentState.GetRequiredPlayer(buttonChanged.PlayerId).SetButton(buttonChanged.Button, buttonChanged.IsPressed);
+ break;
+ case WeaponSlotSelected weaponSlotSelected:
+ CurrentState.GetRequiredPlayer(weaponSlotSelected.PlayerId).SelectWeaponSlot(weaponSlotSelected.SlotIndex);
+ break;
+ default:
+ throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.");
+ }
+ }
+ }
+
+ private void AdvancePlayers(int tick, List events)
+ {
+ foreach (var player in CurrentState.Players)
+ {
+ if (player.MoveAxisX != 0 || player.MoveAxisY != 0)
+ {
+ player.Advance();
+ events.Add(new("PlayerMoved", tick, player.PlayerId));
+ }
+ }
+ }
+
+ private ulong AdvanceRandom()
+ {
+ SIntRandom random = new(CurrentState.RandomState);
+ random.Next();
+ return random.Seed;
+ }
+
+ [ExcludeFromCodeCoverage]
+ private void ValidateRoundTripState(int expectedHash)
+ {
+ if (!m_EnableVerification || m_Config.VerificationMode == VerificationMode.None)
+ return;
+
+ var currentBytes = SaveState();
+ var roundTrip = LoadStateCore(currentBytes, m_GameDefinition, m_Config, false);
+ var roundTrippedBytes = roundTrip.SaveState();
+ if (!currentBytes.AsSpan().SequenceEqual(roundTrippedBytes))
+ throw new SimulationVerificationException("Round-trip serialization changed the saved state payload.");
+
+ var roundTripHash = ComputeStateHash(roundTrip.CurrentState);
+ if (roundTripHash != expectedHash)
+ throw new SimulationVerificationException("Round-trip serialization changed the state hash.");
+ }
+
+ [ExcludeFromCodeCoverage]
+ private static void ValidateCloneResult(TickResult? cloneResult, int stateHash, int tick)
+ {
+ if (cloneResult is null)
+ return;
+
+ if (cloneResult.StateHash != stateHash)
+ throw new SimulationVerificationException($"Clone replay diverged at tick {tick}.");
+ }
+
+ public int CurrentTick => CurrentState.Tick;
+
+ public SimulationState CurrentState { get; }
+
+ public WorldSnapshot PreviousSnapshot { get; private set; }
+
+ public WorldSnapshot CurrentSnapshot { get; private set; }
+
+ private readonly SimulationConfig m_Config;
+
+ private readonly bool m_EnableVerification;
+
+ private readonly GameDefinition m_GameDefinition;
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/SimulationConfig.cs b/src/SideScrollerGame.Sim/SimulationConfig.cs
new file mode 100644
index 0000000..6008b00
--- /dev/null
+++ b/src/SideScrollerGame.Sim/SimulationConfig.cs
@@ -0,0 +1,23 @@
+using System.Diagnostics.CodeAnalysis;
+using SideScrollerGame.Sim.Verification;
+
+namespace SideScrollerGame.Sim;
+
+[ExcludeFromCodeCoverage]
+public sealed record SimulationConfig
+{
+ public SimulationConfig(int ticksPerSecond, VerificationMode verificationMode)
+ {
+ if (ticksPerSecond <= 0)
+ throw new ArgumentOutOfRangeException(nameof(ticksPerSecond));
+
+ TicksPerSecond = ticksPerSecond;
+ VerificationMode = verificationMode;
+ }
+
+ public int TicksPerSecond { get; init; }
+
+ public VerificationMode VerificationMode { get; init; }
+
+ public static SimulationConfig Default { get; } = new(SimulationDefaults.DefaultTicksPerSecond, VerificationMode.None);
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/SimulationDefaults.cs b/src/SideScrollerGame.Sim/SimulationDefaults.cs
new file mode 100644
index 0000000..d401fa7
--- /dev/null
+++ b/src/SideScrollerGame.Sim/SimulationDefaults.cs
@@ -0,0 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim;
+
+[ExcludeFromCodeCoverage]
+public static class SimulationDefaults
+{
+ public const int DefaultTicksPerSecond = 60;
+ public const int ReplayFormatVersion = 1;
+ public const int StateFormatVersion = 1;
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Verification/SimulationVerificationException.cs b/src/SideScrollerGame.Sim/Verification/SimulationVerificationException.cs
new file mode 100644
index 0000000..659ed3d
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Verification/SimulationVerificationException.cs
@@ -0,0 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace SideScrollerGame.Sim.Verification;
+
+[ExcludeFromCodeCoverage]
+public sealed class SimulationVerificationException : Exception
+{
+ public SimulationVerificationException(string message) : base(message)
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/SideScrollerGame.Sim/Verification/VerificationMode.cs b/src/SideScrollerGame.Sim/Verification/VerificationMode.cs
new file mode 100644
index 0000000..3ad899e
--- /dev/null
+++ b/src/SideScrollerGame.Sim/Verification/VerificationMode.cs
@@ -0,0 +1,8 @@
+namespace SideScrollerGame.Sim.Verification;
+
+public enum VerificationMode
+{
+ None,
+ RoundTripState,
+ RoundTripAndStepClone
+}
\ No newline at end of file
diff --git a/tests/SideScrollerGame.Sim.Tests/UnitTest1.cs b/tests/SideScrollerGame.Sim.Tests/DeterministicMathSmokeTests.cs
similarity index 100%
rename from tests/SideScrollerGame.Sim.Tests/UnitTest1.cs
rename to tests/SideScrollerGame.Sim.Tests/DeterministicMathSmokeTests.cs
diff --git a/tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj b/tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
index 2a27cfb..67d3a9e 100644
--- a/tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
+++ b/tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
@@ -5,12 +5,14 @@
enable
enable
+ [SideScrollerGame.Sim]SideScrollerGame.Sim.Math.*,[SideScrollerGame.Sim]SideScrollerGame.Sim.SimulationDefaults
false
true
+
diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationConfigTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationConfigTests.cs
new file mode 100644
index 0000000..dcdaf3f
--- /dev/null
+++ b/tests/SideScrollerGame.Sim.Tests/SimulationConfigTests.cs
@@ -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(() => new SimulationConfig(0, VerificationMode.None));
+ }
+}
\ No newline at end of file
diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationReplayTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationReplayTests.cs
new file mode 100644
index 0000000..3e4f17e
--- /dev/null
+++ b/tests/SideScrollerGame.Sim.Tests/SimulationReplayTests.cs
@@ -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();
+ var rightHashes = ImmutableArray.CreateBuilder();
+ 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.Empty);
+
+ var exception = Assert.Throws(() => 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(() => 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(() => ReplayPlayer.Play(divergentReplay, definition, config));
+
+ Assert.Contains("diverged", exception.Message.ToLowerInvariant());
+ }
+}
\ No newline at end of file
diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs
new file mode 100644
index 0000000..13d0d6d
--- /dev/null
+++ b/tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs
new file mode 100644
index 0000000..adcc8b3
--- /dev/null
+++ b/tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs
new file mode 100644
index 0000000..029032d
--- /dev/null
+++ b/tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs
@@ -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(() => 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(() => 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(() => 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(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
+
+ Assert.Contains("Duplicate player id 1", exception.Message);
+ }
+
+ [Fact]
+ public void Constructor_RejectsNullDefinition()
+ {
+ var exception = Assert.Throws(() => new Simulation(null!, SimulationTestFactory.CreateConfig(), 1));
+
+ Assert.Equal("gameDefinition", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_RejectsNullConfig()
+ {
+ var exception = Assert.Throws(() => 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(() => Simulation.LoadState(bytes, null!, config));
+
+ Assert.Equal("gameDefinition", exception.ParamName);
+ }
+}
\ No newline at end of file
diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs b/tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs
new file mode 100644
index 0000000..8b63b72
--- /dev/null
+++ b/tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs
@@ -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));
+ }
+}
\ No newline at end of file
diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationVerificationTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationVerificationTests.cs
new file mode 100644
index 0000000..0b8a121
--- /dev/null
+++ b/tests/SideScrollerGame.Sim.Tests/SimulationVerificationTests.cs
@@ -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);
+ }
+}
\ No newline at end of file