mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 10:32:05 +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"])
|
var incoming_state := String(update["state"])
|
||||||
if not _is_ticket_state(incoming_state):
|
if not _is_ticket_state(incoming_state):
|
||||||
return _request_resync(self.ticket_id)
|
return _request_resync(self.ticket_id)
|
||||||
|
if not _is_legal_ticket_transition(phase, incoming_state):
|
||||||
|
return _request_resync(self.ticket_id)
|
||||||
revision = incoming_revision
|
revision = incoming_revision
|
||||||
phase = incoming_state
|
phase = incoming_state
|
||||||
if update.has("playlist"):
|
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]
|
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:
|
func _has_string(value: Dictionary, key: String) -> bool:
|
||||||
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
|
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:
|
func test_ticket_projection_accepts_authoritative_accepted_phase() -> void:
|
||||||
var state := MatchmakingState.new()
|
var state := MatchmakingState.new()
|
||||||
assert_true(state.begin_queue("ticket-accepted", "ranked"), "queue setup succeeds")
|
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_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")
|
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"]:
|
for phase in ["ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED"]:
|
||||||
var state := MatchmakingState.new()
|
var state := MatchmakingState.new()
|
||||||
assert_true(state.begin_queue("ticket-" + phase, "casual"), "queue setup succeeds for " + phase)
|
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_eq(state.phase, phase, "post-match phase remains visible: " + phase)
|
||||||
assert_true(not state.can_cancel(), "post-match phase cannot cancel: " + 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")
|
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:
|
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
|
||||||
var state := MatchmakingState.new()
|
var state := MatchmakingState.new()
|
||||||
state.begin_queue("ticket-1", "casual")
|
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 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.
|
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