Implement deterministic simulation spine
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
|||||||
/android/
|
/android/
|
||||||
bin
|
bin
|
||||||
obj
|
obj
|
||||||
|
coverage.cobertura.xml
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<Project Sdk="Godot.NET.Sdk/4.5.1">
|
<Project Sdk="Godot.NET.Sdk/4.5.1">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj" />
|
<ProjectReference Include="..\src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
|
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
|
||||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -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: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 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`.
|
- [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.
|
- [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.
|
||||||
- [ ] Implement versioned save/load, replay recording, replay playback, and the optional round-trip verification modes.
|
- [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 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 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.
|
- [ ] 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`.
|
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.
|
- 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.
|
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
|
## 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.
|
- 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.
|
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
|
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
|
## Outcomes & Retrospective
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace SideScrollerGame.Sim;
|
|
||||||
|
|
||||||
public static class SimulationAssemblyMarker
|
|
||||||
{
|
|
||||||
}
|
|
||||||
15
src/SideScrollerGame.Sim/Definitions/GameDefinition.cs
Normal file
15
src/SideScrollerGame.Sim/Definitions/GameDefinition.cs
Normal 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; }
|
||||||
|
}
|
||||||
18
src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs
Normal file
18
src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
#define RANGE_CHECK
|
#define RANGE_CHECK
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics.Contracts;
|
using System.Diagnostics.Contracts;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
@@ -14,20 +12,24 @@ public static class FixPoint16Ext
|
|||||||
{
|
{
|
||||||
public static FixPoint16 Average(this IEnumerable<FixPoint16> source)
|
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)
|
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())
|
if (!e.MoveNext())
|
||||||
return FixPoint16.Zero;
|
return FixPoint16.Zero;
|
||||||
|
|
||||||
long sum = selector(e.Current).m_Value;
|
long sum = selector(e.Current).m_Value;
|
||||||
int count = 1;
|
var count = 1;
|
||||||
while (e.MoveNext())
|
while (e.MoveNext())
|
||||||
{
|
{
|
||||||
checked { sum += selector(e.Current).m_Value; }
|
checked
|
||||||
|
{
|
||||||
|
sum += selector(e.Current).m_Value;
|
||||||
|
}
|
||||||
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,15 +38,13 @@ public static class FixPoint16Ext
|
|||||||
|
|
||||||
public static FixPoint16 Sum(this IEnumerable<FixPoint16> source)
|
public static FixPoint16 Sum(this IEnumerable<FixPoint16> source)
|
||||||
{
|
{
|
||||||
using IEnumerator<FixPoint16> e = source.GetEnumerator();
|
using var e = source.GetEnumerator();
|
||||||
if (!e.MoveNext())
|
if (!e.MoveNext())
|
||||||
return FixPoint16.Zero;
|
return FixPoint16.Zero;
|
||||||
|
|
||||||
var sum = e.Current;
|
var sum = e.Current;
|
||||||
while (e.MoveNext())
|
while (e.MoveNext())
|
||||||
{
|
|
||||||
sum += e.Current;
|
sum += e.Current;
|
||||||
}
|
|
||||||
|
|
||||||
return sum;
|
return sum;
|
||||||
}
|
}
|
||||||
@@ -83,9 +83,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < c_IntegerMin || value > c_IntegerMax)
|
if (value < c_IntegerMin || value > c_IntegerMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Integer to FixPoint argument out of range: {value}");
|
throw new ArithmeticException($"Integer to FixPoint argument out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
m_Value = value << c_Shift;
|
m_Value = value << c_Shift;
|
||||||
}
|
}
|
||||||
@@ -95,25 +93,17 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (divisor == 0)
|
if (divisor == 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException("Divison by zero");
|
throw new ArithmeticException("Divison by zero");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
long iResult;
|
long iResult;
|
||||||
|
|
||||||
if (((uint)dividend & 0x80000000U) == ((uint)divisor & 0x80000000U))
|
if (((uint)dividend & 0x80000000U) == ((uint)divisor & 0x80000000U))
|
||||||
{
|
iResult = (((long)dividend << c_Shift) + divisor / 2) / divisor;
|
||||||
iResult = (((long)dividend << c_Shift) + (divisor / 2)) / divisor;
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
iResult = (((long)dividend << c_Shift) - divisor / 2) / divisor;
|
||||||
iResult = (((long)dividend << c_Shift) - (divisor / 2)) / divisor;
|
|
||||||
}
|
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (iResult < c_LongMin || iResult > c_LongMax)
|
if (iResult < c_LongMin || iResult > c_LongMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Division result out of range: {iResult}");
|
throw new ArithmeticException($"Division result out of range: {iResult}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
m_Value = (int)iResult;
|
m_Value = (int)iResult;
|
||||||
}
|
}
|
||||||
@@ -125,7 +115,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
if (divisor == 0L)
|
if (divisor == 0L)
|
||||||
throw new ArithmeticException("Divison by zero");
|
throw new ArithmeticException("Divison by zero");
|
||||||
#endif
|
#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;
|
dividend >>= 1;
|
||||||
divisor >>= 1;
|
divisor >>= 1;
|
||||||
@@ -134,9 +124,9 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
long iResult;
|
long iResult;
|
||||||
|
|
||||||
if (((ulong)dividend & 0x8000000000000000UL) == ((ulong)divisor & 0x8000000000000000UL))
|
if (((ulong)dividend & 0x8000000000000000UL) == ((ulong)divisor & 0x8000000000000000UL))
|
||||||
iResult = ((dividend << c_Shift) + (divisor / 2)) / divisor;
|
iResult = ((dividend << c_Shift) + divisor / 2) / divisor;
|
||||||
else
|
else
|
||||||
iResult = ((dividend << c_Shift) - (divisor / 2)) / divisor;
|
iResult = ((dividend << c_Shift) - divisor / 2) / divisor;
|
||||||
|
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (iResult < c_LongMin || iResult > c_LongMax)
|
if (iResult < c_LongMin || iResult > c_LongMax)
|
||||||
@@ -149,27 +139,19 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < c_IntegerMin || value > c_IntegerMax)
|
if (value < c_IntegerMin || value > c_IntegerMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
|
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if (value < 0.0)
|
if (value < 0.0)
|
||||||
{
|
m_Value = (int)(value * c_Multiplier - 0.5);
|
||||||
m_Value = (int)((value * c_Multiplier) - 0.5);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
m_Value = (int)(value * c_Multiplier + 0.5);
|
||||||
m_Value = (int)((value * c_Multiplier) + 0.5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public FixPoint16(float value)
|
public FixPoint16(float value)
|
||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < c_IntegerMin || value > c_IntegerMax)
|
if (value < c_IntegerMin || value > c_IntegerMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
|
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
m_Value = 0;
|
m_Value = 0;
|
||||||
Update(value);
|
Update(value);
|
||||||
@@ -233,13 +215,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
case EParseState.TrimStartWhitespace:
|
case EParseState.TrimStartWhitespace:
|
||||||
{
|
{
|
||||||
if (char.IsWhiteSpace(character))
|
if (char.IsWhiteSpace(character))
|
||||||
{
|
|
||||||
++i;
|
++i;
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
state = EParseState.ParseMantissa;
|
state = EParseState.ParseMantissa;
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EParseState.ParseMantissa:
|
case EParseState.ParseMantissa:
|
||||||
@@ -322,13 +301,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
case EParseState.TrimEndWhitespace:
|
case EParseState.TrimEndWhitespace:
|
||||||
{
|
{
|
||||||
if (char.IsWhiteSpace(character))
|
if (char.IsWhiteSpace(character))
|
||||||
{
|
|
||||||
++i;
|
++i;
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
throw new FormatException($"Unexpected character '{character}' after the number.");
|
throw new FormatException($"Unexpected character '{character}' after the number.");
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,27 +322,19 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
if (exponentValue > 0)
|
if (exponentValue > 0)
|
||||||
{
|
{
|
||||||
while (exponentValue-- > 0)
|
while (exponentValue-- > 0)
|
||||||
{
|
|
||||||
if (denominatorPower > 0)
|
if (denominatorPower > 0)
|
||||||
{
|
|
||||||
denominatorPower -= 1;
|
denominatorPower -= 1;
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
numerator *= 10;
|
numerator *= 10;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
|
||||||
denominatorPower -= exponentValue;
|
denominatorPower -= exponentValue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
numerator = mantissaNegative ? -numerator : numerator;
|
numerator = mantissaNegative ? -numerator : numerator;
|
||||||
denominator = 1;
|
denominator = 1;
|
||||||
for (int i = 0; i < denominatorPower; ++i)
|
for (var i = 0; i < denominatorPower; ++i)
|
||||||
denominator *= 10;
|
denominator *= 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,9 +407,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
unchecked
|
unchecked
|
||||||
{
|
{
|
||||||
if (m_Value < 0)
|
if (m_Value < 0)
|
||||||
{
|
|
||||||
return -(-m_Value >> c_Shift);
|
return -(-m_Value >> c_Shift);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
return ToDouble() * 360.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override readonly string ToString()
|
public readonly override string ToString()
|
||||||
{
|
{
|
||||||
return $"{ToDouble()}[0x{m_Value:x}]";
|
return $"{ToDouble()}[0x{m_Value:x}]";
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public override readonly int GetHashCode()
|
public readonly override int GetHashCode()
|
||||||
{
|
{
|
||||||
return m_Value.GetHashCode();
|
return m_Value.GetHashCode();
|
||||||
}
|
}
|
||||||
@@ -517,12 +483,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public override readonly bool Equals(object? obj)
|
public readonly override bool Equals(object? obj)
|
||||||
{
|
{
|
||||||
if (obj == null || obj.GetType() != typeof(FixPoint16))
|
if (obj == null || obj.GetType() != typeof(FixPoint16))
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
return ((FixPoint16)obj).m_Value == m_Value;
|
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;
|
var iResult = (long)a.m_Value + b.m_Value;
|
||||||
|
|
||||||
if (iResult < c_LongMin || iResult > c_LongMax)
|
if (iResult < c_LongMin || iResult > c_LongMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Addition result out of range: {iResult}");
|
throw new ArithmeticException($"Addition result out of range: {iResult}");
|
||||||
}
|
|
||||||
|
|
||||||
return new() { m_Value = (int)iResult };
|
return new() { m_Value = (int)iResult };
|
||||||
#else
|
#else
|
||||||
return new FixPoint16 { m_Value = a.m_Value + b.m_Value };
|
return new FixPoint16 { m_Value = a.m_Value + b.m_Value };
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,13 +578,11 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
var iResult = (long)a.m_Value - b.m_Value;
|
var iResult = (long)a.m_Value - b.m_Value;
|
||||||
|
|
||||||
if (iResult < c_LongMin || iResult > c_LongMax)
|
if (iResult < c_LongMin || iResult > c_LongMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Substraction result out of range: {iResult}");
|
throw new ArithmeticException($"Substraction result out of range: {iResult}");
|
||||||
}
|
|
||||||
|
|
||||||
return new() { m_Value = (int)iResult };
|
return new() { m_Value = (int)iResult };
|
||||||
#else
|
#else
|
||||||
return new FixPoint16 { m_Value = a.m_Value - b.m_Value };
|
return new FixPoint16 { m_Value = a.m_Value - b.m_Value };
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -634,9 +594,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (a.m_Value < 0 && -a.m_Value < 0)
|
if (a.m_Value < 0 && -a.m_Value < 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Negation result out of range: {a.m_Value}");
|
throw new ArithmeticException($"Negation result out of range: {a.m_Value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = -a.m_Value };
|
return new() { m_Value = -a.m_Value };
|
||||||
}
|
}
|
||||||
@@ -647,12 +605,10 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
unchecked
|
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 RANGE_CHECK
|
||||||
if (iResult < c_LongMin || iResult > c_LongMax)
|
if (iResult < c_LongMin || iResult > c_LongMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
|
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = (int)iResult };
|
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;
|
var iResult = (long)a.m_Value * value;
|
||||||
|
|
||||||
if (iResult < c_LongMin || iResult > c_LongMax)
|
if (iResult < c_LongMin || iResult > c_LongMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
|
throw new ArithmeticException($"Multiplication result out of range: {iResult}");
|
||||||
}
|
|
||||||
|
|
||||||
return new() { m_Value = (int)iResult };
|
return new() { m_Value = (int)iResult };
|
||||||
#else
|
#else
|
||||||
return new FixPoint16 { m_Value = a.m_Value * value };
|
return new FixPoint16 { m_Value = a.m_Value * value };
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,16 +639,12 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value == 0)
|
if (value == 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException("Divison by zero");
|
throw new ArithmeticException("Divison by zero");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if ((a.m_Value & 0x80000000) == (value & 0x80000000))
|
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 RANGE_CHECK
|
||||||
if (b.m_Value == 0)
|
if (b.m_Value == 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException("Divison by zero");
|
throw new ArithmeticException("Divison by zero");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
long iResult;
|
long iResult;
|
||||||
|
|
||||||
if ((a.m_Value & 0x80000000) == (b.m_Value & 0x80000000))
|
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
|
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 RANGE_CHECK
|
||||||
if (iResult < c_LongMin || iResult > c_LongMax)
|
if (iResult < c_LongMin || iResult > c_LongMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Division result out of range: {iResult}");
|
throw new ArithmeticException($"Division result out of range: {iResult}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = (int)iResult };
|
return new() { m_Value = (int)iResult };
|
||||||
}
|
}
|
||||||
@@ -865,9 +807,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
value = IntMath.Sqrt(value);
|
value = IntMath.Sqrt(value);
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value > 0x7fffffff)
|
if (value > 0x7fffffff)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length out of range: {value}");
|
throw new ArithmeticException($"Length out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = (int)value };
|
return new() { m_Value = (int)value };
|
||||||
}
|
}
|
||||||
@@ -880,9 +820,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
var value = aSquared + bSquared;
|
var value = aSquared + bSquared;
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < 0)
|
if (value < 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length squared out of range: {value}");
|
throw new ArithmeticException($"Length squared out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
@@ -896,17 +834,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
var value = aSquared + bSquared;
|
var value = aSquared + bSquared;
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < 0)
|
if (value < 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length squared out of range: {value}");
|
throw new ArithmeticException($"Length squared out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
value = IntMath.Sqrt(value);
|
value = IntMath.Sqrt(value);
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value > 0x7fffffff)
|
if (value > 0x7fffffff)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length out of range: {value}");
|
throw new ArithmeticException($"Length out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = (int)value };
|
return new() { m_Value = (int)value };
|
||||||
}
|
}
|
||||||
@@ -919,24 +853,18 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
var value = aSquared + bSquared;
|
var value = aSquared + bSquared;
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < 0)
|
if (value < 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length squared out of range: {value}");
|
throw new ArithmeticException($"Length squared out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
value += cSquared;
|
value += cSquared;
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < 0)
|
if (value < 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length squared out of range: {value}");
|
throw new ArithmeticException($"Length squared out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
value = IntMath.Sqrt(value);
|
value = IntMath.Sqrt(value);
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value > 0x7fffffff)
|
if (value > 0x7fffffff)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length out of range: {value}");
|
throw new ArithmeticException($"Length out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = (int)value };
|
return new() { m_Value = (int)value };
|
||||||
}
|
}
|
||||||
@@ -967,17 +895,13 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value.m_Value < MinusOne.m_Value)
|
if (value.m_Value < MinusOne.m_Value)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
|
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = -s_AsinTable[-value.m_Value] };
|
return new() { m_Value = -s_AsinTable[-value.m_Value] };
|
||||||
}
|
}
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value.m_Value > One.m_Value)
|
if (value.m_Value > One.m_Value)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
|
throw new ArithmeticException($"Asin number out of range: {value.m_Value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = s_AsinTable[value.m_Value] };
|
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 < 0)
|
||||||
{
|
{
|
||||||
if (-value.m_Value < s_AtanTable.Length)
|
if (-value.m_Value < s_AtanTable.Length)
|
||||||
{
|
|
||||||
return new() { m_Value = s_AtanTable[-value.m_Value] };
|
return new() { m_Value = s_AtanTable[-value.m_Value] };
|
||||||
}
|
|
||||||
|
|
||||||
return new() { m_Value = MinusHalfPi.m_Value + s_AtanTable[-(One / value).m_Value] };
|
return new() { m_Value = MinusHalfPi.m_Value + s_AtanTable[-(One / value).m_Value] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.m_Value < s_AtanTable.Length)
|
if (value.m_Value < s_AtanTable.Length)
|
||||||
{
|
|
||||||
return new() { m_Value = -s_AtanTable[value.m_Value] };
|
return new() { m_Value = -s_AtanTable[value.m_Value] };
|
||||||
}
|
|
||||||
|
|
||||||
return new() { m_Value = s_AtanTable[(One / 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 (y.m_Value == 0)
|
||||||
{
|
{
|
||||||
if (x.m_Value >= 0)
|
if (x.m_Value >= 0)
|
||||||
{
|
|
||||||
return Zero;
|
return Zero;
|
||||||
}
|
|
||||||
|
|
||||||
return Pi;
|
return Pi;
|
||||||
}
|
}
|
||||||
@@ -1027,51 +945,39 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
if (y.m_Value > 0)
|
if (y.m_Value > 0)
|
||||||
{
|
{
|
||||||
if (x.m_Value == 0)
|
if (x.m_Value == 0)
|
||||||
{
|
|
||||||
return HalfPi;
|
return HalfPi;
|
||||||
}
|
|
||||||
|
|
||||||
if (x.m_Value > 0)
|
if (x.m_Value > 0)
|
||||||
{
|
{
|
||||||
// x > 0, y > 0
|
// x > 0, y > 0
|
||||||
if (y.m_Value <= x.m_Value)
|
if (y.m_Value <= x.m_Value)
|
||||||
{
|
|
||||||
return new() { m_Value = s_AtanTable[(y / 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] };
|
return new() { m_Value = HalfPi.m_Value - s_AtanTable[(x / y).m_Value] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// x < 0, y > 0
|
// x < 0, y > 0
|
||||||
if (y.m_Value <= -x.m_Value)
|
if (y.m_Value <= -x.m_Value)
|
||||||
{
|
|
||||||
return new() { m_Value = Pi.m_Value - s_AtanTable[-(y / 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] };
|
return new() { m_Value = HalfPi.m_Value + s_AtanTable[-(x / y).m_Value] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (x.m_Value == 0)
|
if (x.m_Value == 0)
|
||||||
{
|
|
||||||
return MinusHalfPi;
|
return MinusHalfPi;
|
||||||
}
|
|
||||||
|
|
||||||
if (x.m_Value > 0)
|
if (x.m_Value > 0)
|
||||||
{
|
{
|
||||||
// x > 0, y < 0
|
// x > 0, y < 0
|
||||||
if (-y.m_Value <= x.m_Value)
|
if (-y.m_Value <= x.m_Value)
|
||||||
{
|
|
||||||
return new() { m_Value = -s_AtanTable[-(y / 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] };
|
return new() { m_Value = MinusHalfPi.m_Value + s_AtanTable[-(x / y).m_Value] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// x < 0, y < 0
|
// x < 0, y < 0
|
||||||
if (y.m_Value >= x.m_Value)
|
if (y.m_Value >= x.m_Value)
|
||||||
{
|
|
||||||
return new() { m_Value = MinusPi.m_Value + s_AtanTable[(y / 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] };
|
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;
|
var iResult = (b.m_Value - a.m_Value) & c_FractionMask;
|
||||||
|
|
||||||
if (iResult > Pi.m_Value)
|
if (iResult > Pi.m_Value)
|
||||||
{
|
|
||||||
iResult -= TwoPi.m_Value;
|
iResult -= TwoPi.m_Value;
|
||||||
}
|
|
||||||
|
|
||||||
return new() { m_Value = iResult };
|
return new() { m_Value = iResult };
|
||||||
}
|
}
|
||||||
@@ -1106,7 +1010,7 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public static FixPoint16 Lerp(FixPoint16 value1, FixPoint16 value2, FixPoint16 amount)
|
public static FixPoint16 Lerp(FixPoint16 value1, FixPoint16 value2, FixPoint16 amount)
|
||||||
{
|
{
|
||||||
return value1 + (amount * (value2 - value1));
|
return value1 + amount * (value2 - value1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
@@ -1175,13 +1079,9 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
public void Update(float value)
|
public void Update(float value)
|
||||||
{
|
{
|
||||||
if (value < 0.0f)
|
if (value < 0.0f)
|
||||||
{
|
m_Value = (int)(value * c_MultiplierFloat - 0.5f);
|
||||||
m_Value = (int)((value * c_MultiplierFloat) - 0.5f);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
m_Value = (int)(value * c_MultiplierFloat + 0.5f);
|
||||||
m_Value = (int)((value * c_MultiplierFloat) + 0.5f);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int m_Value;
|
public int m_Value;
|
||||||
@@ -1190,28 +1090,28 @@ public partial struct FixPoint16 : IEquatable<FixPoint16>, IComparable<FixPoint1
|
|||||||
{
|
{
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
[Pure]
|
[Pure]
|
||||||
get => new FixPoint16 { m_Value = m_Value * 2 };
|
get => new() { m_Value = m_Value * 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
public FixPoint16 Quadrupled
|
public FixPoint16 Quadrupled
|
||||||
{
|
{
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
[Pure]
|
[Pure]
|
||||||
get => new FixPoint16 { m_Value = m_Value * 4 };
|
get => new() { m_Value = m_Value * 4 };
|
||||||
}
|
}
|
||||||
|
|
||||||
public FixPoint16 Halved
|
public FixPoint16 Halved
|
||||||
{
|
{
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
[Pure]
|
[Pure]
|
||||||
get => new FixPoint16 { m_Value = m_Value / 2 };
|
get => new() { m_Value = m_Value / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
public FixPoint16 Quartered
|
public FixPoint16 Quartered
|
||||||
{
|
{
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
[Pure]
|
[Pure]
|
||||||
get => new FixPoint16 { m_Value = m_Value / 4 };
|
get => new() { m_Value = m_Value / 4 };
|
||||||
}
|
}
|
||||||
|
|
||||||
public static readonly FixPoint16 Zero = default;
|
public static readonly FixPoint16 Zero = default;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
#define RANGE_CHECK
|
#define RANGE_CHECK
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace SideScrollerGame.Sim.Math;
|
namespace SideScrollerGame.Sim.Math;
|
||||||
@@ -23,9 +22,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < c_IntegerMin || value > c_IntegerMax)
|
if (value < c_IntegerMin || value > c_IntegerMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Long to FixPoint argument out of range: {value}");
|
throw new ArithmeticException($"Long to FixPoint argument out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
m_Value = value << c_Shift;
|
m_Value = value << c_Shift;
|
||||||
}
|
}
|
||||||
@@ -34,36 +31,24 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < c_IntegerMin || value > c_IntegerMax)
|
if (value < c_IntegerMin || value > c_IntegerMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
|
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if (value < 0.0)
|
if (value < 0.0)
|
||||||
{
|
m_Value = (int)(value * c_Multiplier - 0.5);
|
||||||
m_Value = (int)((value * c_Multiplier) - 0.5);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
m_Value = (int)(value * c_Multiplier + 0.5);
|
||||||
m_Value = (int)((value * c_Multiplier) + 0.5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public FixPoint16Long(float value)
|
public FixPoint16Long(float value)
|
||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < c_IntegerMin || value > c_IntegerMax)
|
if (value < c_IntegerMin || value > c_IntegerMax)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
|
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if (value < 0.0f)
|
if (value < 0.0f)
|
||||||
{
|
m_Value = (int)(value * c_MultiplierFloat - 0.5f);
|
||||||
m_Value = (int)((value * c_MultiplierFloat) - 0.5f);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
m_Value = (int)(value * c_MultiplierFloat + 0.5f);
|
||||||
m_Value = (int)((value * c_MultiplierFloat) + 0.5f);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public long ToLongFloor()
|
public long ToLongFloor()
|
||||||
@@ -79,9 +64,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
|
|||||||
public long ToLongRound()
|
public long ToLongRound()
|
||||||
{
|
{
|
||||||
if (m_Value < 0)
|
if (m_Value < 0)
|
||||||
{
|
|
||||||
return -((-m_Value + c_Half) >> c_Shift);
|
return -((-m_Value + c_Half) >> c_Shift);
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
public long ToLong()
|
||||||
{
|
{
|
||||||
if (m_Value < 0)
|
if (m_Value < 0)
|
||||||
{
|
|
||||||
return -(-m_Value >> c_Shift);
|
return -(-m_Value >> c_Shift);
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
public override bool Equals(object? obj)
|
||||||
{
|
{
|
||||||
if (obj == null)
|
if (obj == null)
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
return ((FixPoint16Long)obj).m_Value == m_Value;
|
return ((FixPoint16Long)obj).m_Value == m_Value;
|
||||||
}
|
}
|
||||||
@@ -203,12 +182,10 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
|
|||||||
{
|
{
|
||||||
Int128 bigA = a.m_Value;
|
Int128 bigA = a.m_Value;
|
||||||
Int128 bigB = b.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 RANGE_CHECK
|
||||||
if (result < long.MinValue || result > long.MaxValue)
|
if (result < long.MinValue || result > long.MaxValue)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Multiplication result out of range: {result}");
|
throw new ArithmeticException($"Multiplication result out of range: {result}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = (long)result };
|
return new() { m_Value = (long)result };
|
||||||
}
|
}
|
||||||
@@ -217,25 +194,17 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (b.m_Value == 0)
|
if (b.m_Value == 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException("Divison by zero");
|
throw new ArithmeticException("Divison by zero");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
Int128 result;
|
Int128 result;
|
||||||
|
|
||||||
if (((ulong)a.m_Value & 0x8000000000000000UL) == ((ulong)b.m_Value & 0x8000000000000000UL))
|
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
|
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 RANGE_CHECK
|
||||||
if (result < long.MinValue || result > long.MaxValue)
|
if (result < long.MinValue || result > long.MaxValue)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Division result out of range: {result}");
|
throw new ArithmeticException($"Division result out of range: {result}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return new() { m_Value = (long)result };
|
return new() { m_Value = (long)result };
|
||||||
}
|
}
|
||||||
@@ -254,16 +223,12 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
|
|||||||
{
|
{
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value == 0)
|
if (value == 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException("Divison by zero");
|
throw new ArithmeticException("Divison by zero");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if (((a.m_Value >> 32) & 0x80000000) == (value & 0x80000000))
|
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)
|
public static implicit operator FixPoint16Long(int value)
|
||||||
@@ -354,9 +319,7 @@ public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquata
|
|||||||
var value = aSquared + bSquared;
|
var value = aSquared + bSquared;
|
||||||
#if RANGE_CHECK
|
#if RANGE_CHECK
|
||||||
if (value < 0)
|
if (value < 0)
|
||||||
{
|
|
||||||
throw new ArithmeticException($"Length squared out of range: {value}");
|
throw new ArithmeticException($"Length squared out of range: {value}");
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
return new() { m_Value = IntMath.Sqrt(value) };
|
return new() { m_Value = IntMath.Sqrt(value) };
|
||||||
|
|||||||
9
src/SideScrollerGame.Sim/Input/InputButton.cs
Normal file
9
src/SideScrollerGame.Sim/Input/InputButton.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SideScrollerGame.Sim.Input;
|
||||||
|
|
||||||
|
public enum InputButton
|
||||||
|
{
|
||||||
|
Jump = 0,
|
||||||
|
FirePrimary = 1,
|
||||||
|
FireSecondary = 2,
|
||||||
|
Dash = 3
|
||||||
|
}
|
||||||
18
src/SideScrollerGame.Sim/Input/SimulationAction.cs
Normal file
18
src/SideScrollerGame.Sim/Input/SimulationAction.cs
Normal 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;
|
||||||
23
src/SideScrollerGame.Sim/Input/TickActionBatch.cs
Normal file
23
src/SideScrollerGame.Sim/Input/TickActionBatch.cs
Normal 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; }
|
||||||
|
}
|
||||||
3
src/SideScrollerGame.Sim/PlayerId.cs
Normal file
3
src/SideScrollerGame.Sim/PlayerId.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SideScrollerGame.Sim;
|
||||||
|
|
||||||
|
public readonly record struct PlayerId(int Value);
|
||||||
18
src/SideScrollerGame.Sim/Replay/RecordedTick.cs
Normal file
18
src/SideScrollerGame.Sim/Replay/RecordedTick.cs
Normal 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; }
|
||||||
|
}
|
||||||
30
src/SideScrollerGame.Sim/Replay/ReplayPlayer.cs
Normal file
30
src/SideScrollerGame.Sim/Replay/ReplayPlayer.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/SideScrollerGame.Sim/Replay/ReplayRecord.cs
Normal file
24
src/SideScrollerGame.Sim/Replay/ReplayRecord.cs
Normal 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; }
|
||||||
|
}
|
||||||
27
src/SideScrollerGame.Sim/Replay/ReplayRecorder.cs
Normal file
27
src/SideScrollerGame.Sim/Replay/ReplayRecorder.cs
Normal 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;
|
||||||
|
}
|
||||||
24
src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs
Normal file
24
src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs
Normal 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; }
|
||||||
|
}
|
||||||
68
src/SideScrollerGame.Sim/Runtime/PlayerState.cs
Normal file
68
src/SideScrollerGame.Sim/Runtime/PlayerState.cs
Normal 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; }
|
||||||
|
}
|
||||||
20
src/SideScrollerGame.Sim/Runtime/SimulationEvent.cs
Normal file
20
src/SideScrollerGame.Sim/Runtime/SimulationEvent.cs
Normal 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; }
|
||||||
|
}
|
||||||
52
src/SideScrollerGame.Sim/Runtime/SimulationState.cs
Normal file
52
src/SideScrollerGame.Sim/Runtime/SimulationState.cs
Normal 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; }
|
||||||
|
}
|
||||||
24
src/SideScrollerGame.Sim/Runtime/TickResult.cs
Normal file
24
src/SideScrollerGame.Sim/Runtime/TickResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
24
src/SideScrollerGame.Sim/Runtime/WorldSnapshot.cs
Normal file
24
src/SideScrollerGame.Sim/Runtime/WorldSnapshot.cs
Normal 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; }
|
||||||
|
}
|
||||||
25
src/SideScrollerGame.Sim/Serialization/DeterministicHash.cs
Normal file
25
src/SideScrollerGame.Sim/Serialization/DeterministicHash.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/SideScrollerGame.Sim/Serialization/ReplayRecordSerializer.cs
Normal file
146
src/SideScrollerGame.Sim/Serialization/ReplayRecordSerializer.cs
Normal 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}.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/SideScrollerGame.Sim/Simulation.cs
Normal file
202
src/SideScrollerGame.Sim/Simulation.cs
Normal 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;
|
||||||
|
}
|
||||||
23
src/SideScrollerGame.Sim/SimulationConfig.cs
Normal file
23
src/SideScrollerGame.Sim/SimulationConfig.cs
Normal 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);
|
||||||
|
}
|
||||||
11
src/SideScrollerGame.Sim/SimulationDefaults.cs
Normal file
11
src/SideScrollerGame.Sim/SimulationDefaults.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Sim.Verification;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public sealed class SimulationVerificationException : Exception
|
||||||
|
{
|
||||||
|
public SimulationVerificationException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SideScrollerGame.Sim.Verification;
|
||||||
|
|
||||||
|
public enum VerificationMode
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
RoundTripState,
|
||||||
|
RoundTripAndStepClone
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<Exclude>[SideScrollerGame.Sim]SideScrollerGame.Sim.Math.*,[SideScrollerGame.Sim]SideScrollerGame.Sim.SimulationDefaults</Exclude>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
<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="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
|||||||
19
tests/SideScrollerGame.Sim.Tests/SimulationConfigTests.cs
Normal file
19
tests/SideScrollerGame.Sim.Tests/SimulationConfigTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
119
tests/SideScrollerGame.Sim.Tests/SimulationReplayTests.cs
Normal file
119
tests/SideScrollerGame.Sim.Tests/SimulationReplayTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs
Normal file
46
tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs
Normal file
103
tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs
Normal file
24
tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user