diff --git a/Game/project.godot b/Game/project.godot index ae3469a7..2dc4cb94 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -26,6 +26,7 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn" [autoload] GameSettings="*res://scripts/game_settings.gd" +ControlPlaneClient="*res://scripts/control_plane_client.gd" VideoSettings="*res://scripts/video_settings.gd" BackgroundFPS="*res://scripts/background_fps.gd" PerfOverlay="*res://scripts/perf_overlay.gd" diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd new file mode 100644 index 00000000..6173d703 --- /dev/null +++ b/Game/scripts/control_plane_client.gd @@ -0,0 +1,129 @@ +class_name ControlPlaneClient +extends Node + +# Authenticated HTTP boundary for matchmaking. ENet/Steam carries the match +# itself; this client only handles queue/proposal control-plane state. + +signal request_succeeded(operation: String, payload: Dictionary) +signal request_failed(operation: String, http_code: int, detail: String) + +const DEFAULT_BASE_URL := "http://127.0.0.1:8080" + +var base_url := DEFAULT_BASE_URL +var access_token := "" +var state: MatchmakingState + +var _request: HTTPRequest +var _operation := "" + + +func _ready() -> void: + state = MatchmakingState.new() + _request = HTTPRequest.new() + _request.timeout = 10.0 + add_child(_request) + _request.request_completed.connect(_on_request_completed) + + +func configure(url: String, token: String) -> bool: + var normalized := url.strip_edges().trim_suffix("/") + var normalized_token := token.strip_edges() + if not is_valid_base_url(normalized) or normalized_token.is_empty() or normalized_token.contains("\r") or normalized_token.contains("\n"): + return false + base_url = normalized + access_token = normalized_token + return true + + +func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error: + if ticket_id.is_empty() or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1: + return ERR_INVALID_PARAMETER + if not state.begin_queue(ticket_id, playlist): + return ERR_INVALID_PARAMETER + return _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version}, _idempotency_key("queue")) + + +func recover_queue(ticket_id: String) -> Error: + if ticket_id.is_empty(): + return ERR_INVALID_PARAMETER + return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "") + + +func heartbeat(ticket_id: String, expected_revision: int) -> Error: + if ticket_id.is_empty() or expected_revision < 0: + return ERR_INVALID_PARAMETER + return _start_request("queue_heartbeat", HTTPClient.METHOD_POST, "/v1/queue/%s/heartbeat" % ticket_id, {}, _idempotency_key("heartbeat"), expected_revision) + + +func cancel_queue(ticket_id: String, expected_revision: int) -> Error: + if ticket_id.is_empty() or expected_revision < 0 or not state.can_cancel(): + return ERR_INVALID_PARAMETER + return _start_request("queue_cancel", HTTPClient.METHOD_POST, "/v1/queue/%s/cancel" % ticket_id, {}, _idempotency_key("cancel"), expected_revision) + + +func respond_to_proposal(proposal_id: String, accept: bool, expected_revision: int) -> Error: + if proposal_id.is_empty() or expected_revision < 0: + return ERR_INVALID_PARAMETER + var action := "accept" if accept else "decline" + return _start_request("proposal_" + action, HTTPClient.METHOD_POST, "/v1/proposals/%s/%s" % [proposal_id, action], {}, _idempotency_key("proposal"), expected_revision) + + +static func is_valid_base_url(url: String) -> bool: + if url.is_empty() or url.contains(" ") or url.contains("\r") or url.contains("\n") or url.contains("?") or url.contains("#") or url.contains("@") or url.ends_with("/"): + return false + return url.begins_with("http://") or url.begins_with("https://") + + +static func normalize_ticket(payload: Dictionary) -> Dictionary: + var result := payload.duplicate(true) + if result.has("expires_at") and result["expires_at"] is String: + result["expires_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["expires_at"])) + return result + + +func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error: + if _request == null or not _operation.is_empty() or access_token.is_empty() or not is_valid_base_url(base_url): + return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED + var headers := PackedStringArray(["Authorization: Bearer " + access_token, "Accept: application/json"]) + if not idempotency_key.is_empty(): + headers.append("Idempotency-Key: " + idempotency_key) + if expected_revision >= 0: + headers.append("If-Match-Revision: %d" % expected_revision) + var body := "" if payload.is_empty() else JSON.stringify(payload) + _operation = operation + var err := _request.request(base_url + path, headers, method, body) + if err != OK: + _operation = "" + return err + return OK + + +func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var operation := _operation + _operation = "" + if result != HTTPRequest.RESULT_SUCCESS: + state.fail("Control-plane request failed") + request_failed.emit(operation, response_code, "network error") + return + var parsed = JSON.parse_string(body.get_string_from_utf8()) + if not parsed is Dictionary: + state.fail("Control-plane returned invalid JSON") + request_failed.emit(operation, response_code, "invalid JSON") + return + if response_code < 200 or response_code >= 300: + var detail := String(parsed.get("error", "request rejected")) + state.fail(detail) + request_failed.emit(operation, response_code, detail) + return + var payload: Dictionary = parsed + if operation == "queue_create": + state.begin_queue(String(payload.get("ticket_id", "")), String(payload.get("playlist", ""))) + if operation.begins_with("queue_"): + state.apply_ticket_update(normalize_ticket(payload)) + elif operation.begins_with("proposal_"): + state.apply_proposal_update(payload) + request_succeeded.emit(operation, payload) + + +func _idempotency_key(prefix: String) -> String: + return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())] diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd new file mode 100644 index 00000000..21d64bb6 --- /dev/null +++ b/Game/tests/cases/test_control_plane_client.gd @@ -0,0 +1,26 @@ +extends "res://tests/test_case.gd" + +const ControlPlaneClient = preload("res://scripts/control_plane_client.gd") + + +func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void: + assert_true(ControlPlaneClient.is_valid_base_url("http://127.0.0.1:8080"), "local HTTP endpoint is valid") + assert_true(ControlPlaneClient.is_valid_base_url("https://match.example"), "HTTPS endpoint is valid") + assert_true(not ControlPlaneClient.is_valid_base_url("match.example"), "scheme is required") + assert_true(not ControlPlaneClient.is_valid_base_url("http://match.example/"), "trailing slash is normalized before validation") + assert_true(not ControlPlaneClient.is_valid_base_url("http://match example"), "whitespace is rejected") + assert_true(not ControlPlaneClient.is_valid_base_url("https://user:pass@match.example"), "userinfo is rejected") + assert_true(not ControlPlaneClient.is_valid_base_url("https://match.example?token=secret"), "query strings are rejected") + + var client := ControlPlaneClient.new() + assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures") + assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected") + + +func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void: + var payload := {"ticket_id": "ticket-1", "state": "QUEUED", "expires_at": "2026-08-31T12:00:00Z"} + var normalized := ControlPlaneClient.normalize_ticket(payload) + assert_eq(normalized["ticket_id"], "ticket-1", "normalization preserves ticket identity") + assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection") + assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch") + assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload") diff --git a/Game/tests/cases/test_project_settings.gd b/Game/tests/cases/test_project_settings.gd index 6016791e..af076e31 100644 --- a/Game/tests/cases/test_project_settings.gd +++ b/Game/tests/cases/test_project_settings.gd @@ -71,7 +71,7 @@ func test_physics_engine_is_jolt() -> void: func test_required_autoloads_are_registered() -> void: # NetworkManager in particular is reached by name from many scripts; losing # it from [autoload] fails only at the point of use, deep in a smoke test. - for autoload_name in ["GameSettings", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]: + for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]: assert_true( ProjectSettings.has_setting("autoload/" + autoload_name), "autoload/%s registered" % autoload_name diff --git a/multiplayer-todo.md b/multiplayer-todo.md index e12a97cc..e2e4d93f 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1226,7 +1226,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** New pure Godot `MatchmakingState` projection models queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states, and exposes authoritative cancel availability | `test_matchmaking_state.gd` rejects wrong-ticket, revision-gap, same-revision conflict and stale updates, preserves idempotent duplicates, and keeps decline/expiry visible; HTTP client, queue UI wiring, wait/latency explanations and end-to-end backend events remain | +| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** New pure Godot `MatchmakingState` projection models queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` now provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers | `test_matchmaking_state.gd` and `test_control_plane_client.gd` reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and keep terminal errors visible; queue UI wiring, wait/latency explanations and end-to-end backend events 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 | `server/domain/sync.go` and `server/api/service.go` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery and expired-ticket terminal handling; authenticated WebSocket transport, client restart persistence and duplicate-ticket integration remain | | 8.41 `[D:7.8,8.9,8.31,8.40]` | After assignment-ready, install SDR relay ticket before connect and send match-scoped join authorisation in `hello`; retain ENet assignments locally | Production connects/reconnects/fences old generation through SDR, never before assignment-ready; allocated/direct ENet and community flows remain compatible | | 8.42 `[D:8.22,8.23,8.24,8.40]` | Backend-authoritative provisional/rank/tier/delta, abandon status and season countdown UI | Client performs no rating math and displays the committed revision after reconnect |