Files
transcience/tests/integration/test_replica_parity.gd
claude f70de1b825
ci / verify (push) Successful in 45s
Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
The real cause of the ship/bullet separation, which the previous commit only
half-addressed. The server dropped inputs past a lead of 12 while the client
only re-synced past 16, so a client whose lead drifted into 13-16 had every
input silently rejected while believing its timing was fine. The server coasted
on held_input and then stopped; the client kept predicting. The two separated
permanently and the reconciler fought it every snapshot -- "shoved around".
It needed two independent clocks to drift, hence "only after some time", and
nothing in the loop could notice, hence "then persists". The listen-server
diagnostic could never reproduce it: one process, one physics tick, lead
constant by construction.

Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction
band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a
test; and an ack-stall detector re-syncs when last_input_tick stops advancing,
which catches the whole class regardless of cause -- lead alone cannot, because
a wrong lead looks normal from the client. diag_prediction.gd now injects a +14
tick drift and exits non-zero unless the gap recovers.

Also:
- No invulnerability frames. Every bullet that touches a player lands; i-frames
  made dense patterns safer than sparse ones, which inverts the genre. Measured:
  a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s
  drifting. spawn_grace remains the only invulnerable state.
- Death is exited with a HUD button, disabled for the first 3s. The lockout is
  enforced in SimWorld, not just by graying the button -- a client that ignores
  its own UI still waits. The interact key no longer respawns.
- Joining a server that is not there no longer drops the player into an empty
  lobby they cannot act in. Net.join() only creates an ENet object; the game
  scene now waits for the server to actually place us in an instance, with an
  8s timeout, and headless runs exit non-zero instead of idling.

Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass.
2026-09-03 19:19:33 +02:00

127 lines
4.4 KiB
GDScript

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.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])