Files
transcience/tests/unit/test_server_authority.gd
claude afe76c22ce
ci / verify (push) Successful in 46s
Fix bullet atlas, animation speed, sprite flip, fire rate, silent shots
Bullet sprites were wrong because I read the pack's layout backwards. Each of
its 8 PNGs is one ANIMATION FRAME, and a column within a file is a COLOUR
variant -- so walking columns cycled the palette while the shape sat still, and
the rows I picked were in a region of larger multi-cell sprites, which is the
scattered debris that showed on screen. tools/build_local_assets.py now composes
a proper atlas: 8 frames across, one row per SimConfig.KIND_*.

Animations ran at whatever the machine's framerate was, because they counted
_process calls. At 240fps that is four times too fast on its own, before the
requested halving. Both actor and bullet animation are now driven by elapsed
seconds, so they look the same on any machine.

The knight shifted sideways instead of mirroring: a Rect2 with negative width
does not flip, the draw call normalises it, so the sprite kept its orientation
and jumped right by its own width. Mirrored through the canvas transform about
the sprite's centre instead.

Shots were silent when a bullet spawned inside an enemy. The sound rode on
BULLET_SPAWN, which is deliberately suppressed for a bullet resolved and removed
within the same tick -- so no event, no sound, and this got more likely the
closer you stood. Firing is now its own event (PLAYER_FIRED): the shot happened
whether or not a bullet survived to be replicated. Protocol 3 -> 4, because
inserting mid-enum shifts the wire value of every event after it.

Fire cooldown 7 -> 14 ticks (4.3 shots/sec).

Licence question 2, confirmed verbatim: the paid tier DOES grant commercial use
("You may use these assets in personal, commercial or non-commercial
projects"), but its next sentence still forbids redistribution "no matter how
much you modify it". Paying moves these from local-only-and-non-commercial to
local-only-and-commercial; it never makes them committable. Only replacing them
with permissively licensed art does that.

Question 3: CREDITS.md now credits every pack, including the two whose licences
do not require it and the unused ones in considering_dont_use_yet/.

158 tests. check.sh, test.sh and smoke.sh pass.
2026-09-04 00:03:04 +02:00

254 lines
9.7 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")