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) signal session_expired() signal session_changed(player_id: String) const DEFAULT_BASE_URL := "http://127.0.0.1:8080" const PERSIST_PATH := "user://matchmaking_state.cfg" var base_url := DEFAULT_BASE_URL var access_token := "" var auth_expired := false var player_id := "" var session_expires_at := "" var state: MatchmakingState var ranked_profile: RankedProfileState var _request: HTTPRequest var _operation := "" var _last_queue_create: Dictionary = {} func _ready() -> void: state = MatchmakingState.new() ranked_profile = RankedProfileState.new() _load_persisted_state() state.changed.connect(_persist_state) _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 not is_valid_access_token(normalized_token): return false base_url = normalized access_token = normalized_token auth_expired = false 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 var key := _idempotency_key("queue") _last_queue_create = {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version, "key": key} var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version}, key) if err != OK: state.fail("Could not start matchmaking: %s" % error_string(err)) return err func login_steam(web_api_ticket: String) -> Error: if not is_valid_web_api_ticket(web_api_ticket): return ERR_INVALID_PARAMETER return _start_request("steam_session", HTTPClient.METHOD_POST, "/v1/session/steam", {"web_api_ticket": web_api_ticket}, "") func retry_queue_create() -> Error: if _last_queue_create.is_empty() or not _last_queue_create.has("ticket_id"): return ERR_INVALID_DATA var ticket_id := String(_last_queue_create["ticket_id"]) var playlist := String(_last_queue_create["playlist"]) if not state.begin_queue(ticket_id, playlist): return ERR_INVALID_PARAMETER var err := _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": String(_last_queue_create["client_build"]), "protocol_version": int(_last_queue_create["protocol_version"])}, String(_last_queue_create["key"])) if err != OK: state.fail("Could not retry matchmaking: %s" % error_string(err)) return err func can_retry_queue_create() -> bool: return not _last_queue_create.is_empty() and state.phase == MatchmakingState.FAILED and String(_last_queue_create.get("ticket_id", "")) == state.ticket_id 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 recover_proposal(proposal_id: String) -> Error: if proposal_id.is_empty(): return ERR_INVALID_PARAMETER return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "") func fetch_ranked_profile() -> Error: return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "") 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 is_valid_web_api_ticket(ticket: String) -> bool: return not ticket.is_empty() and ticket.length() <= 4096 and not ticket.contains("\r") and not ticket.contains("\n") static func is_valid_access_token(token: String) -> bool: var separator := token.find(":") return separator > 0 and separator < token.length() - 1 and token.length() <= 4096 and not token.contains("\r") and not token.contains("\n") 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 not is_valid_base_url(base_url): return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED if operation != "steam_session" and access_token.is_empty(): return ERR_UNAUTHORIZED var headers := PackedStringArray(["Accept: application/json"]) if operation != "steam_session": headers.append("Authorization: Bearer " + access_token) 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: if operation == "ranked_profile": ranked_profile.set_error("Ranked profile request failed") elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail("Control-plane request failed") else: state.set_notice("Control-plane request failed; retrying is safe") request_failed.emit(operation, response_code, "network error") return var parsed = JSON.parse_string(body.get_string_from_utf8()) if not parsed is Dictionary: if operation == "ranked_profile": ranked_profile.set_error("Ranked profile returned invalid JSON") elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail("Control-plane returned invalid JSON") else: state.set_notice("Control-plane returned invalid JSON; retrying is safe") 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")) if response_code == HTTPClient.RESPONSE_UNAUTHORIZED: access_token = "" auth_expired = true state.fail("Session expired; sign in again") ranked_profile.set_error("Session expired; sign in again") session_expired.emit() elif response_code == HTTPClient.RESPONSE_GONE and operation == "queue_recover": state.expire("Queue ticket expired") elif response_code == HTTPClient.RESPONSE_SERVICE_UNAVAILABLE: state.set_notice("Matchmaking is temporarily unavailable; retrying is safe") elif operation == "ranked_profile": ranked_profile.set_error(detail) elif response_code == HTTPClient.RESPONSE_NOT_FOUND and (operation == "queue_recover" or operation == "proposal_recover"): state.fail("Matchmaking record is no longer available") elif operation == "queue_create" or operation == "queue_recover" or operation == "proposal_recover": state.fail(detail) else: state.set_notice(detail) request_failed.emit(operation, response_code, detail) return var payload: Dictionary = parsed if operation == "steam_session": var returned_token := String(payload.get("access_token", "")) var returned_player_id := String(payload.get("player_id", "")) if returned_player_id.is_empty() or not is_valid_access_token(returned_token): request_failed.emit(operation, response_code, "invalid session response") return player_id = returned_player_id access_token = returned_token auth_expired = false session_expires_at = String(payload.get("expires_at", "")) session_changed.emit(player_id) elif 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) elif operation == "ranked_profile": if not ranked_profile.apply(payload): request_failed.emit(operation, response_code, ranked_profile.error_message) return request_succeeded.emit(operation, payload) func _idempotency_key(prefix: String) -> String: return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())] func _persist_state(snapshot: Dictionary) -> void: var config := ConfigFile.new() config.set_value("matchmaking", "snapshot", JSON.stringify(snapshot)) config.save(PERSIST_PATH) func _load_persisted_state() -> void: var config := ConfigFile.new() if config.load(PERSIST_PATH) != OK: return var raw = config.get_value("matchmaking", "snapshot", "") if not raw is String or String(raw).is_empty(): return var parsed = JSON.parse_string(String(raw)) if parsed is Dictionary and not state.restore_snapshot(parsed): state.fail("Saved matchmaking state is invalid")