Implement deterministic simulation spine

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

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
/android/
bin
obj
coverage.cobertura.xml

View File

@@ -1,10 +1,10 @@
<Project Sdk="Godot.NET.Sdk/4.5.1">
<ItemGroup>
<ProjectReference Include="..\src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
</Project>

View File

@@ -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

View File

@@ -1,5 +0,0 @@
namespace SideScrollerGame.Sim;
public static class SimulationAssemblyMarker
{
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record GameDefinition
{
public GameDefinition(ImmutableArray<PlayerDefinition> players)
{
Players = players.IsDefault ? ImmutableArray<PlayerDefinition>.Empty : players;
}
public ImmutableArray<PlayerDefinition> Players { get; init; }
}

View File

@@ -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; }
}

View File

@@ -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<FixPoint16> source)
{
return Average(source, f => f);
return source.Average(f => f);
}
public static FixPoint16 Average<TSource>(this IEnumerable<TSource> source, Func<TSource, FixPoint16> selector)
{
using IEnumerator<TSource> 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<FixPoint16> source)
{
using IEnumerator<FixPoint16> 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<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > 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<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (divisor == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
long iResult;
if (((uint)dividend & 0x80000000U) == ((uint)divisor & 0x80000000U))
{
iResult = (((long)dividend << c_Shift) + (divisor / 2)) / divisor;
}
iResult = (((long)dividend << c_Shift) + divisor / 2) / divisor;
else
{
iResult = (((long)dividend << c_Shift) - (divisor / 2)) / divisor;
}
iResult = (((long)dividend << c_Shift) - divisor / 2) / divisor;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > 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<FixPoint16>, IComparable<FixPoint1
if (divisor == 0L)
throw new ArithmeticException("Divison by zero");
#endif
while (dividend > (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<FixPoint16>, IComparable<FixPoint1
long iResult;
if (((ulong)dividend & 0x8000000000000000UL) == ((ulong)divisor & 0x8000000000000000UL))
iResult = ((dividend << c_Shift) + (divisor / 2)) / divisor;
iResult = ((dividend << c_Shift) + divisor / 2) / divisor;
else
iResult = ((dividend << c_Shift) - (divisor / 2)) / divisor;
iResult = ((dividend << c_Shift) - divisor / 2) / divisor;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > c_LongMax)
@@ -149,27 +139,19 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#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 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<FixPoint16>, IComparable<FixPoint1
case EParseState.TrimStartWhitespace:
{
if (char.IsWhiteSpace(character))
{
++i;
}
else
{
state = EParseState.ParseMantissa;
}
break;
}
case EParseState.ParseMantissa:
@@ -322,13 +301,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
case EParseState.TrimEndWhitespace:
{
if (char.IsWhiteSpace(character))
{
++i;
}
else
{
throw new FormatException($"Unexpected character '{character}' after the number.");
}
break;
}
}
@@ -346,27 +322,19 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (exponentValue > 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<FixPoint16>, IComparable<FixPoint1
unchecked
{
if (m_Value < 0)
{
return -(-m_Value >> c_Shift);
}
return m_Value >> c_Shift;
}
@@ -499,13 +465,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
return ToDouble() * 360.0;
}
public override readonly string ToString()
public readonly override string ToString()
{
return $"{ToDouble()}[0x{m_Value:x}]";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override readonly int GetHashCode()
public readonly override int GetHashCode()
{
return m_Value.GetHashCode();
}
@@ -517,12 +483,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override readonly bool Equals(object? obj)
public readonly override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != typeof(FixPoint16))
{
return false;
}
return ((FixPoint16)obj).m_Value == m_Value;
}
@@ -596,13 +560,11 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var iResult = (long)a.m_Value + b.m_Value;
if (iResult < c_LongMin || iResult > 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<FixPoint16>, IComparable<FixPoint1
var iResult = (long)a.m_Value - b.m_Value;
if (iResult < c_LongMin || iResult > 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<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (a.m_Value < 0 && -a.m_Value < 0)
{
throw new ArithmeticException($"Negation result out of range: {a.m_Value}");
}
#endif
return new() { m_Value = -a.m_Value };
}
@@ -647,12 +605,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
unchecked
{
var iResult = (((long)a.m_Value * b.m_Value) + c_Half) >> 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<FixPoint16>, IComparable<FixPoint1
var iResult = (long)a.m_Value * value;
if (iResult < c_LongMin || iResult > 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<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
if ((a.m_Value & 0x80000000) == (value & 0x80000000))
{
return new() { m_Value = (int)(((long)a.m_Value + (value / 2)) / value) };
}
return new() { m_Value = (int)(((long)a.m_Value + value / 2) / value) };
return new() { m_Value = (int)(((long)a.m_Value - (value / 2)) / value) };
return new() { m_Value = (int)(((long)a.m_Value - value / 2) / value) };
}
}
@@ -705,25 +655,17 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (b.m_Value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
long iResult;
if ((a.m_Value & 0x80000000) == (b.m_Value & 0x80000000))
{
iResult = (((long)a.m_Value << c_Shift) + (b.m_Value / 2)) / b.m_Value;
}
iResult = (((long)a.m_Value << c_Shift) + b.m_Value / 2) / b.m_Value;
else
{
iResult = (((long)a.m_Value << c_Shift) - (b.m_Value / 2)) / b.m_Value;
}
iResult = (((long)a.m_Value << c_Shift) - b.m_Value / 2) / b.m_Value;
#if RANGE_CHECK
if (iResult < c_LongMin || iResult > 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<FixPoint16>, IComparable<FixPoint1
value = IntMath.Sqrt(value);
#if RANGE_CHECK
if (value > 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<FixPoint16>, IComparable<FixPoint1
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
return value;
@@ -896,17 +834,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
value = IntMath.Sqrt(value);
#if RANGE_CHECK
if (value > 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<FixPoint16>, IComparable<FixPoint1
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
value += cSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
value = IntMath.Sqrt(value);
#if RANGE_CHECK
if (value > 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<FixPoint16>, IComparable<FixPoint1
{
#if RANGE_CHECK
if (value.m_Value < MinusOne.m_Value)
{
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
}
#endif
return new() { m_Value = -s_AsinTable[-value.m_Value] };
}
#if RANGE_CHECK
if (value.m_Value > 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<FixPoint16>, IComparable<FixPoint1
if (value.m_Value < 0)
{
if (-value.m_Value < s_AtanTable.Length)
{
return new() { m_Value = s_AtanTable[-value.m_Value] };
}
return new() { m_Value = MinusHalfPi.m_Value + s_AtanTable[-(One / value).m_Value] };
}
if (value.m_Value < s_AtanTable.Length)
{
return new() { m_Value = -s_AtanTable[value.m_Value] };
}
return new() { m_Value = s_AtanTable[(One / value).m_Value] };
}
@@ -1017,9 +937,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (y.m_Value == 0)
{
if (x.m_Value >= 0)
{
return Zero;
}
return Pi;
}
@@ -1027,51 +945,39 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
if (y.m_Value > 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<FixPoint16>, IComparable<FixPoint1
var iResult = (b.m_Value - a.m_Value) & c_FractionMask;
if (iResult > Pi.m_Value)
{
iResult -= TwoPi.m_Value;
}
return new() { m_Value = iResult };
}
@@ -1106,7 +1010,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Lerp(FixPoint16 value1, FixPoint16 value2, FixPoint16 amount)
{
return value1 + (amount * (value2 - value1));
return value1 + amount * (value2 - value1);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -1175,13 +1079,9 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
public void Update(float value)
{
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 int m_Value;
@@ -1190,28 +1090,28 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => 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;

View File

@@ -2,7 +2,6 @@
#define RANGE_CHECK
#endif
using System;
using System.Runtime.CompilerServices;
namespace SideScrollerGame.Sim.Math;
@@ -23,9 +22,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, 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<FixPoint16Long>, 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<FixPoint16Long>, 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<FixPoint16Long>, IEquata
public long ToLong()
{
if (m_Value < 0)
{
return -(-m_Value >> c_Shift);
}
return m_Value >> c_Shift;
}
@@ -132,9 +113,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, 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<FixPoint16Long>, 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<FixPoint16Long>, 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<FixPoint16Long>, 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<FixPoint16Long>, 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) };

View File

@@ -0,0 +1,9 @@
namespace SideScrollerGame.Sim.Input;
public enum InputButton
{
Jump = 0,
FirePrimary = 1,
FireSecondary = 2,
Dash = 3
}

View File

@@ -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;

View File

@@ -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<SimulationAction> actions)
{
Tick = tick;
Actions = actions.IsDefault ? ImmutableArray<SimulationAction>.Empty : actions;
}
public static TickActionBatch Empty(int tick)
{
return new(tick, ImmutableArray<SimulationAction>.Empty);
}
public int Tick { get; init; }
public ImmutableArray<SimulationAction> Actions { get; init; }
}

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Sim;
public readonly record struct PlayerId(int Value);

View File

@@ -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; }
}

View File

@@ -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<int> 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<int>(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();
}
}

View File

@@ -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<RecordedTick> ticks)
{
ContentHash = contentHash;
Seed = seed;
TicksPerSecond = ticksPerSecond;
Ticks = ticks.IsDefault ? ImmutableArray<RecordedTick>.Empty : ticks;
}
public int ContentHash { get; init; }
public int Seed { get; init; }
public int TicksPerSecond { get; init; }
public ImmutableArray<RecordedTick> Ticks { get; init; }
}

