diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 93907ce1..438b3ac4 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -9,6 +9,8 @@ signal session_expired() signal session_changed(player_id: String) signal websocket_event(event: Dictionary) signal websocket_status_changed(status: String) +signal assignment_connection_started(assignment: AssignmentState) +signal assignment_connection_failed(detail: String) const DEFAULT_BASE_URL := "http://127.0.0.1:8080" const PERSIST_PATH := "user://matchmaking_state.cfg" @@ -178,6 +180,47 @@ func fetch_assignment(match_id: String) -> Error: return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "") +# Starts the assigned game transport only after AssignmentState has validated +# the complete player-scoped manifest. The signed authorisation is passed to +# MatchNet's hello RPC, never appended to the endpoint URL or logged. Server +# admission remains authoritative; this method only owns the client-side +# readiness/transport boundary. +func connect_to_assignment() -> Error: + if assignment == null or not assignment.available or not _assignment_is_fresh(assignment): + var unavailable_detail := "Match assignment is unavailable or expired" + assignment_connection_failed.emit(unavailable_detail) + return ERR_UNAUTHORIZED + var endpoint := _split_assignment_endpoint(assignment.endpoint) + if endpoint.is_empty(): + var invalid_detail := "Match assignment endpoint is invalid" + assignment_connection_failed.emit(invalid_detail) + return ERR_INVALID_PARAMETER + var transport := NetworkManager.TRANSPORT_ENET if assignment.transport == "enet" else NetworkManager.TRANSPORT_STEAM + MatchNet.join_authorisation = assignment.join_authorisation + state.mark_connecting() + var err := NetworkManager.join(String(endpoint["host"]), int(endpoint["port"]), transport) + if err != OK: + MatchNet.join_authorisation = "" + assignment_connection_failed.emit("Unable to connect to match server") + return err + assignment_connection_started.emit(assignment) + return OK + + +static func _assignment_is_fresh(value: AssignmentState) -> bool: + if value == null or value.expires_at.is_empty(): + return false + var expiry := Time.get_unix_time_from_datetime_string(value.expires_at) + return expiry > Time.get_unix_time_from_system() + + +static func _split_assignment_endpoint(value: String) -> Dictionary: + if not AssignmentState._valid_endpoint(value): + return {} + var separator := value.rfind(":") + return {"host": value.substr(0, separator), "port": int(value.substr(separator + 1))} + + func heartbeat(ticket_id: String, expected_revision: int) -> Error: if ticket_id.is_empty() or expected_revision < 0: return ERR_INVALID_PARAMETER diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index de045693..806c778c 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -48,6 +48,10 @@ class PlayerInfo: var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; ยง1.1 decision 2 โ€” dedicated servers are never a player). var local_player_name := "Player" +# Set by the assignment connection path. Direct-IP/community-server joins keep +# this empty for backwards compatibility; allocated matches carry the opaque +# signed authorisation in hello rather than putting it in the endpoint URL. +var join_authorisation := "" # Test hook (tests/match_net_smoke.gd): set false before connecting to # suppress the automatic real hello, so a test can send a deliberately @@ -65,7 +69,7 @@ func _ready() -> void: func _on_connected_to_server() -> void: roster.clear() if _auto_hello: - _hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name) + _hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name, join_authorisation) func _on_disconnected_from_server() -> void: @@ -145,7 +149,7 @@ func _pick_balanced_team() -> int: @rpc("any_peer", "call_remote", "reliable") -func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void: +func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_join_authorisation: String = "") -> void: if not multiplayer.is_server(): return var peer_id := multiplayer.get_remote_sender_id() diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 76137ced..8caa1177 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -50,6 +50,14 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected") +func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void: + var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001") + assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port") + assert_eq(endpoint["port"], 31001, "assignment port is parsed as an integer") + for unsafe in ["127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:31001/path", "https://127.0.0.1:31001"]: + assert_true(ControlPlaneClient._split_assignment_endpoint(unsafe).is_empty(), "unsafe endpoint is rejected: %s" % unsafe) + + func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void: var profile := RankedProfileState.new() assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "s1"}), "valid profile applies") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 1ca6df42..c01aace1 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1229,7 +1229,7 @@ the local/CI/community transport, not a silent production fallback. |---|---|---| | 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 | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd`, `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary`, `TestAuthenticatedWebSocketDeliversOnlyTargetedRevisionedEvents`, `TestStateChangingAPIActionsPublishTargetedEvents`, `TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped` and store proposal SQL tests reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; wiring durable allocation events into the outbox, wait/latency explanations and Godot runtime verification remain | | 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; `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 | `server/domain/sync.go`, `server/api/events.go`, `server/api/service.go`, `server/store/outbox.go`, `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 and prediction warm-up/hard-resync separation; Godot 4.7.1 headless project parse and 143-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; wiring the dispatcher to a production WebSocket/Redis worker and live multi-process control-plane/game 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, preserve explicit transport/slot/join authorisation and do not connect before assignment-ready; 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 | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `test_assignment_state.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql` 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 and signed-claim binding; direct client connect caller, SDR relay-ticket installation, `hello` join-authorisation wiring, fencing integration and live Godot/PostgreSQL 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; 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 | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql` 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 and 144-test Godot compatibility coverage; server-side signature/roster verification, SDR relay-ticket installation, fencing integration and live Godot/PostgreSQL verification 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 | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; ranked profile fetch/display, 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 | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; decline, version mismatch, regional outage retry UI, failed reconnect, duplicate-action recovery and live Godot verification remain |