Files
CosmicClash/Game/scripts/matchmaking_state.gd
T
2026-08-31 22:33:33 +01:00

177 lines
5.3 KiB
GDScript

class_name MatchmakingState
extends RefCounted
# Client-side projection of the authenticated control-plane lifecycle. The
# server remains authoritative; this object only decides what the UI may show
# and refuses stale, gapped, or conflicting revisions instead of guessing.
signal changed(snapshot: Dictionary)
signal resync_required(resource_id: String)
const IDLE := "IDLE"
const QUEUED := "QUEUED"
const PROPOSED := "PROPOSED"
const ALLOCATING := "ALLOCATING"
const PROCESS_READY := "PROCESS_READY"
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
const CONNECTING := "CONNECTING"
const LIVE := "LIVE"
const CANCELLED := "CANCELLED"
const EXPIRED := "EXPIRED"
const FAILED := "FAILED"
var phase := IDLE
var ticket_id := ""
var playlist := ""
var revision := 0
var expires_at_unix := 0
var proposal_id := ""
var proposal_revision := 0
var proposal_state := ""
var message := ""
var needs_resync := false
func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
if new_ticket_id.is_empty() or (new_playlist != "casual" and new_playlist != "ranked"):
return false
_reset()
ticket_id = new_ticket_id
playlist = new_playlist
phase = QUEUED
_emit_changed()
return true
func apply_ticket_update(update: Dictionary) -> bool:
if not _has_string(update, "ticket_id") or not update.has("revision") or not update.has("state"):
return _request_resync(ticket_id)
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
return _request_resync(ticket_id)
var incoming_revision := int(update["revision"])
if incoming_revision < revision:
return false
if incoming_revision == revision:
if _ticket_differs(update):
return _request_resync(ticket_id)
return true
if incoming_revision > revision + 1:
return _request_resync(ticket_id)
var incoming_state := String(update["state"])
if not _is_ticket_state(incoming_state):
return _request_resync(ticket_id)
revision = incoming_revision
phase = incoming_state
if update.has("playlist"):
playlist = String(update["playlist"])
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
if update.has("message"):
message = String(update["message"])
_emit_changed()
return true
func apply_proposal_update(update: Dictionary) -> bool:
if not _has_string(update, "proposal_id") or not update.has("revision") or not update.has("state"):
return _request_resync(proposal_id)
var incoming_id := String(update["proposal_id"])
if proposal_id.is_empty():
proposal_id = incoming_id
elif proposal_id != incoming_id:
return _request_resync(proposal_id)
var incoming_revision := int(update["revision"])
if incoming_revision < proposal_revision:
return false
if incoming_revision == proposal_revision and not proposal_state.is_empty():
if String(update["state"]) != proposal_state:
return _request_resync(proposal_id)
return true
if not proposal_state.is_empty() and incoming_revision > proposal_revision + 1:
return _request_resync(proposal_id)
var incoming_proposal_state := String(update["state"])
if incoming_proposal_state == "OPEN":
phase = PROPOSED
elif incoming_proposal_state == "ACCEPTED":
phase = ALLOCATING
elif incoming_proposal_state == "DECLINED":
phase = FAILED
message = "A player declined the match proposal"
elif incoming_proposal_state == "EXPIRED":
phase = EXPIRED
message = "The match proposal expired"
else:
return _request_resync(proposal_id)
proposal_revision = incoming_revision
proposal_state = incoming_proposal_state
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
_emit_changed()
return true
func mark_assignment_ready() -> void:
phase = ASSIGNMENT_READY
message = "Match server is ready"
_emit_changed()
func mark_connecting() -> void:
phase = CONNECTING
message = "Connecting to match server"
_emit_changed()
func mark_live() -> void:
phase = LIVE
message = "Match in progress"
_emit_changed()
func fail(reason: String) -> void:
phase = FAILED
message = reason if not reason.is_empty() else "Matchmaking failed"
_emit_changed()
func can_cancel() -> bool:
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
func snapshot() -> Dictionary:
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "expires_at_unix": expires_at_unix, "proposal_id": proposal_id, "proposal_revision": proposal_revision, "proposal_state": proposal_state, "message": message, "needs_resync": needs_resync}
func _ticket_differs(update: Dictionary) -> bool:
return String(update["state"]) != phase or (update.has("playlist") and String(update["playlist"]) != playlist) or (update.has("expires_at_unix") and int(update["expires_at_unix"]) != expires_at_unix)
func _request_resync(resource_id: String) -> bool:
needs_resync = true
resync_required.emit(resource_id)
return false
func _emit_changed() -> void:
changed.emit(snapshot())
func _reset() -> void:
phase = IDLE
playlist = ""
revision = 0
expires_at_unix = 0
proposal_id = ""
proposal_revision = 0
proposal_state = ""
message = ""
needs_resync = false
func _is_ticket_state(value: String) -> bool:
return value in [QUEUED, PROPOSED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, CONNECTING, LIVE, CANCELLED, EXPIRED, FAILED]
func _has_string(value: Dictionary, key: String) -> bool:
return value.has(key) and value[key] is String and not String(value[key]).is_empty()