From ffc7856993d36d23d181e1cceabdcb4ac89a5911 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:52:52 +0100 Subject: [PATCH] fix(multiplayer): align proposal participant contract --- Game/scripts/control_plane_client.gd | 26 ++++++++++++++++++- Game/tests/cases/test_control_plane_client.gd | 14 ++++++++++ multiplayer-next.md | 2 ++ server/contracts/v1/openapi.json | 3 ++- server/domain/proposal.go | 8 +++--- 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 2d5c8118..59e8b314 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -453,7 +453,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head if operation == "queue_create": state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", ""))) elif operation.begins_with("proposal_"): - if not _valid_response_opaque_id(payload, "proposal_id"): + if not _valid_proposal_response(payload): state.fail("Proposal response contains an invalid proposal identifier") request_failed.emit(operation, response_code, "invalid proposal identifier") return @@ -532,6 +532,30 @@ 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_proposal_response(payload: Dictionary) -> bool: + if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("participants") or not payload["participants"] is Array: + return false + var participants: Array = payload["participants"] + if participants.size() < 2 or participants.size() > 6: + return false + var seen := {} + for participant in participants: + if not participant is Dictionary: + return false + if not participant.has("player_id") or not participant["player_id"] is String or not is_valid_resource_id(String(participant["player_id"])) or seen.has(String(participant["player_id"])): + return false + if not participant.has("response") or not participant["response"] is String or not String(participant["response"]) in ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]: + return false + if not participant.has("team") or not participant.has("slot") or not _valid_revision(participant["team"]) or not _valid_revision(participant["slot"]): + return false + var team := int(participant["team"]) + var slot := int(participant["slot"]) + if team > 1 or slot > 5 or slot / 3 != team: + return false + seen[String(participant["player_id"])] = true + return true + + func _on_resync_required(resource_id: String) -> void: if not _operation.is_empty(): _pending_resync_resource_id = resource_id diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index d9cd0d01..6d08e699 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_proposal_response_requires_structured_unique_participants() -> void: + var base := {"proposal_id": "proposal_1234567890", "participants": [ + {"player_id": "player_1234567890", "response": "PENDING", "team": 0, "slot": 0}, + {"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3} + ]} + assert_true(ControlPlaneClient._valid_proposal_response(base), "structured proposal participants are accepted") + var duplicate := base.duplicate(true) + duplicate["participants"][1]["player_id"] = "player_1234567890" + assert_true(not ControlPlaneClient._valid_proposal_response(duplicate), "duplicate participant identity is rejected") + var fractional_slot := base.duplicate(true) + fractional_slot["participants"][0]["slot"] = 0.5 + assert_true(not ControlPlaneClient._valid_proposal_response(fractional_slot), "fractional participant slot is rejected") + + func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void: var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001") assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port") diff --git a/multiplayer-next.md b/multiplayer-next.md index 56d99381..2c7095f9 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1607,6 +1607,8 @@ Session establishment now also requires a present, syntactically valid, future ` MatchNet admission configuration now requires exact string opaque match/server IDs and a finite integral protocol version, preventing malformed server context from being coerced into a valid roster binding. +The proposal wire contract now matches the real API participant-object shape (`player_id`, response, team, slot), with JSON tags on the Go model and client validation for count, uniqueness, identities, enums, and integer team/slot assignments. + 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. diff --git a/server/contracts/v1/openapi.json b/server/contracts/v1/openapi.json index ab4a3b77..d1c4ad37 100644 --- a/server/contracts/v1/openapi.json +++ b/server/contracts/v1/openapi.json @@ -88,7 +88,8 @@ "QueueCreate": {"type": "object", "required": ["playlist", "client_build", "protocol_version"], "additionalProperties": false, "properties": {"playlist": {"type": "string", "enum": ["casual", "ranked"]}, "client_build": {"type": "string", "minLength": 1, "maxLength": 128}, "protocol_version": {"type": "integer", "minimum": 1}}}, "QueueTicket": {"type": "object", "required": ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"], "additionalProperties": false, "properties": {"ticket_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "playlist": {"type": "string", "enum": ["casual", "ranked"]}, "state": {"$ref": "#/components/schemas/QueueState"}, "revision": {"type": "integer", "minimum": 0}, "enqueued_at": {"type": "string", "format": "date-time"}, "expires_at": {"type": "string", "format": "date-time"}}}, "QueueState": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]}, - "Proposal": {"type": "object", "required": ["proposal_id", "revision", "state", "expires_at", "participants"], "additionalProperties": false, "properties": {"proposal_id": {"$ref": "#/components/schemas/OpaqueId"}, "revision": {"type": "integer", "minimum": 0}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}, "expires_at": {"type": "string", "format": "date-time"}, "participants": {"type": "array", "minItems": 2, "items": {"$ref": "#/components/schemas/OpaqueId"}}}}, + "Proposal": {"type": "object", "required": ["proposal_id", "revision", "state", "expires_at", "participants"], "additionalProperties": false, "properties": {"proposal_id": {"$ref": "#/components/schemas/OpaqueId"}, "revision": {"type": "integer", "minimum": 0}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}, "expires_at": {"type": "string", "format": "date-time"}, "participants": {"type": "array", "minItems": 2, "maxItems": 6, "items": {"$ref": "#/components/schemas/ProposalParticipant"}}}}, + "ProposalParticipant": {"type": "object", "required": ["player_id", "response", "team", "slot"], "additionalProperties": false, "properties": {"player_id": {"$ref": "#/components/schemas/OpaqueId"}, "response": {"type": "string", "enum": ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]}, "team": {"type": "integer", "minimum": 0, "maximum": 1}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}}}, "Assignment": {"type": "object", "required": ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "server_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}, "expires_at": {"type": "string", "format": "date-time"}, "protocol_version": {"type": "integer", "minimum": 1}, "transport": {"type": "string", "enum": ["steam_sdr", "enet"]}, "endpoint": {"type": "string", "minLength": 3, "maxLength": 256}, "join_authorisation": {"type": "string"}}}, "ServerRegistration": {"type": "object", "required": ["match_id", "protocol_version", "image_digest", "assignment_ready"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "protocol_version": {"type": "integer", "minimum": 1}, "image_digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, "assignment_ready": {"type": "boolean"}}}, "ServerShutdown": {"type": "object", "required": ["reason"], "additionalProperties": false, "properties": {"reason": {"type": "string", "minLength": 1, "maxLength": 96}}}, diff --git a/server/domain/proposal.go b/server/domain/proposal.go index 0718f223..262e3999 100644 --- a/server/domain/proposal.go +++ b/server/domain/proposal.go @@ -32,10 +32,10 @@ const ( ) type ProposalParticipant struct { - PlayerID string - Response Response - Team int - Slot int + PlayerID string `json:"player_id"` + Response Response `json:"response"` + Team int `json:"team"` + Slot int `json:"slot"` } type Proposal struct {