Files
CosmicClash/Game/scripts/matchmaking.gd
T
Josh Creek 801fca7cb0 fix(matchmaking): make regional RTT evidence obtainable end to end
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.
2026-09-05 10:49:28 +01:00

337 lines
13 KiB
GDScript

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 := {}
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)
_refresh_ranked_profile()
_render(ControlPlaneClient.state.snapshot())
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 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]