extends GutTest ## The security tests. Each one describes something a modified client would try ## and asserts that the authoritative world does not let it happen. ## ## The design intent is that these are boring to write, because the client has ## no message that expresses the cheat in the first place -- it can only send ## intent. These tests pin that property down so a future "just let the client ## send its position, it is simpler" change fails loudly. var world: SimWorld const PEER := 7 func before_each() -> void: world = SimWorld.new(1) world.add_player(PEER, "tester") func _send(frame_tick: int, move := Vector2.ZERO, buttons := 0, aim := 0.0) -> void: var frames: Array[InputFrame] = [InputFrame.make(frame_tick, move, aim, buttons)] world.queue_input(PEER, frames) ## Drive the player for [param ticks] ticks with one fresh input per tick. func _drive(ticks: int, move := Vector2.ZERO, buttons := 0, aim := 0.0) -> void: for _i in ticks: _send(world.tick + 1, move, buttons, aim) world.step() func test_player_cannot_outrun_the_configured_speed() -> void: var start: Vector2 = world.players[PEER].pos _drive(60, Vector2(1.0, 0.0)) var travelled: float = world.players[PEER].pos.distance_to(start) assert_almost_eq(travelled, SimConfig.PLAYER_SPEED, 1.0, "one second of held input must cover exactly one second of movement") func test_replayed_input_is_dropped() -> void: _drive(5, Vector2.RIGHT) var pos_after: Vector2 = world.players[PEER].pos var acked: int = world.players[PEER].last_input_tick # Re-send an already-consumed tick, the classic replay attack. _send(acked, Vector2.RIGHT) assert_eq(world.players[PEER].input_queue.size(), 0) world.step() # The held input coasts one more tick, which is expected; what matters is # that the stale frame did not stack a second move on top of it. assert_almost_eq(world.players[PEER].pos.distance_to(pos_after), SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA, 0.001) func test_input_from_the_far_future_is_dropped() -> void: _send(world.tick + SimConfig.INPUT_MAX_LEAD + 50, Vector2.RIGHT) assert_eq(world.players[PEER].input_queue.size(), 0, "a client cannot buy a head start by claiming a future tick") func test_ancient_input_is_dropped() -> void: world.tick = 10000 _send(1, Vector2.RIGHT) assert_eq(world.players[PEER].input_queue.size(), 0) func test_input_flood_cannot_grow_the_queue_without_bound() -> void: for i in 500: _send(world.tick + 1 + i, Vector2.RIGHT) assert_lte(world.players[PEER].input_queue.size(), SimConfig.INPUT_MAX_AGE, "a flood of inputs must not become unbounded server memory") func test_fire_rate_is_enforced_by_the_server() -> void: # Hold fire every single tick; the server still applies its own cooldown. _drive(60, Vector2.ZERO, InputFrame.BTN_FIRE) var expected := 60 / SimConfig.PLAYER_FIRE_COOLDOWN assert_almost_eq(float(world.pool.live_count), float(expected), 2.0, "holding fire must not fire faster than the cooldown allows") func test_a_starved_player_eventually_stops_moving() -> void: _drive(3, Vector2.RIGHT) var pos_at_starve: Vector2 = world.players[PEER].pos # Send nothing at all for a long time, as a disconnecting client would. for _i in SimConfig.INPUT_MAX_AGE + 120: world.step() var coasted: float = world.players[PEER].pos.distance_to(pos_at_starve) assert_lt(coasted, SimConfig.PLAYER_SPEED * 1.0, "a silent client must coast briefly, then stop, not drift forever") func test_enemy_bullets_damage_the_player_and_are_consumed() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.iframes = 0 world.pool.spawn(Vector2(-1.0, 0.0), Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 25) assert_eq(world.pool.live_count, 0, "a bullet that hits must be consumed") func test_invulnerability_frames_stop_a_second_hit() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.iframes = 0 for _i in 2: world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 25, "two bullets in one tick is still one hit") func test_a_replica_world_never_resolves_a_hit() -> void: world.authoritative = false var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.iframes = 0 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() assert_eq(p.hp, SimConfig.PLAYER_MAX_HP, "only the server decides damage; a client replica must never apply it") func test_player_death_and_respawn() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.hp = 5 p.iframes = 0 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() assert_false(p.alive) for _i in SimConfig.PLAYER_RESPAWN_DELAY + 1: world.step() assert_true(p.alive) assert_eq(p.hp, SimConfig.PLAYER_MAX_HP) assert_eq(p.pos, world.spawn_point)