mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
f628ccfd35
newAPIService never supplied SteamLogin, so POST /v1/session/steam always returned 503 auth_unavailable in production. The only implementation was cmd/testkit-api's fake, which derives an identity from the ticket string itself and accepts anything -- so the passing integration path was neither deployable nor secure. On the client side the game started with an empty token and a loopback base URL, and no production code called configure() or login_steam(); the menu entered matchmaking directly, so every request failed ERR_UNAUTHORIZED before reaching the network. Add a real ISteamUserAuth/AuthenticateUserTicket adapter behind an interface, so the production login path is testable with only the Valve call stubbed. It rejects family-shared copies (the account playing does not own the app) and, by default, VAC- or publisher-banned accounts, and refuses malformed tickets locally rather than forwarding them. Crucially it separates our faults from the player's: a Valve outage or a revoked publisher key returns 503, not 401. Answering 401 would tell a legitimate player their login failed and send them to fix an account that is fine while the real fault went unnoticed. A banned identity now returns 403 rather than a misleading 503. Sign-in is configuration-gated on the publisher key and App ID: without them the endpoint keeps returning 503, since silently accepting an unverified ticket would be worse than refusing to authenticate. A returning player keeps the player ID they already had, so ratings, penalties and bans follow the account rather than the session. Client side: acquire a web-API ticket through GodotSteam's async signal -- requesting one returns a handle, not a ticket -- using the existing dynamic-call pattern so stock Godot still parses the project. The endpoint is configurable for release builds, and matchmaking completes sign-in before it will queue. Verified against real PostgreSQL; 232 Godot tests pass.
389 lines
15 KiB
GDScript
389 lines
15 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 := {}
|
|
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]
|