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") ## The shot sound used to ride on BULLET_SPAWN, and a bullet born inside an ## enemy is resolved and removed within the same tick -- so no spawn event was ## ever emitted and the shot was silent. Firing is now its own event. func test_firing_is_reported_even_when_the_bullet_dies_instantly() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.aim = 0.0 # The inert practice dummy, sitting exactly where the muzzle is, so the # bullet is consumed on the tick it is created. Deliberately not a turret: # a turret's own ring would fill the event list with its spawns and the # setup check below would pass for the wrong reason. world.spawn_enemy(Content.dummy(), Vector2(SimConfig.PLAYER_MUZZLE_OFFSET, 0.0)) _drive(1, Vector2.ZERO, InputFrame.BTN_FIRE) assert_eq(_events_of(SimEvent.Type.PLAYER_FIRED).size(), 1, "the shot happened and has to be reported") assert_eq(_events_of(SimEvent.Type.BULLET_SPAWN).size(), 0, "setup check: the bullet really did die on the tick it spawned") func test_firing_is_reported_once_per_shot() -> void: _drive(60, Vector2.ZERO, InputFrame.BTN_FIRE) var fired := _events_of(SimEvent.Type.PLAYER_FIRED).size() var expected := 60 / SimConfig.PLAYER_FIRE_COOLDOWN assert_almost_eq(float(fired), float(expected), 1.0, "one report per shot the cooldown actually allowed") 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 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") ## The inverse of what this asserted before. There are no i-frames: in a bullet ## hell the wall is the threat, and letting one hit grant immunity to the rest ## of the wall inverts the whole risk model. func test_every_bullet_in_a_wall_lands() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO for _i in 3: 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 - 75, "three bullets is three hits, not one") func test_consecutive_ticks_both_deal_damage() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 20, "there is no window of immunity after a hit") func test_a_replica_world_never_resolves_a_hit() -> void: world.authoritative = false var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO 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 _kill_player() -> SimPlayer: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.hp = 5 p.spawn_grace = 0 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() assert_false(p.alive, "setup: the player should be down") return p func _events_of(type: int) -> Array: return world.events.filter(func(e: Dictionary) -> bool: return int(e["t"]) == type) func test_a_downed_player_stays_down_without_input() -> void: var p := _kill_player() world.drain_events() for _i in 600: world.step() assert_false(p.alive, "there is no timed respawn -- death waits for the player") assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0) func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void: _kill_player() _drive(SimConfig.RESPAWN_LOCKOUT_TICKS) world.drain_events() _drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT) assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 1, "the request is an event for the instance layer, not a local revive") # Crucially the world does NOT revive the player itself: only the server's # instance layer can, by moving them to the hub. assert_false(world.players[PEER].alive) ## The HUD disables its button for the same duration, but that is presentation. ## A client that ignores its own UI and spams the bit still has to wait. func test_the_respawn_lockout_is_enforced_by_the_server() -> void: _kill_player() world.drain_events() _drive(SimConfig.RESPAWN_LOCKOUT_TICKS - 2, Vector2.ZERO, InputFrame.BTN_INTERACT) assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0, "holding the request down must not shorten the lockout") _drive(4, Vector2.ZERO, InputFrame.BTN_INTERACT) assert_gt(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0, "and it must actually open once the lockout expires") func test_the_lockout_is_reported_to_the_client() -> void: _kill_player() var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) var wait: float = snap["players"][0]["respawn_wait"] assert_almost_eq(wait, float(SimConfig.RESPAWN_LOCKOUT_TICKS) / float(SimConfig.TICK_RATE), 0.3, "the HUD needs the countdown to render its disabled button") func test_respawn_request_never_reaches_the_client() -> void: _kill_player() _drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT) var packet := NetCodec.decode_events(NetCodec.encode_events(world.tick, world.events)) for ev: Dictionary in packet["events"]: assert_ne(int(ev["t"]), SimEvent.Type.RESPAWN_REQUESTED, "where a dead player goes is the server's decision") func test_spawn_grace_blocks_damage_and_firing() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.spawn_grace = 60 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) _drive(1, Vector2.ZERO, InputFrame.BTN_FIRE) assert_eq(p.hp, SimConfig.PLAYER_MAX_HP, "arrival protection must absorb the hit") assert_eq(world.pool.live_count, 1, "only the enemy bullet: a protected player cannot shoot either") func test_spawn_grace_expires() -> void: var p: SimPlayer = world.players[PEER] p.spawn_grace = 5 _drive(6) assert_eq(p.spawn_grace, 0) assert_false(p.invulnerable()) _drive(1, Vector2.ZERO, InputFrame.BTN_FIRE) assert_eq(world.pool.live_count, 1, "the gun comes back once grace ends") ## A muzzle offset moves a bullet's birthplace away from its shooter, and the ## bullet pool samples geometry once per tick so it never sees that jump. With ## six Doubleshots the sideways offset reaches 45px -- wider than a 32px wall ## plus the 6px hitbox that holds you off it -- so standing flush against a wall ## and firing along it put live shots down the far side. No modified client ## needed, which is what made it worth a test rather than a comment. func _wall_at_column(column: int) -> SimWorld: var g := MapGrid.new(10, 6, MapGrid.Kind.FLOOR) for ty in 6: g.set_tile(column, ty, MapGrid.Kind.WALL) var w := SimWorld.new(1) w.set_map(g) return w func _fire_flush_against_the_wall(doubleshots: int) -> Dictionary: var w := _wall_at_column(4) var p := w.add_player(PEER, "tester") var face: float = w.map.origin.x + 4.0 * MapGrid.TILE p.pos = Vector2(face - SimConfig.PLAYER_RADIUS, w.map.tile_centre(3, 3).y) p.aim = -PI / 2.0 # along the wall, not into it p.spawn_grace = 0 p.fire_cooldown = 0 var ids: Array[StringName] = [] for _i in doubleshots: ids.append(Upgrades.DOUBLESHOT) p.stats = PlayerStats.build(ids) w._fire_player_shot(p) var fired := 0 var past := 0 for i in w.pool.high_water: if w.pool.alive[i] == 0: continue fired += 1 if w.pool.pos[i].x > face: past += 1 return {"fired": fired, "past_the_face": past} func test_no_stack_of_upgrades_can_put_a_shot_through_a_wall() -> void: for doubleshots in [0, 5, 6, 10]: var r := _fire_flush_against_the_wall(doubleshots) assert_eq(int(r["past_the_face"]), 0, "%d Doubleshot(s) put %d shot(s) on the far side of the wall" % [doubleshots, r["past_the_face"]]) func test_clamping_the_muzzle_does_not_swallow_the_shot() -> void: # The fix must not silently reduce your firepower for standing near cover: # a blocked muzzle falls back to the shooter's centre, it does not cancel. assert_eq(int(_fire_flush_against_the_wall(6)["fired"]), 7, "six Doubleshots is seven projectiles, wall or no wall") # --- Control-message pacing ------------------------------------------------- # Selecting or creating a character rewrites the whole store to disk, re-places # the player and broadcasts a roster to everyone, all off one small reliable # packet. The pacing below is what stops a client turning that into a synchronous # disk write it controls the rate of. func _runtime() -> ServerRuntime: # Not parented: _ready() would open a lobby and load a store, and neither is # involved in deciding whether a message arrived too soon. return autofree(ServerRuntime.new()) as ServerRuntime func test_control_messages_are_paced_per_peer() -> void: var srv := _runtime() assert_true(srv._accept_control(PEER), "the first one is always allowed") assert_false(srv._accept_control(PEER), "the second on the same tick is not") srv._tick += ServerRuntime.CONTROL_COOLDOWN_TICKS - 1 assert_false(srv._accept_control(PEER), "one tick short is still too soon") srv._tick += 1 assert_true(srv._accept_control(PEER), "and the cooldown does expire") func test_one_peer_flooding_does_not_gag_another() -> void: var srv := _runtime() assert_true(srv._accept_control(1)) assert_false(srv._accept_control(1)) assert_true(srv._accept_control(2), "pacing is per peer, not global") func test_a_refused_flood_is_logged_once_and_not_once_per_packet() -> void: # The limiter must not become the amplification it exists to prevent: a # client sending a thousand a second would otherwise have the server format # and write a thousand lines. var srv := _runtime() assert_true(srv._accept_control(PEER)) for _i in 500: assert_false(srv._accept_control(PEER)) assert_true(srv._peer_control_warned.get(PEER, false), "the first refusal of a burst is worth saying") srv._tick += ServerRuntime.CONTROL_COOLDOWN_TICKS assert_true(srv._accept_control(PEER)) assert_false(srv._peer_control_warned.has(PEER), "behaving again re-arms the warning for the next burst") # --- Who gets paid for a kill ----------------------------------------------- # Experience is undivided among everyone who was there, which is settled. Who # counts as "there" was not: without a radius, a player parked in the entrance # room collected the full experience for a boss they never saw. func _paying_server(where: Instance) -> ServerRuntime: var srv := _runtime() srv.store = CharacterStore.new("user://test_award_%d.json" % randi()) srv.store.load_from_disk() srv.instances[where.id] = where return srv func _seat(srv: ServerRuntime, where: Instance, peer: int, at: Vector2) -> SimPlayer: var c := srv.store.create_character(peer, "p%d" % peer) srv.peer_accounts[peer] = peer srv.peer_characters[peer] = c.id srv.peer_instance[peer] = where.id where.add_peer(peer, c.display_name) var p: SimPlayer = where.world.players[peer] p.pos = at return p func _xp_of(srv: ServerRuntime, peer: int) -> int: return srv.store.get_character(peer, srv.peer_characters[peer]).total_xp func test_only_players_near_the_kill_are_paid() -> void: var run := Instance.make_dungeon(9, 4242) var srv := _paying_server(run) var kill := Vector2(500.0, 500.0) _seat(srv, run, 11, kill + Vector2(50.0, 0.0)) _seat(srv, run, 22, kill + Vector2(SimConfig.XP_AWARD_RADIUS + 200.0, 0.0)) srv._award_kill(run, 50, kill) assert_eq(_xp_of(srv, 11), 50, "standing over it counts") assert_eq(_xp_of(srv, 22), 0, "parked across the map does not") DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path)) func test_the_radius_reaches_past_what_a_player_can_see() -> void: # Anyone who watched it die must be paid, so the boundary sits outside the # fog rather than on it. var run := Instance.make_dungeon(9, 4242) var srv := _paying_server(run) var kill := Vector2.ZERO _seat(srv, run, 11, Vector2(SimConfig.FOG_VIEW_RADIUS, 0.0)) srv._award_kill(run, 50, kill) assert_eq(_xp_of(srv, 11), 50, "at the very edge of sight you still saw it happen") DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path)) func test_a_kill_still_pays_the_whole_party_that_was_there() -> void: # The radius decides who was present; it must not turn into a split. var run := Instance.make_dungeon(9, 4242) var srv := _paying_server(run) var kill := Vector2(100.0, 100.0) for peer in [11, 22, 33]: _seat(srv, run, peer, kill + Vector2(40.0, 0.0)) srv._award_kill(run, 50, kill) for peer in [11, 22, 33]: assert_eq(_xp_of(srv, peer), 50, "undivided, for everyone present") DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path)) func test_the_dead_are_not_paid_however_close_they_are_lying() -> void: var run := Instance.make_dungeon(9, 4242) var srv := _paying_server(run) var kill := Vector2.ZERO var corpse := _seat(srv, run, 11, kill) corpse.alive = false srv._award_kill(run, 50, kill) assert_eq(_xp_of(srv, 11), 0) DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))