Files
transcience/tests/unit/test_server_authority.gd
T
claude c4beeae38f Initial commit: Transcience MVP
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).

- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
  physics server), ~0.24ms/tick at peak load -- runs headless for free and
  drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
  events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
  enemies and bosses -- a new boss is data in src/content/content.gd, no
  simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
  format
- Lobby hub with a portal into on-demand dungeon instances; one process
  hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
  ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
2026-09-03 16:03:57 +02:00

139 lines
4.9 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")
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)