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