mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
214 lines
9.3 KiB
GDScript
214 lines
9.3 KiB
GDScript
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"
|
|
const PERSIST_PATH := "user://matchmaking_state.cfg"
|
|
|
|
var base_url := DEFAULT_BASE_URL
|
|
var access_token := ""
|
|
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 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
|
|
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 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 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:
|
|
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:
|
|
state.fail("Session expired; sign in again")
|
|
ranked_profile.set_error("Session expired; sign in again")
|
|
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 == "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")
|