mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 04:22:04 +00:00
fix(multiplayer): align proposal participant contract
This commit is contained in:
@@ -453,7 +453,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
|||||||
if operation == "queue_create":
|
if operation == "queue_create":
|
||||||
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
|
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
|
||||||
elif operation.begins_with("proposal_"):
|
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")
|
state.fail("Proposal response contains an invalid proposal identifier")
|
||||||
request_failed.emit(operation, response_code, "invalid proposal identifier")
|
request_failed.emit(operation, response_code, "invalid proposal identifier")
|
||||||
return
|
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]))
|
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:
|
func _on_resync_required(resource_id: String) -> void:
|
||||||
if not _operation.is_empty():
|
if not _operation.is_empty():
|
||||||
_pending_resync_resource_id = resource_id
|
_pending_resync_resource_id = resource_id
|
||||||
|
|||||||
@@ -174,6 +174,20 @@ func test_rest_responses_reject_malformed_resource_identifiers() -> void:
|
|||||||
client.free()
|
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:
|
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
|
||||||
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
|
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")
|
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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}}},
|
"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"}}},
|
"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"]},
|
"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"}}},
|
"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"}}},
|
"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}}},
|
"ServerShutdown": {"type": "object", "required": ["reason"], "additionalProperties": false, "properties": {"reason": {"type": "string", "minLength": 1, "maxLength": 96}}},
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ProposalParticipant struct {
|
type ProposalParticipant struct {
|
||||||
PlayerID string
|
PlayerID string `json:"player_id"`
|
||||||
Response Response
|
Response Response `json:"response"`
|
||||||
Team int
|
Team int `json:"team"`
|
||||||
Slot int
|
Slot int `json:"slot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Proposal struct {
|
type Proposal struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user