using System.Collections.Immutable; using System.Text; using SideScrollerGame.Sim.Definitions; using SideScrollerGame.Sim.Input; using SideScrollerGame.Sim.Math; namespace SideScrollerGame.Sim.Tests; public sealed class SimulationStepTests { private sealed record UnsupportedAction : SimulationAction; private static byte[] CreateStatePayload(int playerId, int positionX, int positionY, bool isGrounded, int lastGroundedTick = -1) { return Encoding.UTF8.GetBytes($$""" {"Version":{{SimulationDefaults.StateFormatVersion}},"Tick":0,"Seed":7,"RandomState":7,"LastRandomValue":0,"Players":[{"PlayerId":{{playerId}},"PositionX":{{positionX * 65536}},"PositionY":{{positionY * 65536}},"MoveAxisX":0,"MoveAxisY":0,"AimAxisX":0,"AimAxisY":0,"SelectedWeaponSlot":0,"ButtonMask":0,"Health":10,"VerticalVelocity":0,"IsGrounded":{{isGrounded.ToString().ToLowerInvariant()}},"LastGroundedTick":{{lastGroundedTick}},"BufferedJumpTick":-1}],"ActivatedTriggerIds":[]} """); } [Fact] public void Step_AdvancesTickSnapshotsAndMovement() { Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7); var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, -1), new AimAxisChanged(new(1), 30, 40), new ButtonChanged(new(1), InputButton.Jump, true), new WeaponSlotSelected(new(1), 3))); Assert.Equal(1, simulation.CurrentTick); Assert.Equal(0, simulation.PreviousSnapshot.Tick); Assert.Equal(0, result.PreviousSnapshot.Tick); Assert.Equal(1, result.CurrentSnapshot.Tick); Assert.Single(result.Events); var player = simulation.CurrentState.GetRequiredPlayer(new(1)); Assert.Equal(12, player.Position.m_X.ToIntRound()); Assert.Equal(19, player.Position.m_Y.ToIntRound()); Assert.Equal(30, player.AimAxisX); Assert.Equal(40, player.AimAxisY); Assert.Equal(3, player.SelectedWeaponSlot); Assert.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() { Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7); var exception = Assert.Throws(() => simulation.Step(TickActionBatch.Empty(2))); Assert.Contains("Expected tick 1", exception.Message); } [Fact] public void Step_RejectsUnknownPlayers() { Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7); var exception = Assert.Throws(() => simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(99), 1, 0)))); Assert.Contains("Unknown player id 99", exception.Message); } [Fact] public void Step_RejectsUnsupportedActions() { Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7); var exception = Assert.Throws(() => simulation.Step(SimulationTestFactory.CreateTick(1, new UnsupportedAction()))); Assert.Contains("Unsupported action type", exception.Message); } [Fact] public void Constructor_RejectsDuplicatePlayers() { GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray.Empty, 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.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.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() { var exception = Assert.Throws(() => new Simulation(null!, SimulationTestFactory.CreateConfig(), 1)); Assert.Equal("gameDefinition", exception.ParamName); } [Fact] public void Constructor_RejectsNullConfig() { var exception = Assert.Throws(() => new Simulation(SimulationTestFactory.CreateGameDefinition(), null!, 1)); Assert.Equal("config", exception.ParamName); } [Fact] public void LoadState_RejectsNullDefinition() { var definition = SimulationTestFactory.CreateGameDefinition(); var config = SimulationTestFactory.CreateConfig(); Simulation simulation = new(definition, config, 2); var bytes = simulation.SaveState(); var exception = Assert.Throws(() => Simulation.LoadState(bytes, null!, config)); Assert.Equal("gameDefinition", exception.ParamName); } [Fact] public void Step_PlatformerJumpUsesBufferedInputAndProducesVerticalVelocity() { var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1), new ButtonChanged(new(1), InputButton.Jump, true))); var player = simulation.CurrentState.GetRequiredPlayer(new(1)); Assert.False(player.IsGrounded); Assert.Equal(12, player.Position.m_X.ToIntRound()); Assert.Equal(18, player.Position.m_Y.ToIntRound()); Assert.Equal(-2, player.VerticalVelocity.ToIntRound()); Assert.Contains(result.Events, static e => e.Kind == "PlayerJumped"); Assert.DoesNotContain(result.Events, static e => e.Kind == "PlayerClamped"); } [Fact] public void Step_PlatformerUsesCoyoteTimeAfterLeavingPlatform() { var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(10, 20), new(12, 22)))); var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); var first = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, 0))); var second = simulation.Step(SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0), new ButtonChanged(new(1), InputButton.Jump, true))); var player = simulation.CurrentState.GetRequiredPlayer(new(1)); Assert.DoesNotContain(first.Events, static e => e.Kind == "PlayerJumped"); Assert.Contains(second.Events, static e => e.Kind == "PlayerJumped"); Assert.False(player.IsGrounded); Assert.Equal(14, player.Position.m_X.ToIntRound()); Assert.Equal(18, player.Position.m_Y.ToIntRound()); } [Fact] public void Step_PlatformerConsumesJumpBufferOnLanding() { var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 18), 10, true, FixPoint16.One, new(2), new(3), 0, 1))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true))); var player = simulation.CurrentState.GetRequiredPlayer(new(1)); Assert.False(player.IsGrounded); Assert.Equal(17, player.Position.m_Y.ToIntRound()); Assert.Equal(-3, player.VerticalVelocity.ToIntRound()); Assert.Contains(result.Events, static e => e.Kind == "PlayerLanded"); Assert.Contains(result.Events, static e => e.Kind == "PlayerJumped"); } [Fact] public void Step_PlatformerClampsHorizontalMovementAtWorldEdge() { var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(11, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0))); Assert.Equal(11, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_X.ToIntRound()); Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped"); } [Fact] public void Step_PlatformerClampsJumpAtWorldCeiling() { var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 2)), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 2), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(4), 0, 1))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true))); var player = simulation.CurrentState.GetRequiredPlayer(new(1)); Assert.Equal(0, player.Position.m_Y.ToIntRound()); Assert.Equal(0, player.VerticalVelocity.ToIntRound()); Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped"); } [Fact] public void Step_PlatformerLandsOnHighestCrossedPlatform() { var platforms = ImmutableArray.Create(new("lower", new(new(8, 20), new(12, 22))), new SolidPlatformDefinition("upper", new(new(8, 15), new(12, 17)))); var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); var result = simulation.Step(SimulationTestFactory.CreateTick(1)); var player = simulation.CurrentState.GetRequiredPlayer(new(1)); Assert.Equal(15, player.Position.m_Y.ToIntRound()); Assert.True(player.IsGrounded); Assert.Contains(result.Events, static e => e.Kind == "PlayerLanded"); } [Fact] public void Step_PlatformerIgnoresPlatformWhenStillAboveTop() { var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17)))); var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 10), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(3), 1, 1))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); simulation.Step(SimulationTestFactory.CreateTick(1)); Assert.Equal(10, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound()); } [Fact] public void Step_PlatformerIgnoresPlatformWhenAlreadyBelowTop() { var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17)))); var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(3), 1, 1))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); simulation.Step(SimulationTestFactory.CreateTick(1)); Assert.Equal(20, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound()); } [Fact] public void Step_PlatformerIgnoresPlatformWhenFallingOutsideRightEdge() { var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17)))); var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(14, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); simulation.Step(SimulationTestFactory.CreateTick(1)); Assert.Equal(22, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound()); } [Fact] public void Step_PlatformerIgnoresPlatformWhenFallingOutsideLeftEdge() { var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17)))); var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(6, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1))); Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7); simulation.Step(SimulationTestFactory.CreateTick(1)); Assert.Equal(22, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound()); } [Fact] public void Step_LoadedPlatformerStateCanRegainGroundedFlagFromSupport() { var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20))); var simulation = Simulation.LoadState(CreateStatePayload(1, 10, 20, false), definition, SimulationTestFactory.CreateConfig()); simulation.Step(SimulationTestFactory.CreateTick(1)); Assert.True(simulation.CurrentState.GetRequiredPlayer(new(1)).IsGrounded); } [Fact] public void Step_LoadedStateBelowFloorClampsBackIntoSupport() { var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20))); var simulation = Simulation.LoadState(CreateStatePayload(1, 10, 25, false), definition, SimulationTestFactory.CreateConfig()); simulation.Step(SimulationTestFactory.CreateTick(1)); var player = simulation.CurrentState.GetRequiredPlayer(new(1)); Assert.Equal(20, player.Position.m_Y.ToIntRound()); Assert.False(player.IsGrounded); } [Fact] public void Step_LoadedStateRejectsPlayersMissingFromDefinition() { var definition = SimulationTestFactory.CreateGameDefinition(players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20))); var simulation = Simulation.LoadState(CreateStatePayload(99, 10, 20, true, 0), definition, SimulationTestFactory.CreateConfig()); var exception = Assert.Throws(() => simulation.Step(SimulationTestFactory.CreateTick(1))); Assert.Contains("Unknown player id 99", exception.Message); } [Fact] public void Constructor_RejectsNegativeJumpBufferTicks() { GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, new(3), 1, -1))); var exception = Assert.Throws(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); Assert.Contains("jump buffer ticks", exception.Message); } [Fact] public void Constructor_RejectsNegativeMoveSpeed() { GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, -FixPoint16.One, FixPoint16.One, new(3), 1, 1))); var exception = Assert.Throws(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); Assert.Contains("move speed", exception.Message); } [Fact] public void Constructor_RejectsNegativeGravity() { GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, -FixPoint16.One, new(3), 1, 1))); var exception = Assert.Throws(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); Assert.Contains("gravity", exception.Message); } [Fact] public void Constructor_RejectsNegativeJumpVelocity() { GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, -FixPoint16.One, 1, 1))); var exception = Assert.Throws(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); Assert.Contains("jump velocity", exception.Message); } [Fact] public void Constructor_RejectsNegativeCoyoteTicks() { GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, new(3), -1, 1))); var exception = Assert.Throws(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11)); Assert.Contains("coyote ticks", exception.Message); } }