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 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) 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) phase = FAILED 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) phase = EXPIRED 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) phase = CANCELLED 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 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 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 = String(saved.get("proposal_id", "")) proposal_revision = maxi(0, int(saved.get("proposal_revision", 0))) proposal_state = String(saved.get("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 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"