The control-message pacing added two commits ago logged one WARN line per refused packet, which is a thousand formatted strings and a thousand stdout writes per second for a client sending a thousand -- the exact cost the limiter was added to bound, moved from the disk write to the log. One line per burst now, re-armed when the peer behaves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
14 KiB
GDScript
354 lines
14 KiB
GDScript
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")
|