Arriving anywhere set health to maximum, so a run cost nothing: walk in, take what you could, walk out, walk back in whole. Current health now lives on the character record for the same reason the inventory does -- SimWorld builds a blank player on every instance change, so anything that must survive a door has to be written down -- and it is banked at every transfer, every disconnect, and on every item transaction, since a potion moves an item and hit points in one action. The hub is where that damage is paid off, at 10% of maximum per second: about ten seconds from nothing, fast enough not to be a chore and slow enough to be a decision before walking back in. The dungeon rate is unchanged, so waiting one out is still not a plan. Regeneration is a property of the world now rather than a constant. adopt() no longer touches current health. It is called mid-session by on_choose_upgrade, and reading health from the record there would snap a player back to whatever was last written to disk; recompute_max_hp still clamps downwards so Glass Cannon takes the hit points above the new ceiling with it. ServerRuntime._adopt_character stays the one place a record becomes a live player. Experience is still undivided, but now only among players within XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so anyone who watched it die is inside and someone parked in the entrance room is not. The death events carry a position for this, and deliberately do not put it on the wire: encode_events still writes the id alone. diag_progression covers the whole chain end to end -- wounded in a dungeon, escaped to the hub with the wound intact, healing there, and walking back in still hurt -- because a unit test cannot transfer a peer. Both halves were mutation-tested against it; the unit suite alone missed the arrival heal entirely. The Proving Grounds is recorded in the roadmap as a debugging tool that does not ship: it pays full experience for a boss with 8% health. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
432 lines
17 KiB
GDScript
432 lines
17 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")
|
|
|
|
|
|
# --- 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))
|