Files
CosmicClash/Game/scripts/matchmaking_state.gd
T

352 lines
12 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 ACCEPTED := "ACCEPTED"
const ALLOCATING := "ALLOCATING"
const PROCESS_READY := "PROCESS_READY"
const ASSIGNMENT_READY := "ASSIGNMENT_READY"
const CONNECTING := "CONNECTING"
const LIVE := "LIVE"
const ASSIGNED := "ASSIGNED"
const RESULT_PENDING := "RESULT_PENDING"
const COMPLETED := "COMPLETED"
const CANCELLED := "CANCELLED"
const EXPIRED := "EXPIRED"
const FAILED := "FAILED"
var phase := IDLE
var ticket_id := ""
var playlist := ""
var revision := 0
var enqueued_at_unix := 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 _valid_revision(update["revision"]) or not update.has("state"):
return _request_resync(self.ticket_id)
if update.has("playlist") and not _valid_playlist(String(update["playlist"])):
return _request_resync(self.ticket_id)
if update.has("enqueued_at_unix") and not _valid_epoch(update["enqueued_at_unix"]):
return _request_resync(self.ticket_id)
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
return _request_resync(self.ticket_id)
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
return _request_resync(self.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(self.ticket_id)
# expires_at_unix is deliberately not part of _ticket_differs' conflict
# check (see its own comment) but is still adopted here: begin_queue()
# has no way to know the server-assigned expiry in advance, so the
# very first same-revision confirmation is the only place a freshly
# queued ticket's expiry is ever set at all.
if update.has("expires_at_unix"):
expires_at_unix = int(update["expires_at_unix"])
if update.has("enqueued_at_unix"):
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
return true
if incoming_revision > revision + 1:
return _request_resync(self.ticket_id)
var incoming_state := String(update["state"])
if not _is_ticket_state(incoming_state):
return _request_resync(self.ticket_id)
if not _is_legal_ticket_transition(phase, incoming_state):
return _request_resync(self.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("enqueued_at_unix"):
enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"]))
if update.has("message"):
message = String(update["message"])
else:
message = ""
needs_resync = false
_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 _valid_revision(update["revision"]) or not update.has("state"):
return _request_resync(proposal_id)
if update.has("expires_at_unix") and not _valid_epoch(update["expires_at_unix"]):
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":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = PROPOSED
elif incoming_proposal_state == "ACCEPTED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = ALLOCATING
elif incoming_proposal_state == "DECLINED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "A player declined the match proposal"
elif incoming_proposal_state == "EXPIRED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "The match proposal expired"
elif incoming_proposal_state == "CANCELLED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
if phase == PROPOSED:
phase = QUEUED
message = "The match proposal was cancelled"
else:
return _request_resync(proposal_id)
proposal_revision = incoming_revision
proposal_state = incoming_proposal_state
needs_resync = false
if incoming_proposal_state == "OPEN" or incoming_proposal_state == "ACCEPTED":
message = ""
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 expire(reason: String = "Matchmaking expired") -> void:
phase = EXPIRED
message = reason
_emit_changed()
func set_notice(notice: String) -> void:
message = notice
_emit_changed()
func restore_snapshot(saved: Dictionary) -> bool:
_reset()
if saved.is_empty():
return true
if saved.has("phase") and not saved["phase"] is String:
return false
if saved.has("ticket_id") and not saved["ticket_id"] is String:
return false
if saved.has("playlist") and not saved["playlist"] is String:
return false
if saved.has("proposal_id") and not saved["proposal_id"] is String:
return false
if saved.has("proposal_state") and not saved["proposal_state"] is String:
return false
if saved.has("message") and not saved["message"] is String:
return false
if saved.has("revision") and not _valid_revision(saved["revision"]):
return false
for epoch_key in ["enqueued_at_unix", "expires_at_unix"]:
if saved.has(epoch_key) and not _valid_epoch(saved[epoch_key]):
return false
if saved.has("proposal_revision") and not _valid_revision(saved["proposal_revision"]):
return false
var saved_phase := String(saved.get("phase", IDLE))
var saved_ticket_id := String(saved.get("ticket_id", ""))
if saved_ticket_id.is_empty() or not _is_ticket_state(saved_phase):
return false
var saved_playlist := String(saved.get("playlist", ""))
if saved_playlist != "casual" and saved_playlist != "ranked":
return false
var saved_proposal_id := String(saved.get("proposal_id", ""))
var saved_proposal_state := String(saved.get("proposal_state", ""))
if saved_proposal_state not in ["", "OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] or (not saved_proposal_state.is_empty() and saved_proposal_id.is_empty()):
return false
ticket_id = saved_ticket_id
playlist = saved_playlist
phase = saved_phase
revision = maxi(0, int(saved.get("revision", 0)))
enqueued_at_unix = maxi(0, int(saved.get("enqueued_at_unix", 0)))
expires_at_unix = maxi(0, int(saved.get("expires_at_unix", 0)))
proposal_id = saved_proposal_id
proposal_revision = maxi(0, int(saved.get("proposal_revision", 0)))
proposal_state = saved_proposal_state
message = "Recovering authoritative matchmaking state"
needs_resync = phase != CANCELLED and phase != EXPIRED and phase != FAILED and phase != COMPLETED
_emit_changed()
return true
func can_cancel() -> bool:
return phase == QUEUED or phase == PROPOSED or phase == ALLOCATING
func has_open_proposal() -> bool:
return not proposal_id.is_empty() and proposal_state == "OPEN"
func waited_seconds(now_unix: int) -> int:
if enqueued_at_unix <= 0:
return 0
return maxi(0, now_unix - enqueued_at_unix)
func snapshot() -> Dictionary:
return {"phase": phase, "ticket_id": ticket_id, "playlist": playlist, "revision": revision, "enqueued_at_unix": enqueued_at_unix, "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:
# expires_at_unix is excluded on purpose: begin_queue()'s optimistic local
# state has no way to know the server-assigned expiry before the first
# real response arrives, so comparing it here made the very first
# same-revision confirmation after every begin_queue() look like a
# conflict, unconditionally -- found by an actual client hitting a real
# server: apply_ticket_update() kept requesting a resync, whose own
# response hit exactly the same false mismatch, forever, which
# control_plane_smoke.gd (a live end-to-end test, not a mock) surfaced as
# a request that legitimately never terminates. It's still kept current
# via the direct assignment below, just not treated as a conflict signal.
return String(update["state"]) != phase or (update.has("playlist") and String(update["playlist"]) != playlist)
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
enqueued_at_unix = 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, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]
func _is_legal_ticket_transition(from: String, to: String) -> bool:
if from == to:
return true
var transitions := {
QUEUED: [PROPOSED, CANCELLED, EXPIRED],
PROPOSED: [QUEUED, ACCEPTED, CANCELLED, EXPIRED],
ACCEPTED: [QUEUED, ALLOCATING, CANCELLED, FAILED],
ALLOCATING: [PROCESS_READY, FAILED, CANCELLED],
PROCESS_READY: [ASSIGNMENT_READY, FAILED, CANCELLED],
ASSIGNMENT_READY: [ASSIGNED, FAILED, CANCELLED],
ASSIGNED: [CONNECTING, FAILED, CANCELLED],
CONNECTING: [LIVE, FAILED, EXPIRED],
LIVE: [RESULT_PENDING, FAILED],
RESULT_PENDING: [COMPLETED, FAILED],
}
return transitions.has(from) and to in transitions[from]
func _is_legal_proposal_transition(from: String, to: String) -> bool:
if from.is_empty():
return to == "OPEN"
if from == to:
return true
return from == "OPEN" and to in ["ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]
func _has_string(value: Dictionary, key: String) -> bool:
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
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
func _valid_playlist(value: String) -> bool:
return value == "casual" or value == "ranked"
func _valid_epoch(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