mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fix(multiplayer): enforce client state transitions
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user