extends Control const CLIENT_BUILD := "dev" const PROTOCOL_VERSION := 1 const HEARTBEAT_SECONDS := 10.0 const RECOVERY_POLL_SECONDS := 2.0 @onready var playlist_dropdown: OptionButton = %PlaylistDropdown @onready var status_label: Label = %StatusLabel @onready var detail_label: Label = %DetailLabel @onready var ranked_profile_label: Label = %RankedProfileLabel @onready var queue_button: Button = %QueueButton @onready var cancel_button: Button = %CancelButton @onready var accept_button: Button = %AcceptButton @onready var decline_button: Button = %DeclineButton @onready var back_button: Button = %BackButton var _elapsed_seconds := 0.0 var _heartbeat_seconds := 0.0 var _recovery_poll_seconds := 0.0 # Regions still awaiting RTT evidence, and the queue request deferred until at # least one lands. The matcher ignores a ticket with no predicted RTT, so # queueing before probing produces a search that can never match. var _pending_probe_regions: Array[String] = [] var _probed_regions: Array[String] = [] var _deferred_queue := {} var _web_api_ticket_handle := 0 func _ready() -> void: AudioManager.bind_tree_buttons(self) playlist_dropdown.add_item("Casual") playlist_dropdown.set_item_metadata(0, "casual") playlist_dropdown.add_item("Ranked") playlist_dropdown.set_item_metadata(1, "ranked") playlist_dropdown.item_selected.connect(_on_playlist_selected) ControlPlaneClient.state.changed.connect(_on_state_changed) ControlPlaneClient.request_failed.connect(_on_request_failed) ControlPlaneClient.request_succeeded.connect(_on_request_succeeded) ControlPlaneClient.session_expired.connect(_on_session_expired) ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received) ControlPlaneClient.probe_recorded.connect(_on_probe_recorded) _ensure_signed_in() _refresh_ranked_profile() _render(ControlPlaneClient.state.snapshot()) # Matchmaking previously opened with an empty token against a loopback default, # so every request failed ERR_UNAUTHORIZED before reaching the network. Point # the client at its configured endpoint and complete Steam sign-in first. func _ensure_signed_in() -> void: if ControlPlaneClient.has_session(): return if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""): _on_local_error("Matchmaking endpoint is not configured") return if not SteamBootstrap.supports_web_api_ticket(): # Deliberately explicit rather than silently presenting a search that # can never start: online matchmaking requires a verified identity. _on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason()) return var steam := Engine.get_singleton("Steam") if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket): steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket) _web_api_ticket_handle = SteamBootstrap.request_web_api_ticket() if _web_api_ticket_handle <= 0: _on_local_error("Could not request a Steam authentication ticket") return ControlPlaneClient.state.set_notice("Signing in...") func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void: # Steam reports k_EResultOK as 1; anything else means no usable ticket. if result != 1 or ticket.is_empty(): _on_local_error("Steam declined to issue an authentication ticket") return var encoded := SteamBootstrap.encode_web_api_ticket(ticket) if encoded.is_empty(): _on_local_error("Steam returned an unusable authentication ticket") return var err := ControlPlaneClient.login_steam(encoded) if err != OK: _on_local_error("Could not sign in: %s" % error_string(err)) func _exit_tree() -> void: # The ticket handle is a Steam resource; releasing it avoids leaking one # per visit to this screen. if _web_api_ticket_handle > 0: SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle) _web_api_ticket_handle = 0 func _process(delta: float) -> void: if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]: _elapsed_seconds += delta _heartbeat_seconds += delta _recovery_poll_seconds += delta if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS: _recovery_poll_seconds = 0.0 var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if ControlPlaneClient.state.has_open_proposal() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id) if recovery_err != OK and recovery_err != ERR_BUSY: _on_local_error("State recovery unavailable: %s" % error_string(recovery_err)) if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS: _heartbeat_seconds = 0.0 var err := ControlPlaneClient.heartbeat(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision) if err != OK: _on_local_error("Heartbeat unavailable: %s" % error_string(err)) _render(ControlPlaneClient.state.snapshot()) func _on_queue_pressed() -> void: if not ControlPlaneClient.has_session(): # Queueing without a session would fail at the first request guard. _ensure_signed_in() return if ControlPlaneClient.can_retry_queue_create(): var retry_err := ControlPlaneClient.retry_queue_create() if retry_err != OK: _on_local_error("Could not retry matchmaking: %s" % error_string(retry_err)) return if ControlPlaneClient.can_retry_last_mutation(): var mutation_err := ControlPlaneClient.retry_last_mutation() if mutation_err != OK: _on_local_error("Could not retry matchmaking action: %s" % error_string(mutation_err)) return if not _can_start_new_search(ControlPlaneClient.state.phase): return _elapsed_seconds = 0.0 _heartbeat_seconds = 0.0 _recovery_poll_seconds = 0.0 var playlist := String(playlist_dropdown.get_selected_metadata()) var ticket_id := "ticket-%s-%s" % [str(Time.get_ticks_usec()), str(randi())] # A ticket with no regional RTT evidence is invisible to the matcher, so # collect it first and queue once the first region reports. if _probed_regions.is_empty(): _deferred_queue = {"ticket_id": ticket_id, "playlist": playlist} _start_probe_collection() return var err := ControlPlaneClient.queue_create(ticket_id, playlist, CLIENT_BUILD, PROTOCOL_VERSION) if err != OK: _on_local_error("Could not start matchmaking: %s" % error_string(err)) func _start_probe_collection() -> void: _pending_probe_regions = [] for region in ControlPlaneClient.PROBE_REGIONS: _pending_probe_regions.append(String(region)) ControlPlaneClient.state.set_notice("Measuring connection quality...") _request_next_probe() # One request at a time: the client serialises HTTP through a single # HTTPRequest, so a second call would return ERR_BUSY. func _request_next_probe() -> void: if _pending_probe_regions.is_empty(): _finish_probe_collection() return var region := _pending_probe_regions[0] var err := ControlPlaneClient.request_probe_challenge(region) if err != OK and err != ERR_BUSY: # A region we cannot probe is not fatal; placement just uses the # regions that did respond. _pending_probe_regions.remove_at(0) _request_next_probe() func _on_probe_challenge_received(region: String, nonce_base64: String) -> void: var err := ControlPlaneClient.submit_probe_answer(region, nonce_base64, ControlPlaneClient.opaque_location_payload()) if err != OK: _drop_pending_probe(region) func _on_probe_recorded(region: String, _server_rtt_ms: int) -> void: if not _probed_regions.has(region): _probed_regions.append(region) _drop_pending_probe(region) func _drop_pending_probe(region: String) -> void: var index := _pending_probe_regions.find(region) if index >= 0: _pending_probe_regions.remove_at(index) _request_next_probe() func _finish_probe_collection() -> void: if _deferred_queue.is_empty(): return var queued := _deferred_queue _deferred_queue = {} if _probed_regions.is_empty(): # Queueing now would create a ticket the matcher can never select. _on_local_error("Could not measure connection quality to any region; matchmaking is unavailable") return var err := ControlPlaneClient.queue_create(String(queued["ticket_id"]), String(queued["playlist"]), CLIENT_BUILD, PROTOCOL_VERSION) if err != OK: _on_local_error("Could not start matchmaking: %s" % error_string(err)) func _on_cancel_pressed() -> void: if not ControlPlaneClient.state.can_cancel(): return var err := ControlPlaneClient.cancel_queue(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision) if err != OK: _on_local_error("Could not cancel matchmaking: %s" % error_string(err)) func _on_accept_pressed() -> void: var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision) if err != OK: _on_local_error("Could not accept proposal: %s" % error_string(err)) func _on_decline_pressed() -> void: var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, false, ControlPlaneClient.state.proposal_revision) if err != OK: _on_local_error("Could not decline proposal: %s" % error_string(err)) func _on_playlist_selected(_index: int) -> void: _refresh_ranked_profile() func _refresh_ranked_profile() -> void: var ranked := String(playlist_dropdown.get_selected_metadata()) == "ranked" ranked_profile_label.visible = ranked if not ranked: return var err := ControlPlaneClient.fetch_ranked_profile() if err != OK and err != ERR_BUSY: ranked_profile_label.text = "Ranked profile unavailable: %s" % error_string(err) func _on_back_pressed() -> void: if ControlPlaneClient.state.can_cancel(): status_label.text = "Cancel the active search before leaving" return get_tree().change_scene_to_file(ScenePaths.MAIN_MENU) func _on_state_changed(snapshot: Dictionary) -> void: _render(snapshot) func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void: ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text() _render(ControlPlaneClient.state.snapshot()) func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void: detail_label.text = detail ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text() _render(ControlPlaneClient.state.snapshot()) func _on_session_expired() -> void: status_label.text = "Session expired" detail_label.text = "Sign in again before searching for a match" queue_button.disabled = true func _on_local_error(detail: String) -> void: detail_label.text = detail static func phase_label(phase: String) -> String: match phase: MatchmakingState.IDLE: return "Ready to search" MatchmakingState.QUEUED: return "Searching for players" MatchmakingState.PROPOSED: return "Match found — confirm" MatchmakingState.ACCEPTED: return "Match accepted — preparing server" MatchmakingState.ALLOCATING: return "Preparing match server" MatchmakingState.PROCESS_READY: return "Match server started" MatchmakingState.ASSIGNMENT_READY: return "Match assigned" MatchmakingState.CONNECTING: return "Connecting to match" MatchmakingState.LIVE: return "Match in progress" MatchmakingState.RESULT_PENDING: return "Recording match result" MatchmakingState.COMPLETED: return "Match complete" MatchmakingState.ASSIGNED: return "Match assigned" MatchmakingState.CANCELLED: return "Search cancelled" MatchmakingState.EXPIRED: return "Search expired" MatchmakingState.FAILED: return "Matchmaking unavailable" _: return "Recovering matchmaking state" func _render(snapshot: Dictionary) -> void: var phase := String(snapshot.get("phase", MatchmakingState.IDLE)) status_label.text = phase_label(phase) if String(snapshot.get("message", "")) != "": detail_label.text = String(snapshot["message"]) elif phase == MatchmakingState.QUEUED: var waited := _elapsed_seconds if int(snapshot.get("enqueued_at_unix", 0)) > 0: waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system()))) detail_label.text = queue_wait_detail_text(int(waited), int(snapshot.get("revision", 0))) elif phase == MatchmakingState.PROPOSED: detail_label.text = proposal_countdown_text(int(snapshot.get("expires_at_unix", 0)), int(Time.get_unix_time_from_system())) elif phase == MatchmakingState.ACCEPTED: detail_label.text = phase_detail_label(phase) elif phase in [MatchmakingState.ALLOCATING, MatchmakingState.PROCESS_READY, MatchmakingState.ASSIGNMENT_READY, MatchmakingState.ASSIGNED]: detail_label.text = phase_detail_label(phase) elif phase in [MatchmakingState.CONNECTING, MatchmakingState.LIVE]: detail_label.text = "%s · %s" % [phase_detail_label(phase), latency_detail_text(NetworkManager.rtt_ms)] elif phase == MatchmakingState.RESULT_PENDING: detail_label.text = "The server is confirming the final result" elif phase == MatchmakingState.COMPLETED: detail_label.text = "The match result has been recorded" elif phase == MatchmakingState.IDLE: detail_label.text = "Choose a playlist to begin" cancel_button.visible = ControlPlaneClient.state.can_cancel() accept_button.visible = phase == MatchmakingState.PROPOSED decline_button.visible = phase == MatchmakingState.PROPOSED var retry_search := ControlPlaneClient.can_retry_queue_create() var retry_mutation := ControlPlaneClient.can_retry_last_mutation() queue_button.disabled = ControlPlaneClient.auth_expired or not (_can_start_new_search(phase) or retry_search or retry_mutation) queue_button.text = "Retry Search" if retry_search else ("Retry Request" if retry_mutation else "Search") static func _is_terminal(phase: String) -> bool: return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED] static func phase_detail_label(phase: String) -> String: match phase: MatchmakingState.ACCEPTED: return "All players accepted; preparing the match server" MatchmakingState.ALLOCATING: return "Finding a dedicated match server" MatchmakingState.PROCESS_READY: return "Match server started; preparing player assignments" MatchmakingState.ASSIGNMENT_READY: return "Player assignments are ready" MatchmakingState.ASSIGNED: return "Your match server is ready" MatchmakingState.CONNECTING: return "Connecting to the match server" MatchmakingState.LIVE: return "Match in progress" _: return "" static func proposal_countdown_text(expires_at_unix: int, now_unix: int) -> String: if expires_at_unix <= 0: return "Review the proposal before the countdown expires" return "Review proposal · %ds remaining" % maxi(0, expires_at_unix - now_unix) static func queue_wait_detail_text(waited_seconds: int, revision: int) -> String: var waited := maxi(0, waited_seconds) var suffix := "looking for compatible players" if waited >= 30: suffix = "widening skill range while keeping latency limits" elif waited >= 10: suffix = "matching nearby skill and latency" return "Waiting %ds · %s · revision %d" % [waited, suffix, maxi(0, revision)] static func latency_detail_text(rtt_ms: float) -> String: if not is_finite(rtt_ms) or rtt_ms < 0.0: return "Latency: measuring" var rounded := int(round(rtt_ms)) if rtt_ms <= 50.0: return "Latency: %dms · excellent" % rounded if rtt_ms <= 100.0: return "Latency: %dms · good" % rounded return "Latency: %dms · high" % rounded static func _can_start_new_search(phase: String) -> bool: return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]