test(client): pace input-roundtrip moves past the D-053 stance cooldown (T-1068 fix ported)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 21:30:52 +02:00
co-authored by Claude Fable 5
parent 8d1121d13a
commit 3819e65759
+36 -3
View File
@@ -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