mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
801fca7cb0
domain.validCandidate hard-requires a non-empty PredictedRTT map, but
CreateQueueTicket persisted an empty one and the only endpoint that
could fill it returned 503 in every real binary, because Service.Probe
was assigned nowhere outside api tests. No client-created ticket could
ever be selected by the matcher. The Godot client had no probe method at
all, so even a wired backend was unreachable from the game.
Four distinct defects had to be fixed for this path to work:
Nothing issued the nonce ProbeProvider was meant to compare against, so
the contract could not be satisfied even in principle. Add
POST /v1/probes/{region}/challenge, backed by a durable single-use
challenge -- durable because any replica may serve the answer for a
challenge another replica issued. RTT is the interval between issuing
and receiving, so no client-reported latency reaches placement.
CreateQueueTicket marshalled a nil map to JSON `null`, a JSONB scalar
rather than an object, and jsonb_set rejects that with "cannot set path
in scalar". RecordProbe would have failed at runtime even once wired.
Persist an object, and normalise non-object values in the update for
rows already written.
A nil ProbeRecorder made the handler report success while persisting
nothing, which silently leaves the ticket unmatchable. That is a
misconfiguration, not a successful probe; it now returns 503.
A successful probe updated PostgreSQL only. The candidate inserted at
enqueue time carries an empty RTT map, and the Redis keyspace has its
TTL continually refreshed, so the stale entry need never repair itself.
Refresh that player's projection after the probe commits.
Client side: add the challenge/answer round trip and have the
matchmaking screen collect evidence before creating a ticket, since
queueing first produces a search that can never match. Probing every
region fully is not required -- placement uses whichever regions
answered -- but queueing with none is refused rather than silently
stalling.
New integration test drives the real enqueue and probe paths and then
asks the actual matcher predicate, rather than hand-building a candidate
the way the unit tests do -- which is exactly why they missed this.
Also make the integration schema reset drop the whole public schema: the
enumerated table list silently broke with each new migration.
871 lines
39 KiB
GDScript
871 lines
39 KiB
GDScript
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 probe_challenge_received(region: String, nonce_base64: String)
|
|
signal probe_recorded(region: String, server_rtt_ms: int)
|
|
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"
|
|
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
|
|
|
|
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 assignment: AssignmentState
|
|
|
|
var _request: HTTPRequest
|
|
var _operation := ""
|
|
var _last_queue_create: Dictionary = {}
|
|
var _last_mutation: Dictionary = {}
|
|
var _last_mutation_retryable := false
|
|
var _websocket: WebSocketPeer
|
|
var _websocket_status := "DISCONNECTED"
|
|
var _websocket_retry_seconds := 0.0
|
|
var _websocket_backoff := 1.0
|
|
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:
|
|
state = MatchmakingState.new()
|
|
ranked_profile = RankedProfileState.new()
|
|
assignment = AssignmentState.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)
|
|
state.resync_required.connect(_on_resync_required)
|
|
_websocket = WebSocketPeer.new()
|
|
assignment_connection_failed.connect(_on_assignment_connection_failed)
|
|
NetworkManager.connection_failed.connect(_on_network_connection_failed)
|
|
|
|
|
|
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
|
|
# synchronous failures (assignment missing/expired, invalid endpoint,
|
|
# NetworkManager.join() erroring immediately) previously only emitted
|
|
# assignment_connection_failed -- a signal nothing in the client actually
|
|
# listened to. state.phase would stay stuck at ASSIGNED, the UI would keep
|
|
# showing "Your match server is ready" forever, and there was no way back to
|
|
# a fresh search.
|
|
func _on_assignment_connection_failed(detail: String) -> void:
|
|
state.fail(detail)
|
|
|
|
|
|
# The likelier real-world failure than the synchronous one above:
|
|
# NetworkManager.join() returns OK immediately (the attempt started), but the
|
|
# actual ENet handshake fails asynchronously later -- unreachable server,
|
|
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
|
|
# main_menu.gd's own _on_connection_failed exists to cover for the direct-join
|
|
# flow (see its header comment); nothing covered it for a matchmaking-driven
|
|
# connect. Guarded to CONNECTING so this never reacts to an unrelated
|
|
# connection_failed, such as one belonging to main_menu.gd's own direct join.
|
|
func _on_network_connection_failed() -> void:
|
|
if state.phase == MatchmakingState.CONNECTING:
|
|
state.fail("Unable to connect to the match server")
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if not auth_expired and is_session_expired(session_expires_at):
|
|
_expire_session()
|
|
if _websocket == null:
|
|
return
|
|
_websocket.poll()
|
|
var ready_state := _websocket.get_ready_state()
|
|
if ready_state == WebSocketPeer.STATE_OPEN:
|
|
_websocket_retry_seconds = 0.0
|
|
_websocket_backoff = 1.0
|
|
_set_websocket_status("CONNECTED")
|
|
while _websocket.get_available_packet_count() > 0:
|
|
_handle_websocket_packet(_websocket.get_packet())
|
|
elif ready_state == WebSocketPeer.STATE_CONNECTING:
|
|
_set_websocket_status("CONNECTING")
|
|
elif ready_state == WebSocketPeer.STATE_CLOSED:
|
|
_set_websocket_status("DISCONNECTED")
|
|
if not auth_expired and is_valid_access_token(access_token):
|
|
_websocket_retry_seconds -= _delta
|
|
if _websocket_retry_seconds <= 0.0:
|
|
_websocket_retry_seconds = _websocket_backoff
|
|
_websocket_backoff = minf(_websocket_backoff * 2.0, 30.0)
|
|
connect_event_stream()
|
|
if not _pending_proposal_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
|
var proposal_id := _pending_proposal_id
|
|
_pending_proposal_id = ""
|
|
recover_proposal(proposal_id)
|
|
elif not _pending_assignment_match_id.is_empty() and _operation.is_empty() and not player_id.is_empty():
|
|
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()
|
|
if not is_valid_base_url(normalized) or not is_valid_access_token(normalized_token):
|
|
return false
|
|
base_url = normalized
|
|
access_token = normalized_token
|
|
session_expires_at = ""
|
|
auth_expired = false
|
|
if _websocket != null:
|
|
connect_event_stream()
|
|
return true
|
|
|
|
|
|
func connect_event_stream() -> Error:
|
|
if not is_valid_access_token(access_token) or auth_expired or not is_valid_base_url(base_url):
|
|
return ERR_UNAUTHORIZED
|
|
var socket_url := websocket_url(base_url) + "/v1/events"
|
|
_websocket = WebSocketPeer.new()
|
|
# Godot 4.7 moved handshake headers onto WebSocketPeer; the second
|
|
# connect_to_url argument is TLSOptions, not an HTTP header array. Keep the
|
|
# bearer token in the authenticated handshake without putting it in the URL.
|
|
_websocket.handshake_headers = PackedStringArray(["Authorization: Bearer " + access_token])
|
|
var err := _websocket.connect_to_url(socket_url)
|
|
if err != OK:
|
|
_set_websocket_status("DISCONNECTED")
|
|
return err
|
|
_websocket_retry_seconds = 0.0
|
|
_set_websocket_status("CONNECTING")
|
|
return OK
|
|
|
|
|
|
func disconnect_event_stream() -> void:
|
|
if _websocket != null:
|
|
_websocket.close()
|
|
_websocket_retry_seconds = 0.0
|
|
_websocket_backoff = 1.0
|
|
_set_websocket_status("DISCONNECTED")
|
|
|
|
|
|
static func websocket_url(url: String) -> String:
|
|
if url.begins_with("https://"):
|
|
return "wss://" + url.trim_prefix("https://")
|
|
if url.begins_with("http://"):
|
|
return "ws://" + url.trim_prefix("http://")
|
|
return ""
|
|
|
|
|
|
func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error:
|
|
if not is_valid_resource_id(ticket_id) 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
|
|
|
|
|
|
# Regional latency probing. The backend issues a single-use nonce, the client
|
|
# echoes it back with its opaque platform location, and the backend derives the
|
|
# round trip from its own timestamps -- no client-measured latency is accepted.
|
|
#
|
|
# Until a ticket has RTT evidence for at least one region the matcher will not
|
|
# consider it (server/domain.validCandidate requires a non-empty map), so this
|
|
# has to complete before searching is meaningful.
|
|
const PROBE_REGIONS := ["EU", "NA"]
|
|
|
|
|
|
func request_probe_challenge(region: String) -> Error:
|
|
if not is_valid_probe_region(region):
|
|
return ERR_INVALID_PARAMETER
|
|
return _start_request("probe_challenge_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s/challenge" % region, {}, "")
|
|
|
|
|
|
func submit_probe_answer(region: String, nonce_base64: String, opaque_location_base64: String) -> Error:
|
|
if not is_valid_probe_region(region) or nonce_base64.is_empty() or opaque_location_base64.is_empty():
|
|
return ERR_INVALID_PARAMETER
|
|
return _start_request("probe_answer_" + region, HTTPClient.METHOD_POST, "/v1/probes/%s" % region, {
|
|
"nonce": nonce_base64,
|
|
"opaque_location": opaque_location_base64,
|
|
}, "")
|
|
|
|
|
|
static func is_valid_probe_region(region: String) -> bool:
|
|
return region == "EU" or region == "NA"
|
|
|
|
|
|
# The platform location is opaque to us by design: the backend treats it as a
|
|
# blob and never derives placement from anything the client measured. Without a
|
|
# Steam runtime there is nothing to report, so send a stable non-empty marker
|
|
# rather than failing the probe -- the RTT is what actually matters and that is
|
|
# measured by the backend either way.
|
|
static func opaque_location_payload() -> String:
|
|
if Engine.has_singleton("Steam"):
|
|
var steam := Engine.get_singleton("Steam")
|
|
if steam.has_method("getLocalPingLocation"):
|
|
var location = steam.call("getLocalPingLocation")
|
|
if location is String and not String(location).is_empty():
|
|
return Marshalls.utf8_to_base64(String(location))
|
|
return Marshalls.utf8_to_base64("no-platform-ping-location")
|
|
|
|
|
|
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 retry_last_mutation() -> Error:
|
|
if not can_retry_last_mutation():
|
|
return ERR_INVALID_DATA
|
|
var request := _last_mutation.duplicate(true)
|
|
return _start_request(String(request["operation"]), int(request["method"]), String(request["path"]), request["payload"], String(request["key"]), int(request["expected_revision"]))
|
|
|
|
|
|
func can_retry_last_mutation() -> bool:
|
|
return _last_mutation_retryable and not _last_mutation.is_empty() and _operation.is_empty() and not auth_expired and is_valid_access_token(access_token)
|
|
|
|
|
|
func recover_queue(ticket_id: String) -> Error:
|
|
if not is_valid_resource_id(ticket_id):
|
|
return ERR_INVALID_PARAMETER
|
|
return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "")
|
|
|
|
|
|
func recover_proposal(proposal_id: String) -> Error:
|
|
if not is_valid_resource_id(proposal_id):
|
|
return ERR_INVALID_PARAMETER
|
|
return _start_request("proposal_recover", HTTPClient.METHOD_GET, "/v1/proposals/" + proposal_id, {}, "")
|
|
|
|
|
|
static func resync_target(resource_id: String, ticket_id: String, proposal_id: String, proposal_open: bool) -> String:
|
|
if resource_id == ticket_id and not ticket_id.is_empty():
|
|
return ticket_id
|
|
if resource_id == proposal_id and not proposal_id.is_empty():
|
|
return proposal_id if proposal_open else ticket_id
|
|
return ""
|
|
|
|
|
|
func fetch_ranked_profile() -> Error:
|
|
return _start_request("ranked_profile", HTTPClient.METHOD_GET, "/v1/profile/ranked", {}, "")
|
|
|
|
|
|
func fetch_assignment(match_id: String) -> Error:
|
|
if not is_valid_resource_id(match_id) or player_id.is_empty():
|
|
return ERR_INVALID_PARAMETER
|
|
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 not AssignmentState.is_valid_expiry_timestamp(value.expires_at):
|
|
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 not is_valid_resource_id(ticket_id) 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 not is_valid_resource_id(ticket_id) 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 not is_valid_resource_id(proposal_id) 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 is_session_expired(expires_at: String, now_unix: int = -1) -> bool:
|
|
if expires_at.is_empty():
|
|
return false
|
|
if not is_valid_rfc3339_timestamp(expires_at):
|
|
return true
|
|
var expiry_unix := Time.get_unix_time_from_datetime_string(expires_at)
|
|
if expiry_unix < 0:
|
|
return true
|
|
var current_unix := now_unix
|
|
if current_unix < 0:
|
|
current_unix = int(Time.get_unix_time_from_system())
|
|
return expiry_unix <= current_unix
|
|
|
|
|
|
static func is_valid_session_response(payload: Dictionary) -> bool:
|
|
if not payload.has("expires_at") or not payload["expires_at"] is String:
|
|
return false
|
|
var expires_at := String(payload["expires_at"])
|
|
return is_valid_rfc3339_timestamp(expires_at) and not is_session_expired(expires_at)
|
|
|
|
|
|
static func is_valid_rfc3339_timestamp(value: String) -> bool:
|
|
if value.is_empty():
|
|
return false
|
|
var timestamp_pattern := RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$")
|
|
if timestamp_pattern.search(value) == null:
|
|
return false
|
|
var year := int(value.substr(0, 4))
|
|
var month := int(value.substr(5, 2))
|
|
var day := int(value.substr(8, 2))
|
|
var hour := int(value.substr(11, 2))
|
|
var minute := int(value.substr(14, 2))
|
|
var second := int(value.substr(17, 2))
|
|
if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59:
|
|
return false
|
|
var days_in_month := [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
var leap_year := year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
if leap_year:
|
|
days_in_month[1] = 29
|
|
if day < 1 or day > days_in_month[month - 1]:
|
|
return false
|
|
var timezone_index := value.find("+", 19)
|
|
if timezone_index < 0:
|
|
timezone_index = value.find("-", 19)
|
|
if timezone_index >= 0:
|
|
var offset_hour := int(value.substr(timezone_index + 1, 2))
|
|
var offset_minute := int(value.substr(timezone_index + 4, 2))
|
|
if offset_hour > 23 or offset_minute > 59:
|
|
return false
|
|
return true
|
|
|
|
|
|
static func is_valid_resource_id(value: String) -> bool:
|
|
if value.length() < 16 or value.length() > 128:
|
|
return false
|
|
var resource_pattern := RegEx.create_from_string("^[A-Za-z0-9_-]+$")
|
|
return resource_pattern.search(value) != null
|
|
|
|
|
|
static func is_retryable_mutation_response(response_code: int) -> bool:
|
|
return response_code == 0 or response_code == HTTPClient.RESPONSE_REQUEST_TIMEOUT or response_code == HTTPClient.RESPONSE_TOO_MANY_REQUESTS or response_code >= 500
|
|
|
|
|
|
static func should_recover_queue_after_conflict(operation: String, response_code: int, ticket_id: String) -> bool:
|
|
return response_code == HTTPClient.RESPONSE_CONFLICT and operation in ["queue_heartbeat", "queue_cancel"] and not ticket_id.is_empty()
|
|
|
|
|
|
static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
|
var result := payload.duplicate(true)
|
|
for pair in [["enqueued_at", "enqueued_at_unix"], ["expires_at", "expires_at_unix"]]:
|
|
var source_key: String = pair[0]
|
|
var target_key: String = pair[1]
|
|
if not result.has(source_key):
|
|
continue
|
|
if not result[source_key] is String or not is_valid_rfc3339_timestamp(String(result[source_key])):
|
|
result[target_key] = -1
|
|
else:
|
|
result[target_key] = Time.get_unix_time_from_datetime_string(String(result[source_key]))
|
|
return result
|
|
|
|
|
|
static func normalize_proposal(payload: Dictionary) -> Dictionary:
|
|
var result := payload.duplicate(true)
|
|
if not result.has("expires_at"):
|
|
return result
|
|
if not result["expires_at"] is String or not is_valid_rfc3339_timestamp(String(result["expires_at"])):
|
|
result["expires_at_unix"] = -1
|
|
else:
|
|
result["expires_at_unix"] = int(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
|
|
if operation != "steam_session" and is_session_expired(session_expires_at):
|
|
_expire_session()
|
|
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
|
|
if not idempotency_key.is_empty():
|
|
_last_mutation = {"operation": operation, "method": method, "path": path, "payload": payload.duplicate(true), "key": idempotency_key, "expected_revision": expected_revision}
|
|
_last_mutation_retryable = false
|
|
return OK
|
|
|
|
|
|
func _expire_session() -> void:
|
|
if auth_expired:
|
|
return
|
|
access_token = ""
|
|
auth_expired = true
|
|
disconnect_event_stream()
|
|
state.fail("Session expired; sign in again")
|
|
ranked_profile.set_error("Session expired; sign in again")
|
|
session_expired.emit()
|
|
|
|
|
|
func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
var operation := _operation
|
|
_operation = ""
|
|
if result != HTTPRequest.RESULT_SUCCESS:
|
|
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
|
if operation == "ranked_profile":
|
|
ranked_profile.set_error("Ranked profile request failed")
|
|
elif operation == "queue_create":
|
|
state.fail("Control-plane request failed")
|
|
elif operation == "queue_recover" or operation == "proposal_recover":
|
|
state.set_notice("Could not refresh matchmaking state; retrying")
|
|
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:
|
|
_last_mutation_retryable = _last_mutation.get("operation", "") == operation
|
|
if operation == "ranked_profile":
|
|
ranked_profile.set_error("Ranked profile returned invalid JSON")
|
|
elif operation == "queue_create":
|
|
state.fail("Control-plane returned invalid JSON")
|
|
elif operation == "queue_recover" or operation == "proposal_recover":
|
|
state.set_notice("Could not refresh matchmaking state; retrying")
|
|
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:
|
|
_last_mutation_retryable = _last_mutation.get("operation", "") == operation and is_retryable_mutation_response(response_code)
|
|
var detail := String(parsed.get("error", "request rejected"))
|
|
var recover_proposal_after_conflict := response_code == HTTPClient.RESPONSE_CONFLICT and (operation == "proposal_accept" or operation == "proposal_decline") and not state.proposal_id.is_empty()
|
|
var recover_queue_after_conflict := should_recover_queue_after_conflict(operation, response_code, state.ticket_id)
|
|
if response_code == HTTPClient.RESPONSE_UNAUTHORIZED:
|
|
access_token = ""
|
|
auth_expired = true
|
|
disconnect_event_stream()
|
|
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 response_code == HTTPClient.RESPONSE_UPGRADE_REQUIRED and operation == "queue_create":
|
|
# Distinct from the generic queue_create failure below: retrying
|
|
# with the same client build can never succeed, so the retry
|
|
# offer must not be shown (can_retry_queue_create() checks
|
|
# _last_queue_create; clearing it here suppresses "Retry Search").
|
|
_last_queue_create = {}
|
|
state.fail("Your client is out of date -- please update to continue searching")
|
|
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)
|
|
if recover_proposal_after_conflict:
|
|
_pending_resync_resource_id = state.proposal_id
|
|
call_deferred("_run_pending_resync")
|
|
if recover_queue_after_conflict:
|
|
_pending_resync_resource_id = state.ticket_id
|
|
call_deferred("_run_pending_resync")
|
|
return
|
|
var payload: Dictionary = parsed
|
|
_last_mutation_retryable = false
|
|
if operation == "steam_session":
|
|
var returned_token := String(payload.get("access_token", ""))
|
|
var returned_player_id := String(payload.get("player_id", ""))
|
|
if not is_valid_resource_id(returned_player_id) or not is_valid_access_token(returned_token) or not is_valid_session_response(payload):
|
|
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", ""))
|
|
connect_event_stream()
|
|
session_changed.emit(player_id)
|
|
elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel":
|
|
if not _valid_queue_response(payload):
|
|
state.fail("Queue response contains invalid contract data")
|
|
request_failed.emit(operation, response_code, "invalid queue response")
|
|
return
|
|
if operation == "queue_create":
|
|
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
|
|
elif operation.begins_with("proposal_"):
|
|
if not _valid_proposal_response(payload):
|
|
state.fail("Proposal response contains an invalid proposal identifier")
|
|
request_failed.emit(operation, response_code, "invalid proposal identifier")
|
|
return
|
|
if operation.begins_with("queue_"):
|
|
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":
|
|
if not ranked_profile.apply(payload):
|
|
request_failed.emit(operation, response_code, ranked_profile.error_message)
|
|
return
|
|
elif operation == "assignment":
|
|
if not assignment.apply(payload, player_id):
|
|
request_failed.emit(operation, response_code, assignment.error_message)
|
|
return
|
|
elif operation.begins_with("probe_challenge_"):
|
|
# Answer immediately: the nonce is single-use and short-lived, and the
|
|
# interval to this answer is exactly what the backend measures.
|
|
var challenge_region := operation.trim_prefix("probe_challenge_")
|
|
var nonce := String(payload.get("nonce", ""))
|
|
if nonce.is_empty():
|
|
request_failed.emit(operation, response_code, "probe challenge did not include a nonce")
|
|
return
|
|
probe_challenge_received.emit(challenge_region, nonce)
|
|
elif operation.begins_with("probe_answer_"):
|
|
probe_recorded.emit(operation.trim_prefix("probe_answer_"), int(payload.get("server_rtt_ms", -1)))
|
|
request_succeeded.emit(operation, payload)
|
|
if not _pending_resync_resource_id.is_empty():
|
|
call_deferred("_run_pending_resync")
|
|
|
|
|
|
func _handle_websocket_packet(packet: PackedByteArray) -> void:
|
|
var parsed = JSON.parse_string(packet.get_string_from_utf8())
|
|
if not parsed is Dictionary or not _valid_websocket_event(parsed):
|
|
websocket_status_changed.emit("INVALID_EVENT")
|
|
return
|
|
var event: Dictionary = parsed
|
|
websocket_event.emit(event)
|
|
var event_name := String(event["event"])
|
|
if event_name == "state_changed":
|
|
# Allocation and match lifecycle rows are keyed by match ID, not ticket
|
|
# ID. Recover the owner-scoped ticket projection instead of feeding the
|
|
# match revision/resource into the ticket reducer. ASSIGNMENT_READY also
|
|
# carries the durable lookup key, so the assignment fetch can follow the
|
|
# recovery request without depending on a circular assignment_changed
|
|
# notification from the assignment GET itself.
|
|
if event.has("match_id"):
|
|
var match_id := String(event["match_id"])
|
|
_on_resync_required(state.ticket_id)
|
|
if String(event["state"]) == "ASSIGNMENT_READY":
|
|
_pending_assignment_match_id = match_id
|
|
return
|
|
var update := event.duplicate(true)
|
|
update["ticket_id"] = String(event["resource_id"])
|
|
if not state.apply_ticket_update(update):
|
|
return
|
|
elif event_name == "proposal_changed":
|
|
var proposal_update := event.duplicate(true)
|
|
proposal_update["proposal_id"] = String(event["resource_id"])
|
|
if state.prepare_proposal_recovery(String(proposal_update["proposal_id"])):
|
|
state.apply_proposal_update(proposal_update)
|
|
elif event_name == "assignment_changed":
|
|
state.mark_assignment_ready()
|
|
_pending_assignment_match_id = String(event["match_id"])
|
|
elif event_name == "error":
|
|
state.set_notice("Control-plane error: %s" % String(event["code"]))
|
|
_on_resync_required(String(event["resource_id"]))
|
|
|
|
|
|
static func _valid_websocket_event(event: Dictionary) -> bool:
|
|
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
|
|
return false
|
|
if not event.has("revision") or not _valid_revision(event["revision"]):
|
|
return false
|
|
if not event.has("resource_id") or not event["resource_id"] is String or not is_valid_resource_id(String(event["resource_id"])):
|
|
return false
|
|
if not event.has("occurred_at") or not event["occurred_at"] is String or not is_valid_rfc3339_timestamp(String(event["occurred_at"])):
|
|
return false
|
|
var event_name := String(event["event"])
|
|
if event_name == "assignment_changed":
|
|
return event.has("match_id") and event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and event.has("server_id") and event["server_id"] is String and is_valid_resource_id(String(event["server_id"]))
|
|
if event_name == "error":
|
|
return event.has("code") and String(event["code"]) in ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]
|
|
if event_name == "state_changed":
|
|
if not event.has("state") or String(event["state"]) not in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
|
return false
|
|
if event.has("match_id"):
|
|
return event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and String(event["match_id"]) == String(event["resource_id"])
|
|
return true
|
|
if event_name == "proposal_changed":
|
|
return event.has("state") and String(event["state"]) in ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
|
|
return false
|
|
|
|
|
|
static func _valid_revision(value: Variant) -> bool:
|
|
if value is int:
|
|
return int(value) >= 0
|
|
if value is float:
|
|
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
|
return false
|
|
|
|
|
|
static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool:
|
|
return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key]))
|
|
|
|
|
|
static func _valid_queue_response(payload: Dictionary) -> bool:
|
|
for key in ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"]:
|
|
if not payload.has(key):
|
|
return false
|
|
if not _valid_response_opaque_id(payload, "ticket_id") or not _valid_response_opaque_id(payload, "player_id"):
|
|
return false
|
|
if not payload["playlist"] is String or not String(payload["playlist"]) in ["casual", "ranked"]:
|
|
return false
|
|
if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
|
return false
|
|
if payload.has("match_id"):
|
|
if not payload["match_id"] is String or not is_valid_resource_id(String(payload["match_id"])):
|
|
return false
|
|
if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]:
|
|
return false
|
|
if payload.has("proposal_id"):
|
|
if not payload["proposal_id"] is String or not is_valid_resource_id(String(payload["proposal_id"])):
|
|
return false
|
|
if String(payload["state"]) != "PROPOSED":
|
|
return false
|
|
if payload.has("match_id") and payload.has("proposal_id"):
|
|
return false
|
|
if not _valid_revision(payload["revision"]):
|
|
return false
|
|
return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"]))
|
|
|
|
|
|
func _queue_assignment_if_ready(payload: Dictionary) -> void:
|
|
if String(payload.get("state", "")) != "ASSIGNMENT_READY":
|
|
return
|
|
var match_id := String(payload.get("match_id", ""))
|
|
if is_valid_resource_id(match_id):
|
|
_pending_assignment_match_id = match_id
|
|
|
|
|
|
func _queue_proposal_if_ready(payload: Dictionary) -> void:
|
|
if String(payload.get("state", "")) != "PROPOSED":
|
|
return
|
|
var proposal_id := String(payload.get("proposal_id", ""))
|
|
if is_valid_resource_id(proposal_id) and state.prepare_proposal_recovery(proposal_id):
|
|
_pending_proposal_id = proposal_id
|
|
|
|
|
|
func _poll_authoritative_recovery(delta: float) -> void:
|
|
if auth_expired or not is_valid_access_token(access_token) or state.ticket_id.is_empty() or state.phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]:
|
|
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
|
return
|
|
_authoritative_recovery_seconds -= maxf(0.0, delta)
|
|
if _authoritative_recovery_seconds > 0.0 or not _operation.is_empty():
|
|
return
|
|
_authoritative_recovery_seconds = AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS
|
|
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
|
|
_run_resync(resource_id)
|
|
|
|
|
|
static func _valid_proposal_response(payload: Dictionary) -> bool:
|
|
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array:
|
|
return false
|
|
var participants: Array = payload["participants"]
|
|
if participants.size() < 2 or participants.size() > 6:
|
|
return false
|
|
var seen := {}
|
|
for participant in participants:
|
|
if not participant is Dictionary:
|
|
return false
|
|
if not participant.has("player_id") or not participant["player_id"] is String or not is_valid_resource_id(String(participant["player_id"])) or seen.has(String(participant["player_id"])):
|
|
return false
|
|
if not participant.has("response") or not participant["response"] is String or not String(participant["response"]) in ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]:
|
|
return false
|
|
if not participant.has("team") or not participant.has("slot") or not _valid_revision(participant["team"]) or not _valid_revision(participant["slot"]):
|
|
return false
|
|
var team := int(participant["team"])
|
|
var slot := int(participant["slot"])
|
|
if team > 1 or slot > 5 or slot / 3 != team:
|
|
return false
|
|
seen[String(participant["player_id"])] = true
|
|
return true
|
|
|
|
|
|
func _on_resync_required(resource_id: String) -> void:
|
|
if not _operation.is_empty():
|
|
_pending_resync_resource_id = resource_id
|
|
return
|
|
_run_resync(resource_id)
|
|
|
|
|
|
func _run_pending_resync() -> void:
|
|
if not _operation.is_empty() or _pending_resync_resource_id.is_empty():
|
|
return
|
|
var resource_id := _pending_resync_resource_id
|
|
_pending_resync_resource_id = ""
|
|
_run_resync(resource_id)
|
|
|
|
|
|
func _run_resync(resource_id: String) -> void:
|
|
var target := resync_target(resource_id, state.ticket_id, state.proposal_id, state.has_open_proposal())
|
|
if target == state.ticket_id and not state.ticket_id.is_empty():
|
|
recover_queue(state.ticket_id)
|
|
elif target == state.proposal_id and not state.proposal_id.is_empty():
|
|
recover_proposal(state.proposal_id)
|
|
|
|
|
|
func _set_websocket_status(status: String) -> void:
|
|
if _websocket_status == status:
|
|
return
|
|
_websocket_status = status
|
|
websocket_status_changed.emit(status)
|
|
if status == "CONNECTED":
|
|
if not state.ticket_id.is_empty() and state.phase not in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]:
|
|
var resource_id := state.proposal_id if state.has_open_proposal() else state.ticket_id
|
|
if _operation.is_empty():
|
|
_run_resync(resource_id)
|
|
else:
|
|
# A reconnect must not lose its authoritative recovery merely because
|
|
# the previous mutation has not acknowledged yet. The deferred path
|
|
# runs after that request completes and avoids an ERR_BUSY drop.
|
|
_pending_resync_resource_id = resource_id
|
|
|
|
|
|
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")
|