fix(multiplayer): align proposal participant contract

This commit is contained in:
Josh Creek
2026-09-01 22:52:52 +01:00
parent 807aaa4478
commit ffc7856993
5 changed files with 47 additions and 6 deletions
+25 -1
View File
@@ -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
@@ -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")