Add bounds hazards and triggers
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
28
src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs
Normal file
28
src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
20
src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs
Normal file
20
src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs
Normal 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; }
|
||||||
|
}
|
||||||
21
src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs
Normal file
21
src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
20
src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs
Normal file
20
src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user