From 3819e65759216c248fe612475655afeee7fb7fe1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 16 Jul 2026 21:30:52 +0200 Subject: [PATCH] test(client): pace input-roundtrip moves past the D-053 stance cooldown (T-1068 fix ported) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused with server trace logs: Walk stance throttles 1 move per 2 ticks and SILENTLY discards early moves (movement.rs apply_move -> try_move, TRACE-only) — under load the test's MoveEast landed exactly 1 tick after MoveNorth and was consumed. Server behavior is correct, deliberate D-053, pinned by Rust tests; production clients re-send while keys are held. Fix: COOLDOWN_TICKS=3 post-ack spacing, same as test_sprint2_proof.gd (T-1068) whose fix never reached this file due to helper copy-paste. Pre-fix repro at run 17/30 under load; post-fix 30/30 stress green + full suite 2956/2956. Co-Authored-By: Claude Fable 5 --- client/tests/test_input_roundtrip.gd | 39 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) 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