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>
339 lines
13 KiB
GDScript
339 lines
13 KiB
GDScript
## #411: Integration test — full input roundtrip through live server.
|
|
## Validates the complete path: encode inputs → TCP wire → server deserialize →
|
|
## simulation systems → ObserverSnapshot → client decode.
|
|
## Catches integration seams that unit tests miss by exercising both movement
|
|
## and interaction actions against the real server binary.
|
|
## Requires: server binary built (cargo build in server/)
|
|
class_name TestInputRoundtrip
|
|
extends GdUnitTestSuite
|
|
|
|
const CONNECT_TIMEOUT: float = 3.0
|
|
# Ceiling, not a sleep — the wait loop exits the moment the expected state
|
|
# 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
|
|
var _bridge: LocalBridge = null
|
|
var _test_port: int = 0
|
|
|
|
|
|
func _server_binary_path() -> String:
|
|
var project_dir := ProjectSettings.globalize_path("res://")
|
|
return project_dir.path_join("../server/target/debug/settled-reach-server")
|
|
|
|
|
|
static func _random_test_port() -> int:
|
|
return 49152 + (randi() % (65535 - 49152 + 1))
|
|
|
|
|
|
func _spawn_server(server_path: String) -> bool:
|
|
_test_port = _random_test_port()
|
|
var addr := "127.0.0.1:%d" % _test_port
|
|
_server_pid = OS.create_process(server_path, [addr])
|
|
if _server_pid <= 0:
|
|
_server_pid = -1
|
|
return false
|
|
return true
|
|
|
|
|
|
func after_test() -> void:
|
|
if _bridge != null:
|
|
_bridge.disconnect_from_server()
|
|
_bridge = null
|
|
if _server_pid > 0 and OS.is_process_running(_server_pid):
|
|
OS.kill(_server_pid)
|
|
_server_pid = -1
|
|
|
|
|
|
## Spawn the server, connect, and complete the protocol handshake.
|
|
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
|
|
## binary exists, so any failure here is a real failure (asserted loudly).
|
|
## The server binds its port AFTER plugin/simulation init (~250ms), so a
|
|
## random-port collision surfaces as a mid-connect process death, not an
|
|
## instant spawn failure — a death during the connect window therefore
|
|
## retries on a fresh port instead of failing the test (the push gate hit
|
|
## exactly that race: "Failed to bind ... Address already in use").
|
|
func _connect_to_server() -> bool:
|
|
var server_path := _server_binary_path()
|
|
|
|
var connected := false
|
|
for _attempt in range(MAX_PORT_ATTEMPTS):
|
|
if not _spawn_server(server_path):
|
|
continue
|
|
_bridge = LocalBridge.new()
|
|
var elapsed := 0.0
|
|
while elapsed < CONNECT_TIMEOUT:
|
|
if not OS.is_process_running(_server_pid):
|
|
push_warning(
|
|
"Server died pre-connect (port %d likely in use) — retrying" % _test_port
|
|
)
|
|
break
|
|
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
|
|
_bridge.connect_to_server("127.0.0.1", _test_port)
|
|
_bridge.poll()
|
|
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
connected = true
|
|
break
|
|
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
|
|
_bridge.disconnect_from_server()
|
|
_bridge.reset()
|
|
await get_tree().create_timer(0.1).timeout
|
|
elapsed += 0.1
|
|
if connected:
|
|
break
|
|
_bridge.disconnect_from_server()
|
|
_bridge = null
|
|
if _server_pid > 0 and OS.is_process_running(_server_pid):
|
|
OS.kill(_server_pid)
|
|
_server_pid = -1
|
|
|
|
assert_bool(connected).override_failure_message(
|
|
"TCP connect to spawned server failed within %.1fs across %d port attempts"
|
|
% [CONNECT_TIMEOUT, MAX_PORT_ATTEMPTS]
|
|
).is_true()
|
|
if not connected:
|
|
return false
|
|
|
|
# Handshake + startup exchange (#555, #175) — required before input is accepted
|
|
var handshake_ok := await _do_handshake()
|
|
assert_bool(handshake_ok).override_failure_message(
|
|
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
|
|
).is_true()
|
|
return handshake_ok
|
|
|
|
|
|
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
|
|
## first framed message; the client validates it and replies with a
|
|
## StartupMessage carrying world_seed before any input is accepted.
|
|
## Mirrors sim_bridge.gd's HANDSHAKING state.
|
|
func _do_handshake(world_seed: int = 42) -> bool:
|
|
var msg := PackedByteArray()
|
|
var elapsed := 0.0
|
|
while elapsed < CONNECT_TIMEOUT:
|
|
_bridge.poll()
|
|
msg = _bridge.poll_message()
|
|
if msg.size() > 0:
|
|
break
|
|
await get_tree().create_timer(0.05).timeout
|
|
elapsed += 0.05
|
|
if msg.is_empty():
|
|
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
|
|
return false
|
|
var MP = load("res://addons/messagepack/messagepack.gd")
|
|
var decoded: Variant = MP.decode(msg)
|
|
if decoded.status != null or not (decoded.value is Dictionary):
|
|
push_warning("Malformed HandshakeMessage")
|
|
return false
|
|
var startup_bytes := Protocol.encode_startup_message(world_seed)
|
|
if startup_bytes.is_empty():
|
|
return false
|
|
return _bridge.send_message(startup_bytes) == OK
|
|
|
|
|
|
## Send a batch input and receive a snapshot that reflects it.
|
|
## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick —
|
|
## it is not request/response. Drain stale queued snapshots first, then:
|
|
## - 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. 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(
|
|
action_name: String, tick: int = 0, action_data: Variant = null,
|
|
expect_position: Variant = null
|
|
) -> Variant:
|
|
# Drain queued stale snapshots, remembering the newest tick seen.
|
|
var last_tick: int = -1
|
|
_bridge.poll()
|
|
var pending := _bridge.poll_message()
|
|
while pending.size() > 0:
|
|
var stale: Variant = Protocol.decode_snapshot(pending)
|
|
if stale != null:
|
|
last_tick = stale.tick
|
|
_bridge.poll()
|
|
pending = _bridge.poll_message()
|
|
|
|
var input_entry := {"tick": tick, "action_name": action_name}
|
|
if action_data != null:
|
|
input_entry["action_data"] = action_data
|
|
var inputs: Array = [input_entry]
|
|
var encoded := Protocol.encode_player_inputs(inputs)
|
|
assert_that(encoded.size()).is_greater(0)
|
|
var send_err := _bridge.send_message(encoded)
|
|
assert_that(send_err).is_equal(OK)
|
|
|
|
# Wait for a snapshot that reflects the processed input. Wall-clock
|
|
# deadline: the old sleep-count accounting only accrued on EMPTY polls,
|
|
# so a streaming server made the effective window unbounded in theory and
|
|
# load-dependent in practice — measure real time instead.
|
|
var min_tick: int = last_tick + 2
|
|
var snapshot: Variant = null
|
|
var deadline_ms: int = Time.get_ticks_msec() + int(RESPONSE_TIMEOUT * 1000.0)
|
|
while Time.get_ticks_msec() < deadline_ms:
|
|
_bridge.poll()
|
|
var msg := _bridge.poll_message()
|
|
if msg.size() > 0:
|
|
var decoded: Variant = Protocol.decode_snapshot(msg)
|
|
if decoded == null:
|
|
continue # undecodable frame — keep draining
|
|
if expect_position != null:
|
|
snapshot = decoded # keep latest so a timeout reports actual state
|
|
var player := _find_player(decoded)
|
|
if (
|
|
not player.is_empty()
|
|
and is_equal_approx(player.x, expect_position.x)
|
|
and is_equal_approx(player.y, expect_position.y)
|
|
):
|
|
break
|
|
elif decoded.tick >= min_tick:
|
|
snapshot = decoded
|
|
break
|
|
continue
|
|
await get_tree().create_timer(0.05).timeout
|
|
|
|
assert_that(snapshot).override_failure_message(
|
|
"no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name]
|
|
).is_not_null()
|
|
if expect_position != null and snapshot != null:
|
|
var player := _find_player(snapshot)
|
|
var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF
|
|
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
|
|
|
|
|
|
## Find the player entity in a snapshot by kind.
|
|
static func _find_player(snapshot: Dictionary) -> Dictionary:
|
|
for entity in snapshot.entities:
|
|
if entity.kind.variant == "Player":
|
|
return entity
|
|
return {}
|
|
|
|
|
|
# -- Movement roundtrip: send movement, verify position changes ----------------
|
|
|
|
func test_movement_roundtrip(
|
|
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
|
_skip_reason := "server binary not built — run `cargo build` in server/"
|
|
) -> void:
|
|
var ok := await _connect_to_server()
|
|
if not ok:
|
|
return
|
|
|
|
# Use Interact (server no-op) to get a baseline snapshot without moving.
|
|
var baseline: Dictionary = await _send_and_receive("Interact", 0)
|
|
var player := _find_player(baseline)
|
|
assert_that(player.size()).is_greater(0)
|
|
|
|
# Player starts at (16,16) → render coords (16.5, 16.5)
|
|
var start_x: float = player.x
|
|
var start_y: float = player.y
|
|
assert_float(start_x).is_equal_approx(16.5, 0.001)
|
|
assert_float(start_y).is_equal_approx(16.5, 0.001)
|
|
|
|
# Send MoveNorth — player should move to (16, 15) → (16.5, 15.5)
|
|
var snap1: Dictionary = await _send_and_receive("MoveNorth", 1, null, Vector2(16.5, 15.5))
|
|
var p1 := _find_player(snap1)
|
|
assert_that(p1.size()).is_greater(0)
|
|
assert_float(p1.x).is_equal_approx(16.5, 0.001)
|
|
assert_float(p1.y).is_equal_approx(15.5, 0.001)
|
|
|
|
# Send MoveEast — player should move to (17, 15) → (17.5, 15.5)
|
|
var snap2: Dictionary = await _send_and_receive("MoveEast", 2, null, Vector2(17.5, 15.5))
|
|
var p2 := _find_player(snap2)
|
|
assert_that(p2.size()).is_greater(0)
|
|
assert_float(p2.x).is_equal_approx(17.5, 0.001)
|
|
assert_float(p2.y).is_equal_approx(15.5, 0.001)
|
|
|
|
# Verify position actually changed from baseline
|
|
assert_that(p2.x != start_x or p2.y != start_y).is_true()
|
|
|
|
|
|
# -- Interact roundtrip: server accepts without crashing -----------------------
|
|
|
|
func test_interact_roundtrip(
|
|
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
|
_skip_reason := "server binary not built — run `cargo build` in server/"
|
|
) -> void:
|
|
var ok := await _connect_to_server()
|
|
if not ok:
|
|
return
|
|
|
|
# Send Interact action — unit variant, no target data.
|
|
# Server accepts it (currently a no-op) and responds with a valid snapshot.
|
|
var snapshot: Dictionary = await _send_and_receive("Interact", 0)
|
|
|
|
assert_that(snapshot.entities.size()).is_greater(0)
|
|
|
|
# Player should be at start position (Interact doesn't move)
|
|
var player := _find_player(snapshot)
|
|
assert_that(player.size()).is_greater(0)
|
|
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
|
assert_float(player.y).is_equal_approx(16.5, 0.001)
|
|
|
|
# Send Interact again at next tick — server should still accept it
|
|
var snap2: Dictionary = await _send_and_receive("Interact", 1)
|
|
assert_that(snap2).is_not_null()
|
|
|
|
|
|
# -- Mixed sequence: movement then interact in one session ---------------------
|
|
|
|
func test_move_then_interact(
|
|
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
|
_skip_reason := "server binary not built — run `cargo build` in server/"
|
|
) -> void:
|
|
var ok := await _connect_to_server()
|
|
if not ok:
|
|
return
|
|
|
|
# Move player first — (16,16) → (16,15) → render (16.5, 15.5)
|
|
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0, null, Vector2(16.5, 15.5))
|
|
var p1 := _find_player(snap1)
|
|
assert_that(p1.size()).is_greater(0)
|
|
var moved_x: float = p1.x
|
|
var moved_y: float = p1.y
|
|
|
|
# Now send Interact — player should stay where they moved to
|
|
var snap2: Dictionary = await _send_and_receive("Interact", 1)
|
|
var p2 := _find_player(snap2)
|
|
assert_that(p2.size()).is_greater(0)
|
|
assert_float(p2.x).is_equal_approx(moved_x, 0.001)
|
|
assert_float(p2.y).is_equal_approx(moved_y, 0.001)
|