View File

@@ -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<RecordedTick> m_Ticks;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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<PlayerState> players)
{
Tick = tick;
Seed = seed;
RandomState = randomState;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerState>.Empty : players;
}
public SimulationState Clone()
{
var builder = ImmutableArray.CreateBuilder<PlayerState>(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<PlayerState> Players { get; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record TickResult
{
public TickResult(ImmutableArray<SimulationEvent> events, int stateHash, WorldSnapshot previousSnapshot, WorldSnapshot currentSnapshot)
{
Events = events.IsDefault ? ImmutableArray<SimulationEvent>.Empty : events;
StateHash = stateHash;
PreviousSnapshot = previousSnapshot;
CurrentSnapshot = currentSnapshot;
}
public ImmutableArray<SimulationEvent> Events { get; init; }
public int StateHash { get; init; }
public WorldSnapshot PreviousSnapshot { get; init; }
public WorldSnapshot CurrentSnapshot { get; init; }
}

View File

@@ -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<PlayerSnapshot> players)
{
Tick = tick;
StateHash = stateHash;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerSnapshot>.Empty : players;
}
public int Tick { get; init; }
public int StateHash { get; init; }
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerSnapshot> Players { get; init; }
}

View File

@@ -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;
}
}
}

View File

@@ -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<PlayerDefinitionDocument> 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<PlayerDefinitionDocument> 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);
}
}

View File

@@ -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<RecordedTickDocument> Ticks { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record RecordedTickDocument
{
public int Tick { get; init; }
public int ExpectedStateHash { get; init; }
public ImmutableArray<SimulationActionDocument> 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<RecordedTickDocument> ticks = new(replayRecord.Ticks.Length);
foreach (var tick in replayRecord.Ticks)
{
List<SimulationActionDocument> 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<ReplayRecordDocument>(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<RecordedTick>(document.Ticks.Length);
foreach (var tick in document.Ticks)
{
var actions = ImmutableArray.CreateBuilder<SimulationAction>(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}.")
};
}
}

View File

@@ -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<PlayerStateDocument> 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<PlayerStateDocument> 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<SimulationStateDocument>(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<PlayerState>(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());
}
}

View File

@@ -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<SimulationEvent> 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<PlayerState>(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<int> 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<PlayerSnapshot>(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<SimulationEvent> 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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Verification;
[ExcludeFromCodeCoverage]
public sealed class SimulationVerificationException : Exception
{
public SimulationVerificationException(string message) : base(message)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace SideScrollerGame.Sim.Verification;
public enum VerificationMode
{
None,
RoundTripState,
RoundTripAndStepClone
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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