From 5f11dfcdc546af05d5e6c43c348b4503a60b0069 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 16 Apr 2026 11:29:41 +0200 Subject: [PATCH] Implement deterministic simulation spine --- .gitignore | 3 +- godot/SideScrollerGame.Godot.csproj | 16 +- groundwork.md | 9 +- src/SideScrollerGame.Sim/Class1.cs | 5 - .../Definitions/GameDefinition.cs | 15 ++ .../Definitions/PlayerDefinition.cs | 18 ++ .../FixPoint/CompatibilityTypes.cs | 2 +- .../FixPoint/FixPoint16.cs | 176 ++++----------- .../FixPoint/FixPoint16Long.cs | 59 +---- src/SideScrollerGame.Sim/Input/InputButton.cs | 9 + .../Input/SimulationAction.cs | 18 ++ .../Input/TickActionBatch.cs | 23 ++ src/SideScrollerGame.Sim/PlayerId.cs | 3 + .../Replay/RecordedTick.cs | 18 ++ .../Replay/ReplayPlayer.cs | 30 +++ .../Replay/ReplayRecord.cs | 24 +++ .../Replay/ReplayRecorder.cs | 27 +++ .../Runtime/PlayerSnapshot.cs | 24 +++ .../Runtime/PlayerState.cs | 68 ++++++ .../Runtime/SimulationEvent.cs | 20 ++ .../Runtime/SimulationState.cs | 52 +++++ .../Runtime/TickResult.cs | 24 +++ .../Runtime/WorldSnapshot.cs | 24 +++ .../Serialization/DeterministicHash.cs | 25 +++ .../Serialization/GameDefinitionHasher.cs | 44 ++++ .../Serialization/ReplayRecordSerializer.cs | 146 +++++++++++++ .../SimulationStateSerializer.cs | 94 ++++++++ src/SideScrollerGame.Sim/Simulation.cs | 202 ++++++++++++++++++ src/SideScrollerGame.Sim/SimulationConfig.cs | 23 ++ .../SimulationDefaults.cs | 11 + .../SimulationVerificationException.cs | 11 + .../Verification/VerificationMode.cs | 8 + ...est1.cs => DeterministicMathSmokeTests.cs} | 0 .../SideScrollerGame.Sim.Tests.csproj | 2 + .../SimulationConfigTests.cs | 19 ++ .../SimulationReplayTests.cs | 119 +++++++++++ .../SimulationSerializationTests.cs | 38 ++++ .../SimulationStateTests.cs | 46 ++++ .../SimulationStepTests.cs | 103 +++++++++ .../SimulationTestFactory.cs | 24 +++ .../SimulationVerificationTests.cs | 27 +++ 41 files changed, 1406 insertions(+), 203 deletions(-) delete mode 100644 src/SideScrollerGame.Sim/Class1.cs create mode 100644 src/SideScrollerGame.Sim/Definitions/GameDefinition.cs create mode 100644 src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs create mode 100644 src/SideScrollerGame.Sim/Input/InputButton.cs create mode 100644 src/SideScrollerGame.Sim/Input/SimulationAction.cs create mode 100644 src/SideScrollerGame.Sim/Input/TickActionBatch.cs create mode 100644 src/SideScrollerGame.Sim/PlayerId.cs create mode 100644 src/SideScrollerGame.Sim/Replay/RecordedTick.cs create mode 100644 src/SideScrollerGame.Sim/Replay/ReplayPlayer.cs create mode 100644 src/SideScrollerGame.Sim/Replay/ReplayRecord.cs create mode 100644 src/SideScrollerGame.Sim/Replay/ReplayRecorder.cs create mode 100644 src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs create mode 100644 src/SideScrollerGame.Sim/Runtime/PlayerState.cs create mode 100644 src/SideScrollerGame.Sim/Runtime/SimulationEvent.cs create mode 100644 src/SideScrollerGame.Sim/Runtime/SimulationState.cs create mode 100644 src/SideScrollerGame.Sim/Runtime/TickResult.cs create mode 100644 src/SideScrollerGame.Sim/Runtime/WorldSnapshot.cs create mode 100644 src/SideScrollerGame.Sim/Serialization/DeterministicHash.cs create mode 100644 src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs create mode 100644 src/SideScrollerGame.Sim/Serialization/ReplayRecordSerializer.cs create mode 100644 src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs create mode 100644 src/SideScrollerGame.Sim/Simulation.cs create mode 100644 src/SideScrollerGame.Sim/SimulationConfig.cs create mode 100644 src/SideScrollerGame.Sim/SimulationDefaults.cs create mode 100644 src/SideScrollerGame.Sim/Verification/SimulationVerificationException.cs create mode 100644 src/SideScrollerGame.Sim/Verification/VerificationMode.cs rename tests/SideScrollerGame.Sim.Tests/{UnitTest1.cs => DeterministicMathSmokeTests.cs} (100%) create mode 100644 tests/SideScrollerGame.Sim.Tests/SimulationConfigTests.cs create mode 100644 tests/SideScrollerGame.Sim.Tests/SimulationReplayTests.cs create mode 100644 tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs create mode 100644 tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs create mode 100644 tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs create mode 100644 tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs create mode 100644 tests/SideScrollerGame.Sim.Tests/SimulationVerificationTests.cs 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