mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: add revisioned matchmaking client state
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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()
|
||||
@@ -0,0 +1,66 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const MatchmakingState = preload("res://scripts/matchmaking_state.gd")
|
||||
|
||||
|
||||
func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
assert_true(state.begin_queue("ticket-1", "ranked"), "valid queue starts in QUEUED")
|
||||
assert_true(state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "next revision applies")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "proposal is visible")
|
||||
assert_true(state.can_cancel(), "authoritative cancel remains available before allocation")
|
||||
|
||||
|
||||
func test_ticket_projection_rejects_gap_and_wrong_ticket_without_mutation() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "casual")
|
||||
var resync_id := ""
|
||||
state.resync_required.connect(func(id: String) -> void: resync_id = id)
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-2", "revision": 1, "state": "PROPOSED"}), "another player's ticket is rejected")
|
||||
assert_eq(resync_id, "ticket-1", "wrong resource requests recovery for current ticket")
|
||||
assert_eq(state.phase, MatchmakingState.QUEUED, "invalid update cannot mutate phase")
|
||||
assert_true(state.needs_resync, "invalid identity is visible to recovery")
|
||||
|
||||
state.needs_resync = false
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 3, "state": "ALLOCATING"}), "revision gap is rejected")
|
||||
assert_eq(state.phase, MatchmakingState.QUEUED, "gap cannot skip authoritative state")
|
||||
|
||||
|
||||
func test_duplicate_conflict_and_stale_updates_are_safe() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "casual")
|
||||
var update := {"ticket_id": "ticket-1", "revision": 1, "state": "PROPOSED", "playlist": "casual", "expires_at_unix": 100}
|
||||
assert_true(state.apply_ticket_update(update), "first update applies")
|
||||
assert_true(state.apply_ticket_update(update), "identical duplicate is idempotent")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 1, "state": "QUEUED", "playlist": "casual", "expires_at_unix": 100}), "same-revision conflict requests recovery")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "conflicting replay cannot rewind state")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-1", "revision": 0, "state": "QUEUED"}), "stale update is ignored")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state")
|
||||
|
||||
|
||||
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "casual")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 1, "state": "OPEN"}), "open proposal applies")
|
||||
assert_eq(state.phase, MatchmakingState.PROPOSED, "open proposal is visible")
|
||||
assert_true(state.apply_proposal_update({"proposal_id": "proposal-1", "revision": 2, "state": "DECLINED"}), "declined proposal applies")
|
||||
assert_eq(state.phase, MatchmakingState.FAILED, "decline is terminal and visible")
|
||||
assert_true(not state.can_cancel(), "terminal proposal cannot issue queue cancel")
|
||||
|
||||
var expired := MatchmakingState.new()
|
||||
expired.begin_queue("ticket-2", "casual")
|
||||
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 1, "state": "OPEN"}), "second proposal opens")
|
||||
assert_true(expired.apply_proposal_update({"proposal_id": "proposal-2", "revision": 2, "state": "EXPIRED"}), "expired proposal applies")
|
||||
assert_eq(expired.phase, MatchmakingState.EXPIRED, "expiry is visible")
|
||||
assert_true(not expired.can_cancel(), "expired proposal cannot be cancelled")
|
||||
|
||||
|
||||
func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "ranked")
|
||||
state.mark_assignment_ready()
|
||||
assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "assignment readiness is visible")
|
||||
state.mark_connecting()
|
||||
assert_eq(state.phase, MatchmakingState.CONNECTING, "transport connection is visible")
|
||||
state.mark_live()
|
||||
assert_eq(state.phase, MatchmakingState.LIVE, "live match is visible")
|
||||
+1
-1
@@ -1226,7 +1226,7 @@ the local/CI/community transport, not a silent production fallback.
|
||||
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 8.39 `[D:8.3,8.14,8.17]` | Queue UI: playlist/quality, elapsed and estimated wait, proposal countdown, allocation/connect state, cancel and latency/capacity explanations | Every backend state and terminal failure has a non-stuck visible state; cancel/decline is acknowledged authoritatively |
|
||||
| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** New pure Godot `MatchmakingState` projection models queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states, and exposes authoritative cancel availability | `test_matchmaking_state.gd` rejects wrong-ticket, revision-gap, same-revision conflict and stale updates, preserves idempotent duplicates, and keeps decline/expiry visible; HTTP client, queue UI wiring, wait/latency explanations and end-to-end backend events remain |
|
||||
| 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read | `server/domain/sync.go` and `server/api/service.go` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery and expired-ticket terminal handling; authenticated WebSocket transport, client restart persistence and duplicate-ticket integration remain |
|
||||
| 8.41 `[D:7.8,8.9,8.31,8.40]` | After assignment-ready, install SDR relay ticket before connect and send match-scoped join authorisation in `hello`; retain ENet assignments locally | Production connects/reconnects/fences old generation through SDR, never before assignment-ready; allocated/direct ENet and community flows remain compatible |
|
||||
| 8.42 `[D:8.22,8.23,8.24,8.40]` | Backend-authoritative provisional/rank/tier/delta, abandon status and season countdown UI | Client performs no rating math and displays the committed revision after reconnect |
|
||||
|
||||
Reference in New Issue
Block a user