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
This commit is contained in:
2026-09-03 16:03:57 +02:00
commit c4beeae38f
385 changed files with 28725 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
extends GutTest
## Progression through a whole dungeon instance: forming, two trash waves, the
## boss, and the cleared state that sends the party home.
var inst: Instance
func before_each() -> void:
inst = Instance.make_dungeon(2, 12345)
inst.add_peer(1, "tester")
func _step(n: int) -> void:
for _i in n:
inst.step()
func _kill_all_enemies() -> void:
for e in inst.world.enemies.values():
e.alive = false
func test_a_forming_dungeon_locks_after_the_window() -> void:
assert_eq(inst.state, Instance.State.FORMING)
_step(SimConfig.DUNGEON_FORMING_TICKS + 2)
assert_eq(inst.state, Instance.State.ACTIVE)
func test_a_full_party_locks_the_dungeon_immediately() -> void:
for peer in range(2, 2 + SimConfig.DUNGEON_PARTY_MAX - 1):
inst.add_peer(peer, "p%d" % peer)
_step(2)
assert_eq(inst.state, Instance.State.ACTIVE)
assert_false(inst.accepts_new_party_member())
func test_waves_gate_on_clearing_the_previous_one() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
assert_eq(inst.stage, 0)
var wave_one := inst.world.enemies.size()
assert_gt(wave_one, 0)
_step(200)
assert_eq(inst.stage, 0, "a slow party must never be overrun by the next wave")
_kill_all_enemies()
_step(120)
assert_eq(inst.stage, 1)
assert_gt(inst.world.enemies.size(), wave_one)
func test_the_boss_arrives_after_the_last_wave() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
for _wave in 2:
_kill_all_enemies()
_step(120)
assert_eq(inst.stage, 2)
assert_not_null(inst.world.boss)
assert_eq(inst.world.boss.def.id, Content.BOSS_WARDEN)
func test_killing_the_boss_clears_the_instance() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
for _wave in 2:
_kill_all_enemies()
_step(120)
inst.world.boss.alive = false
_step(5)
assert_eq(inst.state, Instance.State.CLEARED)
# The exit timer has to run down, or the party would never be released.
_step(400)
assert_eq(inst.stage_delay, 0)
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
lobby.add_peer(1, "tester")
assert_true(lobby.world.portal_enabled)
_step(1)
for _i in 600:
lobby.step()
assert_eq(lobby.world.pool.live_count, 0, "nothing in the hub may shoot at you")
assert_eq(lobby.world.players[1].hp, SimConfig.PLAYER_MAX_HP)
func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
lobby.add_peer(1, "tester")
lobby.world.players[1].pos = SimConfig.PORTAL_POS
var frames: Array[InputFrame] = [
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
lobby.world.queue_input(1, frames)
lobby.step()
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 1)
func test_interacting_away_from_the_portal_does_nothing() -> void:
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
lobby.add_peer(1, "tester")
lobby.world.players[1].pos = SimConfig.PORTAL_POS + Vector2(0.0, SimConfig.PORTAL_RADIUS + 50.0)
var frames: Array[InputFrame] = [
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
lobby.world.queue_input(1, frames)
lobby.step()
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 0)
@@ -0,0 +1 @@
uid://b0sw7d0vhuxyi
+127
View File
@@ -0,0 +1,127 @@
extends GutTest
## The load-bearing assumption of the whole netcode: a client that only receives
## bullet spawn and despawn events can reproduce the server's bullet field
## exactly, by running the same integration.
##
## That is what lets the server send ~30 bytes once per bullet instead of a
## position for every bullet every snapshot. If this test ever fails, bullets on
## screen no longer match the bullets that can kill you, and the fix is not to
## loosen the tolerance -- it is to stop deriving anything the server did not
## send.
var server: SimWorld
var replica: SimWorld
func before_each() -> void:
server = SimWorld.new(99)
server.authoritative = true
replica = SimWorld.new(99)
replica.authoritative = false
func _sync_one_tick() -> Array[Dictionary]:
server.step()
var events := server.drain_events()
# Order matters: the replica first advances the bullets it already knows
# about to the server's new tick, then adopts the spawns, which arrive at
# their post-step positions.
replica.step()
for ev in events:
replica.apply_event(ev)
return events
func _mismatches() -> Array[String]:
var out: Array[String] = []
var server_uids := {}
for i in server.pool.high_water:
if server.pool.alive[i] == 0:
continue
var id: int = server.pool.uid[i]
server_uids[id] = true
var j := replica.pool.find_by_uid(id)
if j < 0:
out.append("bullet %d missing from replica" % id)
continue
var d: float = (server.pool.pos[i] as Vector2).distance_to(replica.pool.pos[j])
if d > 0.01:
out.append("bullet %d off by %.4f px" % [id, d])
for i in replica.pool.high_water:
if replica.pool.alive[i] == 1 and not server_uids.has(replica.pool.uid[i]):
out.append("bullet %d lingering on replica" % replica.pool.uid[i])
return out
func test_boss_pattern_reproduces_exactly_on_the_replica() -> void:
server.add_player(1, "tester")
server.players[1].pos = Vector2(0.0, 220.0)
server.spawn_boss(Content.warden())
for _i in 900:
_sync_one_tick()
assert_gt(server.pool.live_count, 30, "the fight should be producing bullets")
assert_eq(_mismatches(), [] as Array[String])
func test_enemy_patterns_reproduce_exactly_on_the_replica() -> void:
server.add_player(1, "tester")
server.players[1].pos = Vector2(80.0, 200.0)
server.spawn_enemy(Content.turret(), Vector2(-200.0, -100.0))
server.spawn_enemy(Content.turret(), Vector2(200.0, -100.0), 40)
server.spawn_enemy(Content.drifter(), Vector2(0.0, -200.0))
for _i in 900:
_sync_one_tick()
assert_gt(server.pool.live_count, 5)
assert_eq(_mismatches(), [] as Array[String])
func test_bullets_killed_by_a_hit_are_removed_from_the_replica() -> void:
# A stationary player soaking fire produces plenty of early despawns, which
# are the one thing the replica cannot derive on its own.
server.add_player(1, "tester")
# Just below the boss, inside the ring pattern rather than at its centre.
server.players[1].pos = Vector2(0.0, 20.0)
server.players[1].iframes = 0
server.spawn_boss(Content.warden())
var hits := 0
for _i in 900:
for ev in _sync_one_tick():
if int(ev["t"]) == SimEvent.Type.PLAYER_HIT:
hits += 1
assert_gt(hits, 0, "the test needs the player to actually get hit")
assert_eq(_mismatches(), [] as Array[String])
func test_the_replica_never_invents_a_bullet() -> void:
# Same enemies, but the replica is told nothing at all. It must stay empty:
# emitters and AI are server-side only.
replica.spawn_enemy(Content.turret(), Vector2.ZERO)
replica.spawn_boss(Content.warden())
for _i in 600:
replica.step()
assert_eq(replica.pool.live_count, 0)
func test_full_event_stream_survives_the_wire_format() -> void:
# Same as the parity test, but every event is encoded and decoded on the way,
# so a codec bug cannot hide behind in-process dictionaries.
server.add_player(1, "tester")
server.players[1].pos = Vector2(0.0, 220.0)
server.spawn_boss(Content.warden())
for _i in 600:
server.step()
var packet := NetCodec.decode_events(
NetCodec.encode_events(server.tick, server.drain_events()))
replica.step()
for ev: Dictionary in packet["events"]:
replica.apply_event(ev)
# float32 on the wire, so allow a hair more slack than the in-process test.
var bad := 0
for i in server.pool.high_water:
if server.pool.alive[i] == 0:
continue
var j := replica.pool.find_by_uid(server.pool.uid[i])
if j < 0 or (server.pool.pos[i] as Vector2).distance_to(replica.pool.pos[j]) > 1.0:
bad += 1
assert_eq(bad, 0, "%d of %d bullets diverged through the codec"
% [bad, server.pool.live_count])
@@ -0,0 +1 @@
uid://dvqtvvt3chqcs
+115
View File
@@ -0,0 +1,115 @@
extends GutTest
## The boss format is the thing the MVP has to prove is reusable, so these tests
## are written against [BossDef]/[BossPhase] rather than against the Warden --
## a second boss should pass them unchanged.
var world: SimWorld
func before_each() -> void:
world = SimWorld.new(3)
world.add_player(1, "tester")
world.players[1].pos = Vector2(0.0, 200.0)
func _spawn_warden() -> SimBoss:
return world.spawn_boss(Content.warden())
func test_phase_selection_follows_hp_fraction() -> void:
var def := Content.warden()
assert_eq(def.phase_index_for(1.0), 0)
assert_eq(def.phase_index_for(0.9), 0)
assert_eq(def.phase_index_for(0.5), 1)
assert_eq(def.phase_index_for(0.3), 2)
assert_eq(def.phase_index_for(0.05), 3)
func test_boss_advances_phase_when_damaged() -> void:
var boss := _spawn_warden()
world.step()
assert_eq(boss.phase_index, 0)
boss.hp = int(float(boss.def.max_hp) * 0.5)
world.step()
assert_eq(boss.phase_index, 1)
assert_eq(boss.phase_tick, 1, "entering a phase restarts its timeline")
func test_phase_change_is_announced() -> void:
var boss := _spawn_warden()
world.step()
world.drain_events()
boss.hp = int(float(boss.def.max_hp) * 0.3)
world.step()
var phases := world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.BOSS_PHASE)
assert_eq(phases.size(), 1)
assert_eq(int(phases[0]["phase"]), 2)
func test_telegraph_holds_fire_before_the_phase_starts() -> void:
var boss := _spawn_warden()
var telegraph: int = boss.def.phases[0].telegraph_ticks
for _i in telegraph:
world.step()
assert_eq(world.pool.live_count, 0, "the telegraph window must be silent")
world.step()
assert_gt(world.pool.live_count, 0, "the pattern must start once telegraphed")
func test_the_boss_actually_fills_the_arena() -> void:
_spawn_warden()
for _i in 600:
world.step()
assert_gt(world.pool.live_count, 40,
"a boss phase should keep a real bullet field in the air")
func test_armour_multiplier_scales_incoming_damage() -> void:
var def := Content.warden()
def.phases[0].damage_taken_mult = 0.5
var boss := world.spawn_boss(def)
boss.pos = Vector2.ZERO
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 4.0, 60, 100,
SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT)
world.step()
assert_eq(boss.hp, def.max_hp - 50)
func test_boss_death_is_announced_once() -> void:
var boss := _spawn_warden()
boss.pos = Vector2.ZERO
boss.hp = 10
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 4.0, 60, 100,
SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT)
world.step()
assert_false(boss.alive)
var deaths := world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.BOSS_DIED)
assert_eq(deaths.size(), 1)
## The point of the format: a boss built from scratch in a test, with no code
## anywhere in the simulation that knows about it, has to work.
func test_a_brand_new_boss_needs_no_engine_changes() -> void:
var ring := RingEmitter.new()
ring.interval = 20
ring.count = 6
var phase := BossPhase.new()
phase.name = "Only Phase"
phase.enter_at_hp_fraction = 1.0
phase.telegraph_ticks = 0
phase.loop_ticks = 100
phase.emitters = [ring]
var def := BossDef.new()
def.id = &"test_boss"
def.display_name = "Test Boss"
def.max_hp = 500
def.radius = 30.0
def.phases = [phase]
world.spawn_boss(def)
world.step()
assert_eq(world.pool.live_count, 6)
+1
View File
@@ -0,0 +1 @@
uid://dqau1uvkshq7r
+94
View File
@@ -0,0 +1,94 @@
extends GutTest
## The pool is the hottest data structure in the game and the one place a leak
## would end the run, so its slot bookkeeping is tested directly.
var pool: BulletPool
func before_each() -> void:
pool = BulletPool.new()
func _spawn(p := Vector2.ZERO, v := Vector2(100, 0), life := 60) -> int:
return pool.spawn(p, v, 5.0, life, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
func test_spawn_assigns_unique_ids() -> void:
var a := _spawn()
var b := _spawn()
assert_ne(pool.uid[a], pool.uid[b])
assert_eq(pool.live_count, 2)
func test_despawned_slots_are_reused() -> void:
var a := _spawn()
pool.despawn(a)
assert_eq(pool.live_count, 0)
var b := _spawn()
assert_eq(b, a, "the free list should hand back the slot just released")
assert_eq(pool.high_water, 1, "reuse must not grow the scanned range")
func test_double_despawn_does_not_corrupt_the_count() -> void:
var a := _spawn()
pool.despawn(a)
pool.despawn(a)
assert_eq(pool.live_count, 0)
func test_step_integrates_velocity() -> void:
var a := _spawn(Vector2.ZERO, Vector2(600, 0))
pool.step()
assert_almost_eq(pool.pos[a].x, 600.0 * SimConfig.TICK_DELTA, 0.001)
func test_turn_curves_the_bullet() -> void:
var a := pool.spawn(Vector2.ZERO, Vector2(100, 0), 5.0, 60, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 0.0, deg_to_rad(90.0))
pool.step()
assert_almost_eq(pool.vel[a].angle(), deg_to_rad(90.0), 0.001)
func test_bullets_expire_at_end_of_life() -> void:
var a := _spawn(Vector2.ZERO, Vector2.ZERO, 3)
for _i in 3:
pool.step()
assert_eq(pool.alive[a], 0)
assert_eq(pool.live_count, 0)
func test_bullets_leaving_the_arena_are_culled() -> void:
var start := Vector2(SimConfig.ARENA_HALF.x, 0.0)
var a := _spawn(start, Vector2(100000, 0), 600)
pool.step()
assert_eq(pool.alive[a], 0, "a bullet past the cull margin must not linger")
func test_pool_saturation_returns_minus_one_rather_than_growing() -> void:
for _i in SimConfig.MAX_BULLETS:
_spawn(Vector2.ZERO, Vector2.ZERO, 10000)
assert_eq(pool.live_count, SimConfig.MAX_BULLETS)
assert_eq(_spawn(), -1)
func test_advance_slot_matches_repeated_steps() -> void:
var a := pool.spawn(Vector2.ZERO, Vector2(150, 40), 5.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 30.0, 0.02)
var other := BulletPool.new()
var b := other.spawn(Vector2.ZERO, Vector2(150, 40), 5.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 30.0, 0.02)
pool.advance_slot(a, 12)
for _i in 12:
other.step()
# Client catch-up must land on exactly the server's integration, or bullets
# would drift a little further out of place with every packet.
assert_almost_eq(pool.pos[a].x, other.pos[b].x, 0.0001)
assert_almost_eq(pool.pos[a].y, other.pos[b].y, 0.0001)
func test_find_by_uid_only_matches_live_bullets() -> void:
var a := _spawn()
var id: int = pool.uid[a]
assert_eq(pool.find_by_uid(id), a)
pool.despawn(a)
assert_eq(pool.find_by_uid(id), -1)
+1
View File
@@ -0,0 +1 @@
uid://d1ahm5mut1bdh
+70
View File
@@ -0,0 +1,70 @@
extends GutTest
## The escape button is the player's safety valve, and it is also the one input
## with a real consequence attached, so its timing is server-owned end to end.
var world: SimWorld
const PEER := 3
func before_each() -> void:
world = SimWorld.new(1)
world.add_player(PEER, "tester")
world.players[PEER].iframes = 0
func _hold_escape(ticks: int) -> void:
for _i in ticks:
var frames: Array[InputFrame] = [
InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_ESCAPE)]
world.queue_input(PEER, frames)
world.step()
func _events_of(type: int) -> Array:
return world.events.filter(func(e: Dictionary) -> bool: return int(e["t"]) == type)
func test_escape_takes_the_full_channel_time() -> void:
_hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS - 1)
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 0,
"the escape must not complete early")
_hold_escape(1)
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 1)
func test_releasing_the_button_cancels_the_channel() -> void:
_hold_escape(60)
assert_gt(world.players[PEER].escape_ticks, 0)
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
world.queue_input(PEER, frames)
world.step()
assert_eq(world.players[PEER].escape_ticks, 0)
assert_eq(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 1)
func test_taking_damage_cancels_the_channel() -> void:
_hold_escape(60)
world.pool.spawn(world.players[PEER].pos, Vector2.ZERO, 6.0, 60, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
assert_eq(world.players[PEER].escape_ticks, 0,
"escaping under fire has to be a real risk, not a free exit")
assert_gt(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 0)
func test_a_cancelled_channel_restarts_from_zero() -> void:
_hold_escape(120)
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
world.queue_input(PEER, frames)
world.step()
_hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS - 1)
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 0,
"progress must not carry over from an interrupted channel")
func test_escape_completion_never_reaches_the_client_directly() -> void:
_hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS)
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.ESCAPE_COMPLETED,
"the transfer is the server's decision; clients only see the outcome")
+1
View File
@@ -0,0 +1 @@
uid://chkd5b3cni3wu
+147
View File
@@ -0,0 +1,147 @@
extends GutTest
## Emitters are the authoring surface for every enemy and boss, so their shapes
## are asserted directly rather than eyeballed in game.
var pool: BulletPool
var ctx: EmitContext
func before_each() -> void:
pool = BulletPool.new()
ctx = EmitContext.new()
ctx.pool = pool
ctx.origin = Vector2.ZERO
ctx.rng = RandomNumberGenerator.new()
ctx.rng.seed = 1
func _angles() -> Array[float]:
var out: Array[float] = []
for i in pool.high_water:
if pool.alive[i] == 1:
out.append(pool.vel[i].angle())
return out
func test_ring_emits_the_requested_count() -> void:
var e := RingEmitter.new()
e.count = 8
e.fire(ctx)
assert_eq(pool.live_count, 8)
func test_closed_ring_is_evenly_spaced_with_no_duplicate_at_the_seam() -> void:
var e := RingEmitter.new()
e.count = 8
e.arc_deg = 360.0
e.spin_per_shot_deg = 0.0
e.fire(ctx)
var angles := _angles()
angles.sort()
for i in range(1, angles.size()):
assert_almost_eq(angles[i] - angles[i - 1], TAU / 8.0, 0.001)
func test_spin_advances_with_shot_index() -> void:
var e := RingEmitter.new()
e.count = 1
e.spin_per_shot_deg = 30.0
e.fire(ctx)
var first := _angles()[0]
pool.clear()
ctx.shot_index = 1
e.fire(ctx)
assert_almost_eq(_angles()[0] - first, deg_to_rad(30.0), 0.001)
func test_should_fire_respects_interval_and_window() -> void:
var e := RingEmitter.new()
e.start_tick = 10
e.end_tick = 40
e.interval = 10
assert_false(e.should_fire(9), "not armed before start_tick")
assert_true(e.should_fire(10))
assert_false(e.should_fire(15))
assert_true(e.should_fire(30))
assert_false(e.should_fire(50), "not armed after end_tick")
func test_shot_index_matches_the_number_of_shots_so_far() -> void:
var e := RingEmitter.new()
e.start_tick = 10
e.interval = 10
assert_eq(e.shot_index_at(10), 0)
assert_eq(e.shot_index_at(30), 2)
func test_aimed_spread_centres_on_the_target() -> void:
var e := AimedSpreadEmitter.new()
e.count = 3
e.spread_deg = 30.0
ctx.has_target = true
ctx.target = Vector2(100.0, 0.0)
e.fire(ctx)
var angles := _angles()
angles.sort()
assert_eq(angles.size(), 3)
assert_almost_eq(angles[1], 0.0, 0.001, "the middle shot must point at the target")
assert_almost_eq(angles[2] - angles[0], deg_to_rad(30.0), 0.001)
func test_aimed_spread_falls_back_when_nobody_is_alive() -> void:
var e := AimedSpreadEmitter.new()
e.count = 1
ctx.has_target = false
e.fire(ctx)
assert_eq(pool.live_count, 1, "an emitter with no target must still be safe to fire")
func test_wall_leaves_a_gap_of_the_requested_width() -> void:
var e := WallGapEmitter.new()
e.count = 20
e.gap_width = 3
e.fire(ctx)
assert_eq(pool.live_count, 17)
func test_wall_gap_slides_between_shots() -> void:
var e := WallGapEmitter.new()
e.count = 20
e.gap_width = 3
e.gap_step = 5
e.fire(ctx)
var first_xs: Array = []
for i in pool.high_water:
if pool.alive[i] == 1:
first_xs.append(snappedf(pool.pos[i].x, 0.01))
pool.clear()
ctx.shot_index = 1
e.fire(ctx)
var second_xs: Array = []
for i in pool.high_water:
if pool.alive[i] == 1:
second_xs.append(snappedf(pool.pos[i].x, 0.01))
assert_ne(first_xs, second_xs, "the safe lane must move so it cannot be camped")
func test_arc_sweep_emits_every_arm() -> void:
var e := ArcSweepEmitter.new()
e.arms = 3
e.bullets_per_arm = 4
e.fire(ctx)
assert_eq(pool.live_count, 12)
func test_arc_sweep_angle_changes_over_the_phase() -> void:
var e := ArcSweepEmitter.new()
e.arms = 1
e.bullets_per_arm = 1
e.sweep_period = 4.0
e.sweep_deg = 90.0
ctx.local_tick = 0
e.fire(ctx)
var a0 := _angles()[0]
pool.clear()
ctx.local_tick = 30
e.fire(ctx)
assert_ne(snappedf(a0, 0.001), snappedf(_angles()[0], 0.001))
+1
View File
@@ -0,0 +1 @@
uid://bc7gkxydar6fa
+43
View File
@@ -0,0 +1,43 @@
extends GutTest
## The input packet is the only thing a client can put on the wire, so its
## bounds are a security surface as much as a format.
func _round_trip(f: InputFrame) -> InputFrame:
var b := StreamPeerBuffer.new()
b.big_endian = false
f.write(b)
b.seek(0)
return InputFrame.read(b)
func test_round_trip_preserves_tick_and_buttons() -> void:
var f := InputFrame.make(123456, Vector2(0.5, -0.25), 1.75,
InputFrame.BTN_FIRE | InputFrame.BTN_ESCAPE)
var out := _round_trip(f)
assert_eq(out.tick, 123456)
assert_true(out.pressed(InputFrame.BTN_FIRE))
assert_true(out.pressed(InputFrame.BTN_ESCAPE))
assert_false(out.pressed(InputFrame.BTN_INTERACT))
func test_move_survives_quantisation_within_tolerance() -> void:
var out := _round_trip(InputFrame.make(1, Vector2(0.5, -0.25), 0.0, 0))
assert_almost_eq(out.move.x, 0.5, 0.01)
assert_almost_eq(out.move.y, -0.25, 0.01)
func test_aim_survives_quantisation() -> void:
var out := _round_trip(InputFrame.make(1, Vector2.ZERO, 2.5, 0))
assert_almost_eq(out.aim, 2.5, 0.001)
func test_oversized_move_cannot_be_expressed_on_the_wire() -> void:
var out := _round_trip(InputFrame.make(1, Vector2(50.0, 50.0), 0.0, 0))
assert_lt(out.move.length(), 1.45, "the byte encoding must cap the move vector")
func test_frame_is_exactly_the_declared_size() -> void:
var b := StreamPeerBuffer.new()
InputFrame.make(1, Vector2.ONE, 1.0, 7).write(b)
assert_eq(b.data_array.size(), InputFrame.SIZE)
+1
View File
@@ -0,0 +1 @@
uid://dyg6ud6qm24gw
+39
View File
@@ -0,0 +1,39 @@
extends GutTest
## Movement is the one function the client is allowed to run ahead of the
## server, so client prediction is only correct while these hold.
func test_diagonal_is_not_faster_than_cardinal() -> void:
var straight := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
var diagonal := Movement.step_player(Vector2.ZERO, Vector2(1, 1), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
assert_almost_eq(diagonal.length(), straight.length(), 0.001,
"a diagonal must cover the same distance as a cardinal move")
func test_oversized_input_vector_is_clamped() -> void:
# The wire format cannot express this, but a patched client could try.
var cheated := Movement.step_player(Vector2.ZERO, Vector2(1000, 0), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
var honest := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
assert_eq(cheated, honest, "an over-long move vector must buy no extra speed")
func test_speed_matches_config() -> void:
var moved := Movement.step_player(Vector2.ZERO, Vector2.RIGHT, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
assert_almost_eq(moved.x, SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA, 0.001)
func test_player_is_clamped_to_the_arena() -> void:
var far := Vector2(SimConfig.ARENA_HALF.x - 1.0, 0.0)
var out := Movement.step_player(far, Vector2.RIGHT, 10000.0, SimConfig.ARENA_HALF)
assert_almost_eq(out.x, SimConfig.ARENA_HALF.x, 0.001)
func test_circles_overlap_at_the_boundary() -> void:
assert_true(Movement.circles_overlap(Vector2.ZERO, 5.0, Vector2(9.9, 0.0), 5.0))
assert_false(Movement.circles_overlap(Vector2.ZERO, 5.0, Vector2(10.1, 0.0), 5.0))
func test_outside_arena_respects_the_cull_margin() -> void:
var edge := SimConfig.ARENA_HALF.x + SimConfig.BULLET_CULL_MARGIN
assert_false(Movement.outside_arena(Vector2(edge - 1.0, 0.0)))
assert_true(Movement.outside_arena(Vector2(edge + 1.0, 0.0)))
+1
View File
@@ -0,0 +1 @@
uid://bnhdou74qn25a
+107
View File
@@ -0,0 +1,107 @@
extends GutTest
## Codec round-trips. A field that encodes but decodes wrong shows up as a
## mysterious gameplay bug three layers away, so it is pinned down here.
var world: SimWorld
func before_each() -> void:
world = SimWorld.new(7)
var p := world.add_player(42, "ada")
p.pos = Vector2(120.5, -64.25)
p.aim = 1.25
p.hp = 73
p.escape_ticks = 90
p.last_input_tick = 555
world.spawn_enemy(Content.turret(), Vector2(-200.0, 100.0))
world.spawn_boss(Content.warden())
world.tick = 9001
func test_snapshot_round_trips_player_state() -> void:
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_eq(int(snap["tick"]), 9001)
var rec: Dictionary = snap["players"][0]
assert_eq(int(rec["peer"]), 42)
assert_almost_eq((rec["pos"] as Vector2).x, 120.5, 0.01)
assert_almost_eq((rec["pos"] as Vector2).y, -64.25, 0.01)
assert_almost_eq(float(rec["aim"]), 1.25, 0.001)
assert_eq(int(rec["hp"]), 73)
assert_eq(int(rec["last_input_tick"]), 555)
assert_true((int(rec["flags"]) & Protocol.F_ALIVE) != 0)
assert_true((int(rec["flags"]) & Protocol.F_ESCAPING) != 0)
assert_almost_eq(float(rec["escape"]), 90.0 / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.01)
func test_snapshot_carries_enemy_radius_for_late_joiners() -> void:
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
var e: Dictionary = snap["enemies"][0]
assert_almost_eq(float(e["radius"]), Content.turret().radius, 0.5)
assert_eq(int(e["visual"]), Content.turret().visual)
func test_snapshot_carries_the_boss() -> void:
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_not_null(snap["boss"])
assert_eq(int(snap["boss"]["hp"]), Content.warden().max_hp)
func test_dead_enemies_are_not_sent() -> void:
for e in world.enemies.values():
e.alive = false
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_eq((snap["enemies"] as Array).size(), 0)
func test_event_round_trip_preserves_bullet_spawn() -> void:
var ev := {
"t": SimEvent.Type.BULLET_SPAWN, "uid": 8123,
"pos": Vector2(10.0, -20.0), "vel": Vector2(0.0, 150.0),
"r": 7.5, "life": 300, "kind": SimConfig.KIND_NEEDLE,
"team": SimConfig.TEAM_ENEMY, "accel": 12.0, "turn": 0.02,
}
var packet := NetCodec.decode_events(NetCodec.encode_events(77, [ev]))
assert_eq(int(packet["tick"]), 77)
var out: Dictionary = packet["events"][0]
assert_eq(int(out["uid"]), 8123)
assert_eq(out["pos"], Vector2(10.0, -20.0))
assert_eq(out["vel"], Vector2(0.0, 150.0))
assert_almost_eq(float(out["r"]), 7.5, 0.001)
assert_eq(int(out["life"]), 300)
assert_almost_eq(float(out["accel"]), 12.0, 0.001)
assert_almost_eq(float(out["turn"]), 0.02, 0.0001)
func test_server_only_events_never_reach_the_wire() -> void:
var events: Array[Dictionary] = [
{"t": SimEvent.Type.PORTAL_USED, "peer": 3},
{"t": SimEvent.Type.ESCAPE_COMPLETED, "peer": 3},
{"t": SimEvent.Type.PLAYER_DIED, "peer": 3},
]
var packet := NetCodec.decode_events(NetCodec.encode_events(1, events))
assert_eq((packet["events"] as Array).size(), 1,
"instance transfers are the server's business, not the client's")
assert_eq(int(packet["events"][0]["t"]), SimEvent.Type.PLAYER_DIED)
func test_input_round_trip() -> void:
var frames: Array[InputFrame] = [
InputFrame.make(10, Vector2(1, 0), 0.0, InputFrame.BTN_FIRE),
InputFrame.make(11, Vector2(0, 1), 1.0, 0),
]
var out := NetCodec.decode_inputs(NetCodec.encode_inputs(frames))
assert_eq(out.size(), 2)
assert_eq(out[0].tick, 10)
assert_eq(out[1].tick, 11)
func test_truncated_input_packet_is_rejected_not_read_past() -> void:
var frames: Array[InputFrame] = [InputFrame.make(10, Vector2.ONE, 0.0, 1)]
var data := NetCodec.encode_inputs(frames)
# Claim three frames but send one. A hostile client will try exactly this.
data[0] = 3
assert_eq(NetCodec.decode_inputs(data).size(), 0)
func test_empty_input_packet_is_safe() -> void:
assert_eq(NetCodec.decode_inputs(PackedByteArray()).size(), 0)
+1
View File
@@ -0,0 +1 @@
uid://bsarugkk427hn
+138
View File
@@ -0,0 +1,138 @@
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)
+1
View File
@@ -0,0 +1 @@
uid://mmnj5ebk3h8q