fix(multiplayer): enforce client state transitions

This commit is contained in:
Josh Creek
2026-09-01 21:57:13 +01:00
parent 30a5a89164
commit 0f1864f8bc
3 changed files with 43 additions and 2 deletions
+20
View File
@@ -74,6 +74,8 @@ func apply_ticket_update(update: Dictionary) -> bool:
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"):
@@ -248,5 +250,23 @@ 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 _has_string(value: Dictionary, key: String) -> bool:
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
+21 -2
View File
@@ -14,7 +14,8 @@ func test_ticket_projection_accepts_ordered_updates_and_exposes_cancel() -> void
func test_ticket_projection_accepts_authoritative_accepted_phase() -> void:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 1, "state": "PROPOSED", "playlist": "ranked"}), "proposal phase applies")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-accepted", "revision": 2, "state": "ACCEPTED", "playlist": "ranked"}), "accepted queue phase is valid")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "accepted phase remains visible instead of forcing resync")
assert_true(not state.can_cancel(), "accepted match cannot be cancelled as a queue ticket")
@@ -23,7 +24,12 @@ func test_ticket_projection_accepts_post_match_lifecycle_states() -> void:
for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
var state := MatchmakingState.new()
assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase)
assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": 1, "state": phase, "playlist": "casual"}), "post-match phase is valid: " + phase)
var revision := 1
for next_phase in ["PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
assert_true(state.apply_ticket_update({"ticket_id": "ticket-" + phase, "revision": revision, "state": next_phase, "playlist": "casual"}), "lifecycle phase applies: " + next_phase)
revision += 1
if next_phase == phase:
break
assert_eq(state.phase, phase, "post-match phase remains visible: " + phase)
assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + phase)
@@ -56,6 +62,19 @@ func test_duplicate_conflict_and_stale_updates_are_safe() -> void:
assert_eq(state.phase, MatchmakingState.PROPOSED, "stale update cannot mutate state")
func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-transition", "casual")
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "legal transition applies")
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "LIVE", "playlist": "casual"}), "higher revision cannot jump phases")
assert_eq(state.phase, MatchmakingState.PROPOSED, "illegal jump cannot mutate phase")
state.needs_resync = false
assert_true(state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 2, "state": "ACCEPTED", "playlist": "casual"}), "legal next transition applies")
state.needs_resync = false
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-transition", "revision": 3, "state": "PROPOSED", "playlist": "casual"}), "higher revision cannot rewind after acceptance")
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-1", "casual")
+2
View File
@@ -1538,3 +1538,5 @@ The Godot client now defers reconnect-triggered authoritative recovery when an H
The Godot queue projection now includes the contract's `ACCEPTED` ticket phase. Accepted events are no longer rejected as an unknown state; the UI keeps the accepted status visible and proceeds through allocation recovery. State and WebSocket vocabulary tests cover the transition.
The queue projection now also accepts the contract's post-allocation/result states (`ASSIGNED`, `RESULT_PENDING`, and `COMPLETED`). These states remain visible, cannot issue queue cancellation, and completed matches return the search action to a valid new-search state; adversarial lifecycle and WebSocket vocabulary tests cover them.
Client ticket updates now enforce the versioned legal transition graph as well as revision ordering. Same-state heartbeat revisions remain valid, while higher-revision jumps and rewinds request authoritative recovery without mutating the visible phase; adversarial tests cover both boundaries.