From 45181d1f784bcf3ea8d492c4f0953c83fdf4bd34 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 16 Apr 2026 11:50:37 +0200 Subject: [PATCH] Add bounds hazards and triggers --- groundwork.md | 6 ++ .../Definitions/AxisAlignedBounds.cs | 28 ++++++ .../Definitions/GameDefinition.cs | 5 +- .../Definitions/HazardDefinition.cs | 20 ++++ .../Definitions/LevelDefinition.cs | 21 +++++ .../Definitions/PlayerDefinition.cs | 5 +- .../Definitions/TriggerDefinition.cs | 20 ++++ .../Runtime/PlayerSnapshot.cs | 5 +- .../Runtime/PlayerState.cs | 17 +++- .../Runtime/SimulationState.cs | 16 +++- .../Serialization/GameDefinitionHasher.cs | 93 ++++++++++++++++++- .../SimulationStateSerializer.cs | 16 ++-- src/SideScrollerGame.Sim/Simulation.cs | 57 +++++++++++- .../SimulationSerializationTests.cs | 6 +- .../SimulationStateTests.cs | 29 +++++- .../SimulationStepTests.cs | 82 +++++++++++++++- .../SimulationTestFactory.cs | 4 +- 17 files changed, 405 insertions(+), 25 deletions(-) create mode 100644 src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs create mode 100644 src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs create mode 100644 src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs create mode 100644 src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs diff --git a/groundwork.md b/groundwork.md index e548e08..26da3d7 100644 --- a/groundwork.md +++ b/groundwork.md @@ -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: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 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 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. @@ -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. - 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. +- 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 @@ -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. 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 +- 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 diff --git a/src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs b/src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs new file mode 100644 index 0000000..00dbc82 --- /dev/null +++ b/src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs @@ -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; } +} \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Definitions/GameDefinition.cs b/src/SideScrollerGame.Sim/Definitions/GameDefinition.cs index 91a45f0..526cbc3 100644 --- a/src/SideScrollerGame.Sim/Definitions/GameDefinition.cs +++ b/src/SideScrollerGame.Sim/Definitions/GameDefinition.cs @@ -6,10 +6,13 @@ namespace SideScrollerGame.Sim.Definitions; [ExcludeFromCodeCoverage] public sealed record GameDefinition { - public GameDefinition(ImmutableArray players) + public GameDefinition(LevelDefinition level, ImmutableArray players) { + Level = level; Players = players.IsDefault ? ImmutableArray.Empty : players; } + public LevelDefinition Level { get; init; } + public ImmutableArray Players { get; init; } } \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs b/src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs new file mode 100644 index 0000000..1f0f488 --- /dev/null +++ b/src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs @@ -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; } +} \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs b/src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs new file mode 100644 index 0000000..9c057db --- /dev/null +++ b/src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs @@ -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 hazards, ImmutableArray triggers) + { + WorldBounds = worldBounds; + Hazards = hazards.IsDefault ? ImmutableArray.Empty : hazards; + Triggers = triggers.IsDefault ? ImmutableArray.Empty : triggers; + } + + public AxisAlignedBounds WorldBounds { get; init; } + + public ImmutableArray Hazards { get; init; } + + public ImmutableArray Triggers { get; init; } +} \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs b/src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs index a5d11d0..06e2d1a 100644 --- a/src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs +++ b/src/SideScrollerGame.Sim/Definitions/PlayerDefinition.cs @@ -6,13 +6,16 @@ namespace SideScrollerGame.Sim.Definitions; [ExcludeFromCodeCoverage] public sealed record PlayerDefinition { - public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition) + public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition, int maxHealth) { PlayerId = playerId; SpawnPosition = spawnPosition; + MaxHealth = maxHealth; } public PlayerId PlayerId { get; init; } public FixPointVector2 SpawnPosition { get; init; } + + public int MaxHealth { get; init; } } \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs b/src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs new file mode 100644 index 0000000..3f41593 --- /dev/null +++ b/src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs @@ -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; } +} \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs b/src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs index 13f84df..ffcff28 100644 --- a/src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs +++ b/src/SideScrollerGame.Sim/Runtime/PlayerSnapshot.cs @@ -6,12 +6,13 @@ namespace SideScrollerGame.Sim.Runtime; [ExcludeFromCodeCoverage] 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; Position = position; MoveAxisX = moveAxisX; MoveAxisY = moveAxisY; + Health = health; } public PlayerId PlayerId { get; init; } @@ -21,4 +22,6 @@ public sealed record PlayerSnapshot public sbyte MoveAxisX { get; init; } public sbyte MoveAxisY { get; init; } + + public int Health { get; init; } } \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Runtime/PlayerState.cs b/src/SideScrollerGame.Sim/Runtime/PlayerState.cs index c46a5b5..f362557 100644 --- a/src/SideScrollerGame.Sim/Runtime/PlayerState.cs +++ b/src/SideScrollerGame.Sim/Runtime/PlayerState.cs @@ -5,7 +5,7 @@ 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) + public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask, int health) { PlayerId = playerId; Position = position; @@ -15,11 +15,12 @@ public sealed class PlayerState AimAxisY = aimAxisY; SelectedWeaponSlot = selectedWeaponSlot; ButtonMask = buttonMask; + Health = health; } 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) @@ -50,6 +51,16 @@ public sealed class PlayerState 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 FixPointVector2 Position { get; private set; } @@ -65,4 +76,6 @@ public sealed class PlayerState public int SelectedWeaponSlot { get; private set; } public int ButtonMask { get; private set; } + + public int Health { get; private set; } } \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Runtime/SimulationState.cs b/src/SideScrollerGame.Sim/Runtime/SimulationState.cs index 537c24f..9b4185b 100644 --- a/src/SideScrollerGame.Sim/Runtime/SimulationState.cs +++ b/src/SideScrollerGame.Sim/Runtime/SimulationState.cs @@ -4,13 +4,14 @@ namespace SideScrollerGame.Sim.Runtime; public sealed class SimulationState { - public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray players) + public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray players, ImmutableHashSet activatedTriggerIds) { Tick = tick; Seed = seed; RandomState = randomState; LastRandomValue = lastRandomValue; Players = players.IsDefault ? ImmutableArray.Empty : players; + ActivatedTriggerIds = activatedTriggerIds == default ? ImmutableHashSet.Empty : activatedTriggerIds; } public SimulationState Clone() @@ -19,7 +20,7 @@ public sealed class SimulationState foreach (var player in Players) 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) @@ -40,6 +41,15 @@ public sealed class SimulationState 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 Seed { get; } @@ -49,4 +59,6 @@ public sealed class SimulationState public ulong LastRandomValue { get; private set; } public ImmutableArray Players { get; } + + public ImmutableHashSet ActivatedTriggerIds { get; private set; } } \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs b/src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs index ae711b3..524f49b 100644 --- a/src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs +++ b/src/SideScrollerGame.Sim/Serialization/GameDefinitionHasher.cs @@ -11,6 +11,8 @@ internal static class GameDefinitionHasher [ExcludeFromCodeCoverage] private sealed record GameDefinitionDocument { + public LevelDefinitionDocument Level { get; init; } = null!; + public ImmutableArray Players { get; init; } } @@ -22,6 +24,50 @@ internal static class GameDefinitionHasher public int SpawnX { 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 Hazards { get; init; } + + public ImmutableArray 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) @@ -33,12 +79,55 @@ internal static class GameDefinitionHasher { PlayerId = player.PlayerId.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 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 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); } + + 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 + }; + } } \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs b/src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs index 65b9131..faf3bd7 100644 --- a/src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs +++ b/src/SideScrollerGame.Sim/Serialization/SimulationStateSerializer.cs @@ -23,6 +23,8 @@ internal static class SimulationStateSerializer public ulong LastRandomValue { get; init; } public ImmutableArray Players { get; init; } + + public ImmutableArray ActivatedTriggerIds { get; init; } } [ExcludeFromCodeCoverage] @@ -45,6 +47,8 @@ internal static class SimulationStateSerializer public int SelectedWeaponSlot { get; init; } public int ButtonMask { get; init; } + + public int Health { get; init; } } public static byte[] Serialize(SimulationState state) @@ -62,7 +66,8 @@ internal static class SimulationStateSerializer AimAxisX = player.AimAxisX, AimAxisY = player.AimAxisY, SelectedWeaponSlot = player.SelectedWeaponSlot, - ButtonMask = player.ButtonMask + ButtonMask = player.ButtonMask, + Health = player.Health }); } @@ -73,7 +78,8 @@ internal static class SimulationStateSerializer Seed = state.Seed, RandomState = state.RandomState, 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(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)); - } + 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)); - 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)); } } \ No newline at end of file diff --git a/src/SideScrollerGame.Sim/Simulation.cs b/src/SideScrollerGame.Sim/Simulation.cs index f9bc15b..64dd606 100644 --- a/src/SideScrollerGame.Sim/Simulation.cs +++ b/src/SideScrollerGame.Sim/Simulation.cs @@ -47,6 +47,9 @@ public sealed class Simulation ApplyActions(actions); var nextRandomState = AdvanceRandom(); AdvancePlayers(actions.Tick, events); + ResolveBounds(actions.Tick, events); + ResolveHazards(actions.Tick, events); + ResolveTriggers(actions.Tick, events); CurrentState.AdvanceTick(actions.Tick, nextRandomState, nextRandomState); var stateHash = ComputeStateHash(CurrentState); @@ -81,10 +84,10 @@ public sealed class Simulation var players = ImmutableArray.CreateBuilder(gameDefinition.Players.Length); foreach (var player in gameDefinition.Players) - players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0)); + players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0, player.MaxHealth)); var normalizedSeed = NormalizeSeed(seed); - return new(0, seed, normalizedSeed, 0, players.MoveToImmutable()); + return new(0, seed, normalizedSeed, 0, players.MoveToImmutable(), ImmutableHashSet.Empty); } private static ulong NormalizeSeed(int seed) @@ -99,6 +102,12 @@ public sealed class Simulation { if (!seen.Add(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(state.Players.Length); 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()); } @@ -152,6 +161,48 @@ public sealed class Simulation } } + private void ResolveBounds(int tick, List 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 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 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() { SIntRandom random = new(CurrentState.RandomState); diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs index 13d0d6d..895e70f 100644 --- a/tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs +++ b/tests/SideScrollerGame.Sim.Tests/SimulationSerializationTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using SideScrollerGame.Sim.Definitions; using SideScrollerGame.Sim.Input; namespace SideScrollerGame.Sim.Tests; @@ -7,7 +9,7 @@ public sealed class SimulationSerializationTests [Fact] 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(); Simulation original = new(definition, config, 17); @@ -18,6 +20,8 @@ public sealed class SimulationSerializationTests Assert.Equal(original.CurrentTick, loaded.CurrentTick); 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 originalHash = original.Step(nextBatch).StateHash; diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs index adcc8b3..b834fab 100644 --- a/tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs +++ b/tests/SideScrollerGame.Sim.Tests/SimulationStateTests.cs @@ -9,38 +9,61 @@ public sealed class SimulationStateTests [Fact] 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.ApplyDamage(4); var clone = player.Clone(); player.SetButton(InputButton.Dash, false); + player.SetPosition(new(8, 9)); 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()); + Assert.Equal(5, clone.Health); } [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))); + SimulationState original = new(4, 9, 123UL, 456UL, ImmutableArray.Create(new PlayerState(new(1), new(1, 2), 3, 4, 5, 6, 7, 8, 9)), ImmutableHashSet.Empty.Add("checkpoint_a")); var clone = original.Clone(); original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9); + original.ActivateTrigger("checkpoint_b"); 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); + Assert.DoesNotContain("checkpoint_b", clone.ActivatedTriggerIds); } [Fact] public void SimulationState_AcceptsDefaultPlayerArray() { - SimulationState state = new(0, 1, 1UL, 0UL, default); + SimulationState state = new(0, 1, 1UL, 0UL, default, ImmutableHashSet.Empty); Assert.Empty(state.Players); + Assert.Empty(state.ActivatedTriggerIds); + } + + [Fact] + public void SimulationState_AcceptsDefaultTriggerSet() + { + SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray.Empty, default!); + + Assert.Empty(state.ActivatedTriggerIds); + } + + [Fact] + public void ActivateTrigger_ReturnsFalseWhenRepeated() + { + SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray.Empty, ImmutableHashSet.Empty); + + Assert.True(state.ActivateTrigger("checkpoint_a")); + Assert.False(state.ActivateTrigger("checkpoint_a")); } } \ No newline at end of file diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs b/tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs index 029032d..f03bb07 100644 --- a/tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs +++ b/tests/SideScrollerGame.Sim.Tests/SimulationStepTests.cs @@ -27,11 +27,71 @@ public sealed class SimulationStepTests Assert.Equal(30, player.AimAxisX); Assert.Equal(40, player.AimAxisY); Assert.Equal(3, player.SelectedWeaponSlot); + Assert.Equal(10, player.Health); Assert.NotEqual(0, player.ButtonMask); Assert.Equal(result.StateHash, simulation.CurrentSnapshot.StateHash); 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] public void Step_RejectsUnexpectedTickNumbers() { @@ -65,13 +125,33 @@ public sealed class SimulationStepTests [Fact] 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.Empty, ImmutableArray.Empty), ImmutableArray.Create(new(new(1), new(0, 0), 10), new PlayerDefinition(new(1), new(2, 3), 10))); var exception = Assert.Throws(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); 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.Empty, ImmutableArray.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10))); + + var exception = Assert.Throws(() => 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.Empty, ImmutableArray.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 0))); + + var exception = Assert.Throws(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); + + Assert.Contains("positive health", exception.Message); + } + [Fact] public void Constructor_RejectsNullDefinition() { diff --git a/tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs b/tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs index 8b63b72..423c875 100644 --- a/tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs +++ b/tests/SideScrollerGame.Sim.Tests/SimulationTestFactory.cs @@ -7,9 +7,9 @@ namespace SideScrollerGame.Sim.Tests; internal static class SimulationTestFactory { - public static GameDefinition CreateGameDefinition() + public static GameDefinition CreateGameDefinition(AxisAlignedBounds? worldBounds = null, ImmutableArray hazards = default, ImmutableArray 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)