fix(multiplayer): enforce proposal transitions

This commit is contained in:
Josh Creek
2026-09-01 22:00:37 +01:00
parent a60c1a097e
commit 0ce2a49419
3 changed files with 38 additions and 0 deletions
+21
View File
@@ -112,15 +112,28 @@ func apply_proposal_update(update: Dictionary) -> bool:
return _request_resync(proposal_id) return _request_resync(proposal_id)
var incoming_proposal_state := String(update["state"]) var incoming_proposal_state := String(update["state"])
if incoming_proposal_state == "OPEN": if incoming_proposal_state == "OPEN":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = PROPOSED phase = PROPOSED
elif incoming_proposal_state == "ACCEPTED": elif incoming_proposal_state == "ACCEPTED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = ALLOCATING phase = ALLOCATING
elif incoming_proposal_state == "DECLINED": elif incoming_proposal_state == "DECLINED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = FAILED phase = FAILED
message = "A player declined the match proposal" message = "A player declined the match proposal"
elif incoming_proposal_state == "EXPIRED": elif incoming_proposal_state == "EXPIRED":
if not _is_legal_proposal_transition(proposal_state, incoming_proposal_state):
return _request_resync(proposal_id)
phase = EXPIRED phase = EXPIRED
message = "The match proposal 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: else:
return _request_resync(proposal_id) return _request_resync(proposal_id)
proposal_revision = incoming_revision proposal_revision = incoming_revision
@@ -268,6 +281,14 @@ func _is_legal_ticket_transition(from: String, to: String) -> bool:
return transitions.has(from) and to in transitions[from] 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: 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()
@@ -99,6 +99,21 @@ func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
assert_true(not expired.can_cancel(), "expired proposal cannot be cancelled") assert_true(not expired.can_cancel(), "expired proposal cannot be cancelled")
func test_proposal_projection_rejects_illegal_higher_revision_transitions() -> void:
var state := MatchmakingState.new()
state.begin_queue("ticket-proposal-transition", "casual")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 1, "state": "OPEN"}), "proposal opens")
assert_true(state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 2, "state": "ACCEPTED"}), "proposal accepts")
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-transition", "revision": 3, "state": "OPEN"}), "accepted proposal cannot reopen")
assert_eq(state.proposal_state, "ACCEPTED", "illegal proposal transition cannot mutate state")
state.needs_resync = false
var declined := MatchmakingState.new()
declined.begin_queue("ticket-proposal-declined", "casual")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 1, "state": "OPEN"}), "second proposal opens")
assert_true(declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 2, "state": "DECLINED"}), "second proposal declines")
assert_true(not declined.apply_proposal_update({"proposal_id": "proposal-declined", "revision": 3, "state": "ACCEPTED"}), "declined proposal cannot accept")
func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void: func test_assignment_lifecycle_has_explicit_connecting_and_live_states() -> void:
var state := MatchmakingState.new() var state := MatchmakingState.new()
state.begin_queue("ticket-1", "ranked") state.begin_queue("ticket-1", "ranked")
+2
View File
@@ -1544,3 +1544,5 @@ Client ticket updates now enforce the versioned legal transition graph as well a
Reconnect recovery now treats `COMPLETED` as terminal, avoiding a needless queue read after a finished match. UI policy tests cover the complete expanded lifecycle, including the completed-to-new-search boundary. Reconnect recovery now treats `COMPLETED` as terminal, avoiding a needless queue read after a finished match. UI policy tests cover the complete expanded lifecycle, including the completed-to-new-search boundary.
Ticket, proposal, and WebSocket revisions now fail closed unless they are finite, non-negative integers; fractional values are no longer silently truncated into valid revisions. Adversarial client tests cover fractional and negative inputs. Ticket, proposal, and WebSocket revisions now fail closed unless they are finite, non-negative integers; fractional values are no longer silently truncated into valid revisions. Adversarial client tests cover fractional and negative inputs.
Proposal updates now enforce the documented `OPEN → ACCEPTED/DECLINED/EXPIRED/CANCELLED` graph, including rejecting higher-revision reopen/accept attempts after terminal decisions while preserving same-state duplicates. Adversarial proposal-transition tests cover accepted and declined terminal paths.