Add bounds hazards and triggers

This commit is contained in:
2026-04-16 11:50:37 +02:00
parent c79d5c8f0a
commit 45181d1f78
17 changed files with 405 additions and 25 deletions

View File

@@ -19,6 +19,7 @@ The user-visible outcome is not merely “new projects were added.” The outcom
- [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`.
- [x] (2026-04-16 09:46Z) Implemented the first runnable simulation spine with `Simulation`, `SimulationState`, `WorldSnapshot`, `TickActionBatch`, deterministic fixed-tick movement, deterministic random advancement, and per-tick state hashes. - [x] (2026-04-16 09:46Z) Implemented the first runnable simulation spine with `Simulation`, `SimulationState`, `WorldSnapshot`, `TickActionBatch`, deterministic fixed-tick movement, deterministic random advancement, and per-tick state hashes.
- [x] (2026-04-16 09:46Z) Implemented versioned save/load, replay recording, replay playback, and the `None`, `RoundTripState`, and `RoundTripAndStepClone` verification modes with deterministic unit coverage. - [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.
- [x] (2026-04-16 10:34Z) Added the first data-driven level runtime slice: world bounds clamping, hazard damage resolution, one-shot trigger activation, and serialization coverage for health plus activated triggers.
- [ ] 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.
@@ -41,6 +42,8 @@ The user-visible outcome is not merely “new projects were added.” The outcom
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. - 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. Evidence: the simulation tests replay hashes successfully after round-tripping `ReplayRecordSerializer` and `Simulation.SaveState` payloads.
- Observation: the first meaningful gameplay slice can stay fully data-driven by modeling the level as world bounds plus axis-aligned hazards and triggers, while still preserving deterministic hashes and save/load behavior.
Evidence: the latest simulation tests clamp movement to authored bounds, apply hazard damage, and persist activated trigger ids through `Simulation.SaveState`.
## Decision Log ## Decision Log
@@ -62,6 +65,9 @@ The user-visible outcome is not merely “new projects were added.” The outcom
- Decision: use deterministic JSON documents for the first state and replay formats instead of building a custom binary serializer immediately. - 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. 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 Date/Author: 2026-04-16 / Codex
- Decision: start Milestone 4 with axis-aligned world bounds, hazards, and one-shot triggers before tackling richer collision geometry or combat systems.
Rationale: this delivers the first data-driven fixed-step gameplay rules with low implementation risk and keeps the deterministic test surface small enough to maintain 100 percent coverage.
Date/Author: 2026-04-16 / Codex
## Outcomes & Retrospective ## Outcomes & Retrospective

View File

@@ -0,0 +1,28 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record AxisAlignedBounds
{
public AxisAlignedBounds(FixPointVector2 min, FixPointVector2 max)
{
Min = min;
Max = max;
}
public bool Contains(FixPointVector2 position)
{
return position.m_X >= Min.m_X && position.m_X <= Max.m_X && position.m_Y >= Min.m_Y && position.m_Y <= Max.m_Y;
}
public FixPointVector2 Clamp(FixPointVector2 position)
{
return new(FixPoint16.Clamp(position.m_X, Min.m_X, Max.m_X), FixPoint16.Clamp(position.m_Y, Min.m_Y, Max.m_Y));
}
public FixPointVector2 Min { get; init; }
public FixPointVector2 Max { get; init; }
}

View File

@@ -6,10 +6,13 @@ namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed record GameDefinition public sealed record GameDefinition
{ {
public GameDefinition(ImmutableArray<PlayerDefinition> players) public GameDefinition(LevelDefinition level, ImmutableArray<PlayerDefinition> players)
{ {
Level = level;
Players = players.IsDefault ? ImmutableArray<PlayerDefinition>.Empty : players; Players = players.IsDefault ? ImmutableArray<PlayerDefinition>.Empty : players;
} }
public LevelDefinition Level { get; init; }
public ImmutableArray<PlayerDefinition> Players { get; init; } public ImmutableArray<PlayerDefinition> Players { get; init; }
} }

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record HazardDefinition
{
public HazardDefinition(string id, AxisAlignedBounds bounds, int damagePerTick)
{
Id = id;
Bounds = bounds;
DamagePerTick = damagePerTick;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
public int DamagePerTick { get; init; }
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record LevelDefinition
{
public LevelDefinition(AxisAlignedBounds worldBounds, ImmutableArray<HazardDefinition> hazards, ImmutableArray<TriggerDefinition> triggers)
{
WorldBounds = worldBounds;
Hazards = hazards.IsDefault ? ImmutableArray<HazardDefinition>.Empty : hazards;
Triggers = triggers.IsDefault ? ImmutableArray<TriggerDefinition>.Empty : triggers;
}
public AxisAlignedBounds WorldBounds { get; init; }
public ImmutableArray<HazardDefinition> Hazards { get; init; }
public ImmutableArray<TriggerDefinition> Triggers { get; init; }
}

View File

@@ -6,13 +6,16 @@ namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed record PlayerDefinition public sealed record PlayerDefinition
{ {
public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition) public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition, int maxHealth)
{ {
PlayerId = playerId; PlayerId = playerId;
SpawnPosition = spawnPosition; SpawnPosition = spawnPosition;
MaxHealth = maxHealth;
} }
public PlayerId PlayerId { get; init; } public PlayerId PlayerId { get; init; }
public FixPointVector2 SpawnPosition { get; init; } public FixPointVector2 SpawnPosition { get; init; }
public int MaxHealth { get; init; }
} }

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record TriggerDefinition
{
public TriggerDefinition(string id, AxisAlignedBounds bounds, string kind)
{
Id = id;
Bounds = bounds;
Kind = kind;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
public string Kind { get; init; }
}

View File

@@ -6,12 +6,13 @@ namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed record PlayerSnapshot public sealed record PlayerSnapshot
{ {
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY) public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, int health)
{ {
PlayerId = playerId; PlayerId = playerId;
Position = position; Position = position;
MoveAxisX = moveAxisX; MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY; MoveAxisY = moveAxisY;
Health = health;
} }
public PlayerId PlayerId { get; init; } public PlayerId PlayerId { get; init; }
@@ -21,4 +22,6 @@ public sealed record PlayerSnapshot
public sbyte MoveAxisX { get; init; } public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; } public sbyte MoveAxisY { get; init; }
public int Health { get; init; }
} }

View File

@@ -5,7 +5,7 @@ namespace SideScrollerGame.Sim.Runtime;
public sealed class PlayerState public sealed class PlayerState
{ {
public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask) public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask, int health)
{ {
PlayerId = playerId; PlayerId = playerId;
Position = position; Position = position;
@@ -15,11 +15,12 @@ public sealed class PlayerState
AimAxisY = aimAxisY; AimAxisY = aimAxisY;
SelectedWeaponSlot = selectedWeaponSlot; SelectedWeaponSlot = selectedWeaponSlot;
ButtonMask = buttonMask; ButtonMask = buttonMask;
Health = health;
} }
public PlayerState Clone() public PlayerState Clone()
{ {
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask); return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask, Health);
} }
public void SetMoveAxis(sbyte x, sbyte y) public void SetMoveAxis(sbyte x, sbyte y)
@@ -50,6 +51,16 @@ public sealed class PlayerState
Position += new FixPointVector2(MoveAxisX, MoveAxisY); Position += new FixPointVector2(MoveAxisX, MoveAxisY);
} }
public void ApplyDamage(int damage)
{
Health = System.Math.Max(0, Health - damage);
}
public void SetPosition(FixPointVector2 position)
{
Position = position;
}
public PlayerId PlayerId { get; } public PlayerId PlayerId { get; }
public FixPointVector2 Position { get; private set; } public FixPointVector2 Position { get; private set; }
@@ -65,4 +76,6 @@ public sealed class PlayerState
public int SelectedWeaponSlot { get; private set; } public int SelectedWeaponSlot { get; private set; }
public int ButtonMask { get; private set; } public int ButtonMask { get; private set; }
public int Health { get; private set; }
} }

View File

@@ -4,13 +4,14 @@ namespace SideScrollerGame.Sim.Runtime;
public sealed class SimulationState public sealed class SimulationState
{ {
public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray<PlayerState> players) public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray<PlayerState> players, ImmutableHashSet<string> activatedTriggerIds)
{ {
Tick = tick; Tick = tick;
Seed = seed; Seed = seed;
RandomState = randomState; RandomState = randomState;
LastRandomValue = lastRandomValue; LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerState>.Empty : players; Players = players.IsDefault ? ImmutableArray<PlayerState>.Empty : players;
ActivatedTriggerIds = activatedTriggerIds == default ? ImmutableHashSet<string>.Empty : activatedTriggerIds;
} }
public SimulationState Clone() public SimulationState Clone()
@@ -19,7 +20,7 @@ public sealed class SimulationState
foreach (var player in Players) foreach (var player in Players)
builder.Add(player.Clone()); builder.Add(player.Clone());
return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable()); return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable(), ActivatedTriggerIds);
} }
public PlayerState GetRequiredPlayer(PlayerId playerId) public PlayerState GetRequiredPlayer(PlayerId playerId)
@@ -40,6 +41,15 @@ public sealed class SimulationState
LastRandomValue = lastRandomValue; LastRandomValue = lastRandomValue;
} }
public bool ActivateTrigger(string triggerId)
{
if (ActivatedTriggerIds.Contains(triggerId))
return false;
ActivatedTriggerIds = ActivatedTriggerIds.Add(triggerId);
return true;
}
public int Tick { get; private set; } public int Tick { get; private set; }
public int Seed { get; } public int Seed { get; }
@@ -49,4 +59,6 @@ public sealed class SimulationState
public ulong LastRandomValue { get; private set; } public ulong LastRandomValue { get; private set; }
public ImmutableArray<PlayerState> Players { get; } public ImmutableArray<PlayerState> Players { get; }
public ImmutableHashSet<string> ActivatedTriggerIds { get; private set; }
} }

View File

@@ -11,6 +11,8 @@ internal static class GameDefinitionHasher
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
private sealed record GameDefinitionDocument private sealed record GameDefinitionDocument
{ {
public LevelDefinitionDocument Level { get; init; } = null!;
public ImmutableArray<PlayerDefinitionDocument> Players { get; init; } public ImmutableArray<PlayerDefinitionDocument> Players { get; init; }
} }
@@ -22,6 +24,50 @@ internal static class GameDefinitionHasher
public int SpawnX { get; init; } public int SpawnX { get; init; }
public int SpawnY { get; init; } public int SpawnY { get; init; }
public int MaxHealth { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record LevelDefinitionDocument
{
public BoundsDocument WorldBounds { get; init; } = null!;
public ImmutableArray<HazardDefinitionDocument> Hazards { get; init; }
public ImmutableArray<TriggerDefinitionDocument> Triggers { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record BoundsDocument
{
public int MinX { get; init; }
public int MinY { get; init; }
public int MaxX { get; init; }
public int MaxY { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record HazardDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
public int DamagePerTick { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record TriggerDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
public string Kind { get; init; } = string.Empty;
} }
public static int Compute(GameDefinition gameDefinition) public static int Compute(GameDefinition gameDefinition)
@@ -33,12 +79,55 @@ internal static class GameDefinitionHasher
{ {
PlayerId = player.PlayerId.Value, PlayerId = player.PlayerId.Value,
SpawnX = player.SpawnPosition.m_X.m_Value, SpawnX = player.SpawnPosition.m_X.m_Value,
SpawnY = player.SpawnPosition.m_Y.m_Value SpawnY = player.SpawnPosition.m_Y.m_Value,
MaxHealth = player.MaxHealth
}); });
} }
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument { Players = players.ToImmutableArray() }); List<HazardDefinitionDocument> hazards = new(gameDefinition.Level.Hazards.Length);
foreach (var hazard in gameDefinition.Level.Hazards)
{
hazards.Add(new()
{
Id = hazard.Id,
Bounds = ToDocument(hazard.Bounds),
DamagePerTick = hazard.DamagePerTick
});
}
List<TriggerDefinitionDocument> triggers = new(gameDefinition.Level.Triggers.Length);
foreach (var trigger in gameDefinition.Level.Triggers)
{
triggers.Add(new()
{
Id = trigger.Id,
Bounds = ToDocument(trigger.Bounds),
Kind = trigger.Kind
});
}
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument
{
Level = new()
{
WorldBounds = ToDocument(gameDefinition.Level.WorldBounds),
Hazards = hazards.ToImmutableArray(),
Triggers = triggers.ToImmutableArray()
},
Players = players.ToImmutableArray()
});
return DeterministicHash.Compute(bytes); return DeterministicHash.Compute(bytes);
} }
private static BoundsDocument ToDocument(AxisAlignedBounds bounds)
{
return new()
{
MinX = bounds.Min.m_X.m_Value,
MinY = bounds.Min.m_Y.m_Value,
MaxX = bounds.Max.m_X.m_Value,
MaxY = bounds.Max.m_Y.m_Value
};
}
} }

View File

@@ -23,6 +23,8 @@ internal static class SimulationStateSerializer
public ulong LastRandomValue { get; init; } public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerStateDocument> Players { get; init; } public ImmutableArray<PlayerStateDocument> Players { get; init; }
public ImmutableArray<string> ActivatedTriggerIds { get; init; }
} }
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
@@ -45,6 +47,8 @@ internal static class SimulationStateSerializer
public int SelectedWeaponSlot { get; init; } public int SelectedWeaponSlot { get; init; }
public int ButtonMask { get; init; } public int ButtonMask { get; init; }
public int Health { get; init; }
} }
public static byte[] Serialize(SimulationState state) public static byte[] Serialize(SimulationState state)
@@ -62,7 +66,8 @@ internal static class SimulationStateSerializer
AimAxisX = player.AimAxisX, AimAxisX = player.AimAxisX,
AimAxisY = player.AimAxisY, AimAxisY = player.AimAxisY,
SelectedWeaponSlot = player.SelectedWeaponSlot, SelectedWeaponSlot = player.SelectedWeaponSlot,
ButtonMask = player.ButtonMask ButtonMask = player.ButtonMask,
Health = player.Health
}); });
} }
@@ -73,7 +78,8 @@ internal static class SimulationStateSerializer
Seed = state.Seed, Seed = state.Seed,
RandomState = state.RandomState, RandomState = state.RandomState,
LastRandomValue = state.LastRandomValue, LastRandomValue = state.LastRandomValue,
Players = players.ToImmutableArray() Players = players.ToImmutableArray(),
ActivatedTriggerIds = state.ActivatedTriggerIds.Order(StringComparer.Ordinal).ToImmutableArray()
}); });
} }
@@ -85,10 +91,8 @@ internal static class SimulationStateSerializer
var players = ImmutableArray.CreateBuilder<PlayerState>(document.Players.Length); var players = ImmutableArray.CreateBuilder<PlayerState>(document.Players.Length);
foreach (var player in document.Players) 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, player.Health));
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()); return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable(), document.ActivatedTriggerIds.ToImmutableHashSet(StringComparer.Ordinal));
} }
} }

View File

@@ -47,6 +47,9 @@ public sealed class Simulation
ApplyActions(actions); ApplyActions(actions);
var nextRandomState = AdvanceRandom(); var nextRandomState = AdvanceRandom();
AdvancePlayers(actions.Tick, events); AdvancePlayers(actions.Tick, events);
ResolveBounds(actions.Tick, events);
ResolveHazards(actions.Tick, events);
ResolveTriggers(actions.Tick, events);
CurrentState.AdvanceTick(actions.Tick, nextRandomState, nextRandomState); CurrentState.AdvanceTick(actions.Tick, nextRandomState, nextRandomState);
var stateHash = ComputeStateHash(CurrentState); var stateHash = ComputeStateHash(CurrentState);
@@ -81,10 +84,10 @@ public sealed class Simulation
var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length); var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length);
foreach (var player in gameDefinition.Players) foreach (var player in gameDefinition.Players)
players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0)); players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0, player.MaxHealth));
var normalizedSeed = NormalizeSeed(seed); var normalizedSeed = NormalizeSeed(seed);
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable()); return new(0, seed, normalizedSeed, 0, players.MoveToImmutable(), ImmutableHashSet<string>.Empty);
} }
private static ulong NormalizeSeed(int seed) private static ulong NormalizeSeed(int seed)
@@ -99,6 +102,12 @@ public sealed class Simulation
{ {
if (!seen.Add(player.PlayerId.Value)) if (!seen.Add(player.PlayerId.Value))
throw new InvalidOperationException($"Duplicate player id {player.PlayerId.Value}."); throw new InvalidOperationException($"Duplicate player id {player.PlayerId.Value}.");
if (player.MaxHealth <= 0)
throw new InvalidOperationException($"Player {player.PlayerId.Value} must have positive health.");
if (!gameDefinition.Level.WorldBounds.Contains(player.SpawnPosition))
throw new InvalidOperationException($"Player {player.PlayerId.Value} spawn must start inside world bounds.");
} }
} }
@@ -111,7 +120,7 @@ public sealed class Simulation
{ {
var players = ImmutableArray.CreateBuilder<PlayerSnapshot>(state.Players.Length); var players = ImmutableArray.CreateBuilder<PlayerSnapshot>(state.Players.Length);
foreach (var player in state.Players) foreach (var player in state.Players)
players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY)); players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY, player.Health));
return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable()); return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable());
} }
@@ -152,6 +161,48 @@ public sealed class Simulation
} }
} }
private void ResolveBounds(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
var clamped = m_GameDefinition.Level.WorldBounds.Clamp(player.Position);
if (clamped != player.Position)
{
player.SetPosition(clamped);
events.Add(new("PlayerClamped", tick, player.PlayerId));
}
}
}
private void ResolveHazards(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
foreach (var hazard in m_GameDefinition.Level.Hazards)
{
if (!hazard.Bounds.Contains(player.Position))
continue;
player.ApplyDamage(hazard.DamagePerTick);
events.Add(new("PlayerDamaged", tick, player.PlayerId));
}
}
}
private void ResolveTriggers(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
foreach (var trigger in m_GameDefinition.Level.Triggers)
{
if (!trigger.Bounds.Contains(player.Position) || !CurrentState.ActivateTrigger(trigger.Id))
continue;
events.Add(new(trigger.Kind, tick, player.PlayerId));
}
}
}
private ulong AdvanceRandom() private ulong AdvanceRandom()
{ {
SIntRandom random = new(CurrentState.RandomState); SIntRandom random = new(CurrentState.RandomState);

View File

@@ -1,3 +1,5 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input; using SideScrollerGame.Sim.Input;
namespace SideScrollerGame.Sim.Tests; namespace SideScrollerGame.Sim.Tests;
@@ -7,7 +9,7 @@ public sealed class SimulationSerializationTests
[Fact] [Fact]
public void SaveStateLoadState_PreservesStateAndNextStepHash() public void SaveStateLoadState_PreservesStateAndNextStepHash()
{ {
var definition = SimulationTestFactory.CreateGameDefinition(); var definition = SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(11, 21), new(12, 22)), "TriggerActivated")));
var config = SimulationTestFactory.CreateConfig(); var config = SimulationTestFactory.CreateConfig();
Simulation original = new(definition, config, 17); Simulation original = new(definition, config, 17);
@@ -18,6 +20,8 @@ public sealed class SimulationSerializationTests
Assert.Equal(original.CurrentTick, loaded.CurrentTick); Assert.Equal(original.CurrentTick, loaded.CurrentTick);
Assert.Equal(original.CurrentSnapshot.StateHash, loaded.CurrentSnapshot.StateHash); Assert.Equal(original.CurrentSnapshot.StateHash, loaded.CurrentSnapshot.StateHash);
Assert.Equal(original.CurrentState.GetRequiredPlayer(new(1)).Health, loaded.CurrentState.GetRequiredPlayer(new(1)).Health);
Assert.Equal(original.CurrentState.ActivatedTriggerIds, loaded.CurrentState.ActivatedTriggerIds);
var nextBatch = SimulationTestFactory.CreateTick(2, new ButtonChanged(new(1), InputButton.FirePrimary, true)); var nextBatch = SimulationTestFactory.CreateTick(2, new ButtonChanged(new(1), InputButton.FirePrimary, true));
var originalHash = original.Step(nextBatch).StateHash; var originalHash = original.Step(nextBatch).StateHash;

View File

@@ -9,38 +9,61 @@ public sealed class SimulationStateTests
[Fact] [Fact]
public void PlayerState_CloneAndButtonReleasePreserveIndependentState() public void PlayerState_CloneAndButtonReleasePreserveIndependentState()
{ {
PlayerState player = new(new(1), new(3, 4), 1, 2, 5, 6, 7, 0); PlayerState player = new(new(1), new(3, 4), 1, 2, 5, 6, 7, 0, 9);
player.SetButton(InputButton.Dash, true); player.SetButton(InputButton.Dash, true);
player.ApplyDamage(4);
var clone = player.Clone(); var clone = player.Clone();
player.SetButton(InputButton.Dash, false); player.SetButton(InputButton.Dash, false);
player.SetPosition(new(8, 9));
Assert.NotEqual(player.ButtonMask, clone.ButtonMask); Assert.NotEqual(player.ButtonMask, clone.ButtonMask);
Assert.Equal(7, clone.SelectedWeaponSlot); Assert.Equal(7, clone.SelectedWeaponSlot);
Assert.Equal(3, clone.Position.m_X.ToIntRound()); Assert.Equal(3, clone.Position.m_X.ToIntRound());
Assert.Equal(4, clone.Position.m_Y.ToIntRound()); Assert.Equal(4, clone.Position.m_Y.ToIntRound());
Assert.Equal(5, clone.Health);
} }
[Fact] [Fact]
public void SimulationState_CloneCreatesDeepCopy() 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))); SimulationState original = new(4, 9, 123UL, 456UL, ImmutableArray.Create(new PlayerState(new(1), new(1, 2), 3, 4, 5, 6, 7, 8, 9)), ImmutableHashSet<string>.Empty.Add("checkpoint_a"));
var clone = original.Clone(); var clone = original.Clone();
original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9); original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9);
original.ActivateTrigger("checkpoint_b");
Assert.Equal(4, clone.Tick); Assert.Equal(4, clone.Tick);
Assert.Equal(9, clone.Seed); Assert.Equal(9, clone.Seed);
Assert.Equal((ulong)123, clone.RandomState); Assert.Equal((ulong)123, clone.RandomState);
Assert.Equal((ulong)456, clone.LastRandomValue); Assert.Equal((ulong)456, clone.LastRandomValue);
Assert.Equal(3, clone.GetRequiredPlayer(new(1)).MoveAxisX); Assert.Equal(3, clone.GetRequiredPlayer(new(1)).MoveAxisX);
Assert.DoesNotContain("checkpoint_b", clone.ActivatedTriggerIds);
} }
[Fact] [Fact]
public void SimulationState_AcceptsDefaultPlayerArray() public void SimulationState_AcceptsDefaultPlayerArray()
{ {
SimulationState state = new(0, 1, 1UL, 0UL, default); SimulationState state = new(0, 1, 1UL, 0UL, default, ImmutableHashSet<string>.Empty);
Assert.Empty(state.Players); Assert.Empty(state.Players);
Assert.Empty(state.ActivatedTriggerIds);
}
[Fact]
public void SimulationState_AcceptsDefaultTriggerSet()
{
SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray<PlayerState>.Empty, default!);
Assert.Empty(state.ActivatedTriggerIds);
}
[Fact]
public void ActivateTrigger_ReturnsFalseWhenRepeated()
{
SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray<PlayerState>.Empty, ImmutableHashSet<string>.Empty);
Assert.True(state.ActivateTrigger("checkpoint_a"));
Assert.False(state.ActivateTrigger("checkpoint_a"));
} }
} }

View File

@@ -27,11 +27,71 @@ public sealed class SimulationStepTests
Assert.Equal(30, player.AimAxisX); Assert.Equal(30, player.AimAxisX);
Assert.Equal(40, player.AimAxisY); Assert.Equal(40, player.AimAxisY);
Assert.Equal(3, player.SelectedWeaponSlot); Assert.Equal(3, player.SelectedWeaponSlot);
Assert.Equal(10, player.Health);
Assert.NotEqual(0, player.ButtonMask); Assert.NotEqual(0, player.ButtonMask);
Assert.Equal(result.StateHash, simulation.CurrentSnapshot.StateHash); Assert.Equal(result.StateHash, simulation.CurrentSnapshot.StateHash);
Assert.NotEqual(0UL, simulation.CurrentSnapshot.LastRandomValue); Assert.NotEqual(0UL, simulation.CurrentSnapshot.LastRandomValue);
} }
[Fact]
public void Step_ClampsPlayerToWorldBounds()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(11, 20))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 5, 0)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(11, player.Position.m_X.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_AppliesHazardDamage()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(hazards: ImmutableArray.Create(new HazardDefinition("lava", new(new(11, 20), new(12, 21)), 3))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(7, player.Health);
Assert.Contains(result.Events, static e => e.Kind == "PlayerDamaged");
}
[Fact]
public void Step_IgnoresHazardsWhenPlayerIsOutside()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(hazards: ImmutableArray.Create(new HazardDefinition("lava", new(new(50, 50), new(60, 60)), 3))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(10, simulation.CurrentState.GetRequiredPlayer(new(1)).Health);
Assert.DoesNotContain(result.Events, static e => e.Kind == "PlayerDamaged");
}
[Fact]
public void Step_ActivatesTriggerOnlyOnce()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(11, 20), new(12, 21)), "TriggerActivated"))), SimulationTestFactory.CreateConfig(), 7);
var first = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
var second = simulation.Step(SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0)));
Assert.Contains(first.Events, static e => e.Kind == "TriggerActivated");
Assert.DoesNotContain(second.Events, static e => e.Kind == "TriggerActivated");
Assert.Contains("checkpoint_a", simulation.CurrentState.ActivatedTriggerIds);
}
[Fact]
public void Step_IgnoresTriggersWhenPlayerIsOutside()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(50, 50), new(60, 60)), "TriggerActivated"))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.DoesNotContain(result.Events, static e => e.Kind == "TriggerActivated");
Assert.Empty(simulation.CurrentState.ActivatedTriggerIds);
}
[Fact] [Fact]
public void Step_RejectsUnexpectedTickNumbers() public void Step_RejectsUnexpectedTickNumbers()
{ {
@@ -65,13 +125,33 @@ public sealed class SimulationStepTests
[Fact] [Fact]
public void Constructor_RejectsDuplicatePlayers() public void Constructor_RejectsDuplicatePlayers()
{ {
GameDefinition definition = new(ImmutableArray.Create(new(new(1), new(0, 0)), new PlayerDefinition(new(1), new(2, 3)))); GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty), ImmutableArray.Create(new(new(1), new(0, 0), 10), new PlayerDefinition(new(1), new(2, 3), 10)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("Duplicate player id 1", exception.Message); Assert.Contains("Duplicate player id 1", exception.Message);
} }
[Fact]
public void Constructor_RejectsSpawnOutsideBounds()
{
GameDefinition definition = new(new(new(new(0, 0), new(5, 5)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("spawn must start inside world bounds", exception.Message);
}
[Fact]
public void Constructor_RejectsNonPositiveHealth()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 0)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("positive health", exception.Message);
}
[Fact] [Fact]
public void Constructor_RejectsNullDefinition() public void Constructor_RejectsNullDefinition()
{ {

View File

@@ -7,9 +7,9 @@ namespace SideScrollerGame.Sim.Tests;
internal static class SimulationTestFactory internal static class SimulationTestFactory
{ {
public static GameDefinition CreateGameDefinition() public static GameDefinition CreateGameDefinition(AxisAlignedBounds? worldBounds = null, ImmutableArray<HazardDefinition> hazards = default, ImmutableArray<TriggerDefinition> triggers = default)
{ {
return new(ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20)))); return new(new(worldBounds ?? new(new(0, 0), new(100, 100)), hazards, triggers), ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)));
} }
public static SimulationConfig CreateConfig(VerificationMode verificationMode = VerificationMode.None) public static SimulationConfig CreateConfig(VerificationMode verificationMode = VerificationMode.None)