diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index cf08af9b..2f3f2ac2 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -457,9 +457,9 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head connect_event_stream() session_changed.emit(player_id) elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel": - if not _valid_response_opaque_id(payload, "ticket_id"): - state.fail("Queue response contains an invalid ticket identifier") - request_failed.emit(operation, response_code, "invalid ticket identifier") + if not _valid_queue_response(payload): + state.fail("Queue response contains invalid contract data") + request_failed.emit(operation, response_code, "invalid queue response") return if operation == "queue_create": state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", ""))) @@ -543,6 +543,21 @@ static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool: return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key])) +static func _valid_queue_response(payload: Dictionary) -> bool: + for key in ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"]: + if not payload.has(key): + return false + if not _valid_response_opaque_id(payload, "ticket_id") or not _valid_response_opaque_id(payload, "player_id"): + return false + if not payload["playlist"] is String or not String(payload["playlist"]) in ["casual", "ranked"]: + return false + if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]: + return false + if not _valid_revision(payload["revision"]): + return false + return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"])) + + static func _valid_proposal_response(payload: Dictionary) -> bool: if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array: return false diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index dd302881..57b6fa1e 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -174,6 +174,20 @@ func test_rest_responses_reject_malformed_resource_identifiers() -> void: client.free() +func test_queue_response_requires_the_complete_contract_shape() -> void: + var valid := {"ticket_id": "ticket_1234567890", "player_id": "player_1234567890", "playlist": "casual", "state": "QUEUED", "revision": 0, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"} + assert_true(ControlPlaneClient._valid_queue_response(valid), "complete queue response is accepted") + var missing_expiry := valid.duplicate() + missing_expiry.erase("expires_at") + assert_true(not ControlPlaneClient._valid_queue_response(missing_expiry), "queue response without expiry is rejected") + var fractional_revision := valid.duplicate() + fractional_revision["revision"] = 1.5 + assert_true(not ControlPlaneClient._valid_queue_response(fractional_revision), "fractional queue revision is rejected") + var malformed_player := valid.duplicate() + malformed_player["player_id"] = "player/unsafe" + assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected") + + func test_proposal_response_requires_structured_unique_participants() -> void: var base := {"proposal_id": "proposal_1234567890", "expires_at": "2099-08-31T12:00:00Z", "participants": [ {"player_id": "player_1234567890", "response": "PENDING", "team": 0, "slot": 0}, diff --git a/multiplayer-next.md b/multiplayer-next.md index faf739ad..bfffcab2 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1611,6 +1611,8 @@ The proposal wire contract now matches the real API participant-object shape (`p Proposal responses now require and normalize the contract's RFC3339 `expires_at`; malformed or missing expiry metadata fails closed while already-expired terminal proposals remain representable. +Queue responses now validate the complete published shape before projection: opaque ticket/player IDs, playlist and lifecycle enums, integral revision, and RFC3339 enqueue/expiry timestamps. + Signed MatchNet claims now also require exact JSON string/integer types for every identity, protocol, expiry, slot, team, and generation field; string-number coercion is rejected before canonical signature verification. Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives the menu, lobby, matchmaking, and settings surfaces consistent button, input, option, and label styling. The custom-font portion of `TODO.md` remains open until a distributable font asset is selected.