diff --git a/client/tests/test_input_roundtrip.gd b/client/tests/test_input_roundtrip.gd index 03330097f..efdb561e6 100644 --- a/client/tests/test_input_roundtrip.gd +++ b/client/tests/test_input_roundtrip.gd @@ -9,9 +9,21 @@ extends GdUnitTestSuite const CONNECT_TIMEOUT: float = 3.0 # Ceiling, not a sleep — the wait loop exits the moment the expected state -# arrives. 5.0s flaked under load (gate run 2026-07-16: MoveEast observed -# mid-move at timeout); 10.0s costs nothing when healthy. +# arrives. NOTE: raising this can never fix a missing move. The 2026-07-16 +# gate flake ("MoveEast never registers") was the stance cooldown silently +# discarding the input (see COOLDOWN_TICKS below), not slowness — a dropped +# move stays dropped no matter how long the wait. const RESPONSE_TIMEOUT: float = 10.0 +# Server move cooldown: the proof-room player is Walk stance (ticks_per_move +# = 2, server stance.rs). A move input arriving within that window is +# silently throttled away (movement.rs::apply_move -> +# PlayerMoveCooldown::try_move — TRACE-level log only, input consumed). +# Space consecutive sends past it (2 ticks + 1 margin for apply/snapshot +# lag) so back-to-back moves can never race the cooldown. Same fix as +# test_sprint2_proof.gd (T-1068) — root-caused again in the T-180 hunt with +# server trace logs: "Received input: tick=2 action=MoveEast" followed by +# "Movement throttled by stance Walk cooldown". +const COOLDOWN_TICKS: int = 3 const MAX_PORT_ATTEMPTS: int = 5 var _server_pid: int = -1 @@ -138,7 +150,9 @@ func _do_handshake(world_seed: int = 42) -> bool: ## - expect_position != null (Vector2, render coords): wait until the player ## reaches that position and assert it. Deterministic under load — tick ## margins race the free-running server when the test process is descheduled -## between drain and send. +## between drain and send. Afterwards, hold until the server clock is +## COOLDOWN_TICKS past the landing tick so the caller's next move can never +## race the stance cooldown (which silently discards early move attempts). ## - expect_position == null: wait for a snapshot at least 2 ticks past the ## drain point (enough for no-op actions like Interact). func _send_and_receive( @@ -203,6 +217,25 @@ func _send_and_receive( assert_that(actual.is_equal_approx(expect_position)).override_failure_message( "player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual] ).is_true() + # Space the next move past the per-stance cooldown (see COOLDOWN_TICKS). + # The server silently drops a move arriving within ticks_per_move ticks + # of the previous one; without this hold, back-to-back sends race the + # cooldown and an occasional move is throttled, leaving the player one + # tile short (the T-180 gate flake). The player is stationary meanwhile, + # so the fresher snapshot is still valid for the caller. + var landed_tick: int = int(snapshot.tick) + var spacing_deadline_ms: int = Time.get_ticks_msec() + int(RESPONSE_TIMEOUT * 1000.0) + while Time.get_ticks_msec() < spacing_deadline_ms: + _bridge.poll() + var m := _bridge.poll_message() + if m.size() > 0: + var d: Variant = Protocol.decode_snapshot(m) + if d != null: + snapshot = d + if int(d.tick) >= landed_tick + COOLDOWN_TICKS: + break + continue + await get_tree().create_timer(0.05).timeout return snapshot