diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 760b60bc..78284ddb 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -419,9 +419,9 @@ func _handle_websocket_packet(packet: PackedByteArray) -> void: static func _valid_websocket_event(event: Dictionary) -> bool: if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty(): return false - if not event.has("revision") or not (event["revision"] is int or event["revision"] is float): + if not event.has("revision") or not _valid_revision(event["revision"]): return false - if int(event["revision"]) < 0 or not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty(): + if not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty(): return false if not event.has("occurred_at") or not event["occurred_at"] is String or String(event["occurred_at"]).is_empty(): return false @@ -437,6 +437,14 @@ static func _valid_websocket_event(event: Dictionary) -> bool: return false +static func _valid_revision(value: Variant) -> bool: + if value is int: + return int(value) >= 0 + if value is float: + return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value)) + return false + + func _on_resync_required(resource_id: String) -> void: if not _operation.is_empty(): _pending_resync_resource_id = resource_id diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index a6b79822..a4a9e027 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -49,7 +49,7 @@ func begin_queue(new_ticket_id: String, new_playlist: String) -> bool: func apply_ticket_update(update: Dictionary) -> bool: - if not _has_string(update, "ticket_id") or not update.has("revision") or not update.has("state"): + if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"): return _request_resync(self.ticket_id) if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id: return _request_resync(self.ticket_id) @@ -94,7 +94,7 @@ func apply_ticket_update(update: Dictionary) -> bool: func apply_proposal_update(update: Dictionary) -> bool: - if not _has_string(update, "proposal_id") or not update.has("revision") or not update.has("state"): + if not _has_string(update, "proposal_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"): return _request_resync(proposal_id) var incoming_id := String(update["proposal_id"]) if proposal_id.is_empty(): @@ -270,3 +270,11 @@ func _is_legal_ticket_transition(from: String, to: 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() + + +func _valid_revision(value: Variant) -> bool: + if value is int: + return int(value) >= 0 + if value is float: + return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value)) + return false diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index d7053688..39a4ee0d 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -60,6 +60,12 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted") assignment.erase("server_id") assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected") + var fractional := envelope.duplicate() + fractional["revision"] = 1.5 + assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected") + var negative := envelope.duplicate() + negative["revision"] = -1 + assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected") func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void: diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 879ae361..8c982a9a 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -75,6 +75,13 @@ func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase") +func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-revision", "casual") + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-revision", "revision": 1.5, "state": "PROPOSED"}), "fractional ticket revision is rejected") + assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision is rejected") + + func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void: var state := MatchmakingState.new() state.begin_queue("ticket-1", "casual") diff --git a/multiplayer-next.md b/multiplayer-next.md index 71495e0c..6fc50395 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1542,3 +1542,5 @@ The queue projection now also accepts the contract's post-allocation/result stat 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. 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.