diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index f33cb73c..c9a71e25 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -38,6 +38,16 @@ var _authoritative_recovery_seconds := AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS var _pending_proposal_id := "" var _pending_assignment_match_id := "" var _pending_resync_resource_id := "" +# The final wiring step of the matchmaking pipeline: once state.phase reaches +# ASSIGNED, the client must actually start the game transport. connect_to_assignment() +# already existed with correct validation/signal behavior, but nothing ever +# called it -- a player would sit on "Your match server is ready" forever. +# These two fields defer the connect attempt until the assignment fetch +# (triggered independently, earlier, by ASSIGNMENT_READY) has actually +# completed, and prevent a duplicate/replayed ASSIGNED update from firing a +# second connection attempt for the same match. +var _pending_connect_match_id := "" +var _connect_attempted_match_id := "" func _ready() -> void: @@ -85,9 +95,39 @@ func _process(_delta: float) -> void: var match_id := _pending_assignment_match_id _pending_assignment_match_id = "" fetch_assignment(match_id) + if not _pending_connect_match_id.is_empty() and _assignment_ready_for(_pending_connect_match_id): + var match_id := _pending_connect_match_id + _pending_connect_match_id = "" + _connect_attempted_match_id = match_id + connect_to_assignment() _poll_authoritative_recovery(_delta) +# The assignment fetch (triggered independently by ASSIGNMENT_READY, which +# always precedes ASSIGNED) and the ASSIGNED transition that should start the +# transport can arrive in either order. This is the shared readiness check +# both _connect_when_assigned and the deferred _process retry above use. +func _assignment_ready_for(match_id: String) -> bool: + return assignment != null and assignment.available and assignment.match_id == match_id and _assignment_is_fresh(assignment) + + +# Starts (or defers, if the assignment fetch triggered by the earlier +# ASSIGNMENT_READY event hasn't completed yet) the game transport once the +# ticket-state machine reaches ASSIGNED. connect_to_assignment() itself +# already existed with full validation and failure signalling; nothing ever +# called it, so a player reaching "Your match server is ready" never actually +# connected. _connect_attempted_match_id guards against a duplicate/replayed +# ASSIGNED update firing a second connection attempt for the same match. +func _connect_when_assigned(match_id: String) -> void: + if state.phase != MatchmakingState.ASSIGNED or not is_valid_resource_id(match_id) or match_id == _connect_attempted_match_id: + return + if _assignment_ready_for(match_id): + _connect_attempted_match_id = match_id + connect_to_assignment() + else: + _pending_connect_match_id = match_id + + func configure(url: String, token: String) -> bool: var normalized := url.strip_edges().trim_suffix("/") var normalized_token := token.strip_edges() @@ -508,6 +548,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"): _queue_proposal_if_ready(payload) _queue_assignment_if_ready(payload) + _connect_when_assigned(String(payload.get("match_id", ""))) elif operation.begins_with("proposal_"): state.apply_proposal_update(normalize_proposal(payload)) elif operation == "ranked_profile": diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 16783be2..25236223 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -231,6 +231,85 @@ func test_generic_mutation_retry_is_not_offered_for_unsafe_failures() -> void: client.free() +# connect_to_assignment() already existed, fully validated, with its own +# assignment_connection_started/assignment_connection_failed signals -- but +# nothing anywhere in the client ever called it. A player reaching the +# ASSIGNED phase (server confirms the complete roster) with a fetched, fresh +# assignment would simply sit on "Your match server is ready" forever, +# because the transport was never actually started. This is the wiring fix, +# not just new test coverage for existing behavior. +func test_client_starts_the_transport_once_the_ticket_reaches_assigned() -> void: + var client := ControlPlaneClient.new() + client._ready() + assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures") + client.player_id = "player_1234567890" + assert_true(client.state.begin_queue("ticket-connect-ready", "casual"), "queue setup succeeds") + + # The assignment fetch (triggered independently, earlier, by + # ASSIGNMENT_READY) has already completed by the time ASSIGNED arrives -- + # the common case. + client._operation = "assignment" + var assignment_payload := {"match_id": "match_connect_1234567890", "server_id": "server_connect_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65500", "join_authorisation": "opaque-join-token"} + client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer()) + assert_true(client.assignment.available, "assignment fetch applies") + + var connect_started := [false] + var connect_failed := [false] + client.assignment_connection_started.connect(func(_a): connect_started[0] = true) + client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true) + + client._operation = "queue_recover" + var ticket_payload := {"ticket_id": "ticket-connect-ready", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_connect_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"} + client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer()) + + # connect_to_assignment() itself calls state.mark_connecting() as part of a + # successful attempt, so by the time control returns here phase has + # already advanced past ASSIGNED to CONNECTING -- that advancement is + # itself the proof the connect was actually attempted. + assert_eq(client.state.phase, MatchmakingState.CONNECTING, "reaching ASSIGNED with a ready assignment actually started the transport, rather than sitting idle") + assert_true(connect_started[0] or connect_failed[0], "connect_to_assignment's own signal fired") + assert_true(client._pending_connect_match_id.is_empty(), "an attempted connect is not left pending") + + # A duplicate/replayed ASSIGNED event for the same match (e.g. an + # at-least-once outbox redelivery) must not fire a second connection + # attempt. Called directly against the guarded function rather than + # through another full _on_request_completed round-trip: phase has + # already moved on to CONNECTING, so both of _connect_when_assigned's own + # guards (phase != ASSIGNED, and the _connect_attempted_match_id match) + # now independently refuse a second attempt for this match. + connect_started[0] = false + connect_failed[0] = false + client._connect_when_assigned("match_connect_1234567890") + assert_true(not connect_started[0] and not connect_failed[0], "a duplicate connect attempt for an already-attempted match is not reattempted") + + NetworkManager.shutdown() + client.free() + + +func test_client_defers_the_connect_until_the_assignment_fetch_completes() -> void: + var client := ControlPlaneClient.new() + client._ready() + assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures") + assert_true(client.state.begin_queue("ticket-connect-deferred", "casual"), "queue setup succeeds") + + var connect_started := [false] + var connect_failed := [false] + client.assignment_connection_started.connect(func(_a): connect_started[0] = true) + client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true) + + # ASSIGNED arrives before the assignment fetch (triggered earlier by + # ASSIGNMENT_READY) has actually completed -- the ordering the deferred + # path exists for. client.assignment is still the default, unavailable one. + client._operation = "queue_recover" + var ticket_payload := {"ticket_id": "ticket-connect-deferred", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_deferred_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"} + client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer()) + + assert_eq(client.state.phase, MatchmakingState.ASSIGNED, "ticket state machine still reaches ASSIGNED") + assert_eq(client._pending_connect_match_id, "match_deferred_1234567890", "the connect attempt is deferred until the assignment is actually available") + assert_true(not connect_started[0] and not connect_failed[0], "no connection attempt is made before the assignment is ready -- nothing to connect to yet") + client.free() + + func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void: assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted") assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected") diff --git a/multiplayer-next.md b/multiplayer-next.md index 74136118..792578e9 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1241,7 +1241,7 @@ The allocated-runtime result reporter now keeps a completed match in `RESULTS` u |---|---|---| | 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** `MatchmakingState` now projects queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers; `matchmaking.tscn`/`matchmaking.gd` expose the state and authoritative actions from the main menu; authenticated proposal recovery now reconciles missed proposal events and expires them at read time; the Go API publishes targeted authenticated revisioned queue/proposal/assignment events and the Godot client consumes state/proposal/assignment events, fetching the authoritative assignment after assignment readiness; the API can now use the durable participant-scoped proposal provider for both recovery and accept/decline mutations; durable allocation/no-show transitions write targeted state outbox rows and production/testkit dispatchers deliver them after commit; the client now explains queue wait progress and connection latency quality | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd`, `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary`, `TestAuthenticatedWebSocketDeliversOnlyTargetedRevisionedEvents`, `TestStateChangingAPIActionsPublishTargetedEvents`, `TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped`, and state outbox tests reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, guarantee visible phase/terminal copy, and target every participant; live PostgreSQL-backed dispatcher/fan-out verification remains | | 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read; Godot client projection persists non-secret ticket/proposal state, forces authoritative recovery after restart, and can replay a lost queue-create response with the original ticket/idempotency key; the Go API now exposes an authenticated `/v1/events` WebSocket with bounded per-player queues, strict upgrade/vocabulary validation and REST-resync-safe slow-client failure; Godot `ControlPlaneClient` can connect to the stream using Godot 4.7's handshake-header API, consume validated events, automatically reconnect with bounded backoff, and trigger REST recovery on projection gaps and stream return, including a deferred recovery when an event arrives during an in-flight HTTP mutation; `OutboxDispatcher` now provides ordered at-least-once delivery after durable commit; assignment recovery events carry the durable assignment revision without leaking it into REST JSON; prediction startup now distinguishes the sequence-0 warm-up acknowledgement from genuine missing/evicted history, preventing a false hard-snap during a live match; proposal creation writes one durable targeted `proposal_changed` outbox event per proposal aggregate revision, while result completion writes `match_completed` and production `cmd/control-plane` plus the test-only API harness dispatch both event types through separate filtered consumers | `server/domain/sync.go`, `server/api/events.go`, `server/api/outbox.go`, `server/api/service.go`, `server/store/outbox.go`, `server/store/proposal_sql.go`, `server/cmd/control-plane`, `server/cmd/testkit-api`, `service_test.go`, `outbox_test.go`, `matchmaking_state.gd`, `control_plane_client.gd`, `local_prediction_history.gd`, `net_ship_predictor.gd` and tests cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling, malformed restart snapshots, API-level duplicate-create replay/conflict, authenticated handshake/key rejection, targeted event delivery, exactly-once slow-subscriber closure, invalid-event rejection, delivery-before-ack failure ordering, assignment event/response revision separation, prediction warm-up/hard-resync separation, deferred proposal recovery and proposal/result outbox filtering and delivery failures; Godot 4.7.1 headless project parse and 150-test unit harness pass with compatibility rendering, and a two-process ENet authoritative match smoke passes spawn, movement, prediction, 0% snapshot loss and 0 hard snaps; `cmd/control-plane` provides a signal-bound API role, `cmd/matcher` now supports casual and explicitly enabled ranked roles with durable identity lookup, and `cmd/maintenance` provides bounded season maintenance. **Live multi-process control-plane/game verification now exists**: `scripts/verify_control_plane_integration.sh` runs a real `postgres:17-alpine`, the real `api.Service` (via the new test-only `server/cmd/testkit-api`, wired identically to `cmd/control-plane` except for a fake Steam login — see §8.7), and a real headless Godot client (`control_plane_smoke.gd`) round-tripping login → `fetch_ranked_profile` (expect 404, §8.22) → `queue_create` → heartbeat → `cancel_queue` over an actual network connection — the first time this boundary was exercised end to end rather than against a mock on either side. **The two-player proposal extension now also passes**: `scripts/verify_control_plane_proposal_integration.sh` starts isolated PostgreSQL, the real matcher and testkit API, launches two headless Godot clients, seeds deterministic RTT projections, observes the real WebSocket `OPEN` proposal, accepts from both clients, and verifies both durable tickets plus the proposal reach `ACCEPTED` (including authoritative recovery when the concurrent revision advances). `scripts/run_result_fanout_integration.sh` additionally verifies a real PostgreSQL-backed authenticated WebSocket receives a completed-match event; allocator and Redis fan-out live verification remain | -| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, and `connect_to_assignment()` now starts only the validated ENet/Steam transport after assignment readiness; the opaque join authorisation is carried in the MatchNet hello payload rather than the endpoint URL; allocated Godot servers now fail closed unless an operator-mounted JSON roster of control-plane signed envelopes and an HMAC-SHA256 key are present, and MatchNet verifies the canonical Go claim bytes, checks exact token membership plus match/server/protocol/slot/expiry claims before admitting a peer, rejects concurrent reuse of an active token, tracks server-owned reconnect generations across the 60-second reclaim window, fences expired reclaims and invalidates old peers; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows; a workload-authenticated `GET /v1/servers/{serverId}/roster` now returns the complete signed envelope set only for the bound allocation, and the allocated supervisor atomically materializes it before launching Godot; live authenticated Godot/PostgreSQL assignment verification now exists via `scripts/verify_assignment_integration.sh`, which seeds a durable player-scoped `ASSIGNMENT_READY` row behind the real testkit API and verifies the returned endpoint/join authorisation over HTTP/JSON (including the live numeric-decoding boundary); `scripts/run_supervisor_integration.sh` additionally verifies the allocated-server process-ready → roster fetch/materialization → assignment-ready registration path against real PostgreSQL and the real API, with the workload token recovered from an Agones-shaped GameServer annotation | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `server_boot.gd`, `server_config.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `test_match_net.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql`, `server/allocator/allocator_integration_test.go`, `server/supervisor/supervisor_integration_test.go`, `server/supervisor/supervisor.go` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation, signed-claim binding, strict endpoint splitting, allowlisted claim rejection, canonical HMAC interoperability, forged-signature rejection, duplicate active-token rejection, reclaim generation/expiry behavior, workload-authenticated roster delivery, atomic file installation, real allocator reconciliation, real supervisor registration and 150-test Godot compatibility coverage; the three-process disconnect/reclaim smoke passes ship retention, replacement drive and old-peer invalidation; dynamic per-match launch flags, SDR relay-ticket installation and live Agones cluster integration remain | +| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, and `connect_to_assignment()` starts only the validated ENet/Steam transport after assignment readiness. **Found and fixed a severe gap this row had previously described as already closed**: `connect_to_assignment()` existed, fully validated, with its own `assignment_connection_started`/`assignment_connection_failed` signals -- but nothing anywhere in the client ever called it. A player who completed the entire queue -> proposal -> allocate -> assign pipeline would reach `ASSIGNED` and see "Your match server is ready" and then simply sit there forever; the transport was never actually started. `ControlPlaneClient._connect_when_assigned()` now calls it automatically the moment `state.phase` reaches `ASSIGNED` (wired into the one call site every queue-shaped HTTP response -- heartbeat, recover, and resync-triggered recover -- already shares, so both the REST poll and the WebSocket-triggered-resync path are covered without a second call site), deferring via `_pending_connect_match_id` if the assignment fetch triggered earlier by `ASSIGNMENT_READY` hasn't completed yet, and guarding against a duplicate/replayed `ASSIGNED` event reattempting the connection. The opaque join authorisation is carried in the MatchNet hello payload rather than the endpoint URL; allocated Godot servers now fail closed unless an operator-mounted JSON roster of control-plane signed envelopes and an HMAC-SHA256 key are present, and MatchNet verifies the canonical Go claim bytes, checks exact token membership plus match/server/protocol/slot/expiry claims before admitting a peer, rejects concurrent reuse of an active token, tracks server-owned reconnect generations across the 60-second reclaim window, fences expired reclaims and invalidates old peers; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows; a workload-authenticated `GET /v1/servers/{serverId}/roster` now returns the complete signed envelope set only for the bound allocation, and the allocated supervisor atomically materializes it before launching Godot; live authenticated Godot/PostgreSQL assignment verification now exists via `scripts/verify_assignment_integration.sh`, which seeds a durable player-scoped `ASSIGNMENT_READY` row behind the real testkit API and verifies the returned endpoint/join authorisation over HTTP/JSON (including the live numeric-decoding boundary); `scripts/run_supervisor_integration.sh` additionally verifies the allocated-server process-ready → roster fetch/materialization → assignment-ready registration path against real PostgreSQL and the real API, with the workload token recovered from an Agones-shaped GameServer annotation | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `server_boot.gd`, `server_config.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `test_match_net.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql`, `server/allocator/allocator_integration_test.go`, `server/supervisor/supervisor_integration_test.go`, `server/supervisor/supervisor.go` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation, signed-claim binding, strict endpoint splitting, allowlisted claim rejection, canonical HMAC interoperability, forged-signature rejection, duplicate active-token rejection, reclaim generation/expiry behavior, workload-authenticated roster delivery, atomic file installation, real allocator reconciliation, real supervisor registration and 150-test Godot compatibility coverage; the three-process disconnect/reclaim smoke passes ship retention, replacement drive and old-peer invalidation; two new `test_control_plane_client.gd` tests cover the connect-wiring fix directly: `test_client_starts_the_transport_once_the_ticket_reaches_assigned` proves a ready, fresh assignment plus an `ASSIGNED` ticket update actually starts the transport (`state.phase` advances to `CONNECTING`, `connect_to_assignment`'s own signal fires) and that a duplicate attempt is refused, `test_client_defers_the_connect_until_the_assignment_fetch_completes` proves the opposite ordering (an `ASSIGNED` update before the assignment fetch completes) defers rather than either connecting with stale data or erroring; verified against the real Godot 4.7.1 binary (216/216, no crash), the full local gate and the ENet integration suite; dynamic per-match launch flags, SDR relay-ticket installation and live Agones cluster integration remain | | 8.42 `[D:8.22,8.23,8.24,8.40]` | **IN PROGRESS.** `RankedProfileState` and `ControlPlaneClient.fetch_ranked_profile()` expose the backend-authoritative rating/RD/volatility/games/tier/provisional/season view; matchmaking UI displays provisional/tier status without client-side rating math; runnable API binaries now wire the backend-owned default tier policy; live authenticated profile verification now exists via `scripts/verify_ranked_profile_integration.sh`, which seeds a durable rating and verifies the populated profile over real PostgreSQL, Go HTTP, and Godot JSON | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; the real populated profile path now covers durable rating/games/tier/provisional decoding and caught the missing runtime tier-policy wiring; committed revision after reconnect, abandon status and season countdown remain dependent on live auth/backend events and Godot runtime verification | | 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; a 401 clears the in-memory token, emits `session_expired` and disables retry until a new session is configured; terminal messages remain visible and active searches are not falsely failed on transient errors; stale proposal accept/decline conflicts now automatically schedule an authoritative proposal recovery instead of requiring each UI caller to implement 409 handling | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; the real two-player proposal smoke now exercises the client-owned conflict-recovery path. **This row's own "remain" list was stale**: `matchmaking.gd`'s decline button/handler already existed (`%DeclineButton`, `_on_decline_pressed`, visibility toggled by `MatchmakingState.PROPOSED` alongside accept) with no test gap; `ControlPlaneClient.can_retry_last_mutation()`/`retry_last_mutation()` -- the generic "duplicate-action recovery beyond proposals" and "regional outage retry" mechanism -- also already existed (any mutation, not just a proposal response, becomes retryable on a transport failure or 503/408/429, and the matchmaking queue button already fell back to it), it simply had zero test coverage proving the transition actually happens for a non-proposal mutation; two new tests close that (`test_generic_mutation_retry_recovers_after_a_transient_failure`, `test_generic_mutation_retry_is_not_offered_for_unsafe_failures`). `MatchmakingClient`'s real dispatch (`HTTPRequest.request()`) needs a live SceneTree that `test_runner.tscn`'s synchronous single-`_ready()` execution model cannot provide mid-suite, so the two new tests exercise the `can_retry_last_mutation()` decision boundary and the `ERR_INVALID_DATA` fail-closed path rather than the literal network call; verified against the real Godot 4.7.1 binary (214/214 tests, no crash, no engine-level error), plus a full `make verify-multiplayer-local` re-run. Version-mismatch-specific messaging (a protocol rejection currently surfaces only as the server's generic error string, not a distinguished "update your client" affordance), failed-reconnect UX, and arena selection/long-running worker integration (§8.16) remain |