mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat: publish matchmaking v1 contracts
This commit is contained in:
+1
-1
@@ -34,7 +34,7 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
|
||||
measurable launch SLOs are defined in
|
||||
[MATCHMAKING-SLOs.md](docs/MATCHMAKING-SLOs.md).
|
||||
- [ ] Publish versioned OpenAPI/WebSocket contracts, stable IDs, legal state
|
||||
transitions, revisions and idempotency semantics.
|
||||
transitions, revisions and idempotency semantics ([v1 contracts](server/contracts/v1/)).
|
||||
- [ ] Add PostgreSQL queue ownership/active-participation fences, durable
|
||||
domain migrations/outbox and Redis indexes/TTLs; lost Redis writes must not
|
||||
split a proposal or corrupt durable state.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Cosmic Clash Matchmaking API",
|
||||
"version": "1.0.0",
|
||||
"description": "Versioned control-plane contract. Simulation traffic never uses this API."
|
||||
},
|
||||
"servers": [{"url": "https://matchmaking.invalid/api/v1"}],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"paths": {
|
||||
"/session/steam": {
|
||||
"post": {
|
||||
"security": [],
|
||||
"operationId": "createSteamSession",
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SteamLogin"}}}},
|
||||
"responses": {"200": {"description": "Session created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Session"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "429": {"$ref": "#/components/responses/RateLimited"}}
|
||||
}
|
||||
},
|
||||
"/profile": {
|
||||
"get": {"operationId": "getProfile", "responses": {"200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}}
|
||||
},
|
||||
"/queue/tickets": {
|
||||
"post": {
|
||||
"operationId": "createQueueTicket",
|
||||
"parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueCreate"}}}},
|
||||
"responses": {"201": {"description": "Ticket created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueTicket"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/Invalid"}}
|
||||
}
|
||||
},
|
||||
"/queue/tickets/{ticketId}": {
|
||||
"parameters": [{"$ref": "#/components/parameters/TicketId"}],
|
||||
"get": {"operationId": "getQueueTicket", "responses": {"200": {"description": "Ticket", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueTicket"}}}}, "404": {"$ref": "#/components/responses/NotFound"}}},
|
||||
"delete": {"operationId": "cancelQueueTicket", "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"204": {"description": "Cancelled"}, "409": {"$ref": "#/components/responses/Conflict"}}}
|
||||
},
|
||||
"/queue/tickets/{ticketId}/heartbeat": {
|
||||
"post": {"operationId": "heartbeatQueueTicket", "parameters": [{"$ref": "#/components/parameters/TicketId"}, {"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"200": {"description": "Ticket renewed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueTicket"}}}}, "409": {"$ref": "#/components/responses/Conflict"}}}
|
||||
},
|
||||
"/proposals/{proposalId}/accept": {
|
||||
"post": {"operationId": "acceptProposal", "parameters": [{"$ref": "#/components/parameters/ProposalId"}, {"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"200": {"description": "Proposal updated", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Proposal"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "410": {"$ref": "#/components/responses/Expired"}}}
|
||||
},
|
||||
"/proposals/{proposalId}/decline": {
|
||||
"post": {"operationId": "declineProposal", "parameters": [{"$ref": "#/components/parameters/ProposalId"}, {"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/ExpectedRevision"}], "responses": {"200": {"description": "Proposal declined", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Proposal"}}}}, "409": {"$ref": "#/components/responses/Conflict"}, "410": {"$ref": "#/components/responses/Expired"}}}
|
||||
},
|
||||
"/assignments/{matchId}": {
|
||||
"get": {"operationId": "getAssignment", "parameters": [{"$ref": "#/components/parameters/MatchId"}], "responses": {"200": {"description": "Assignment", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Assignment"}}}}, "404": {"$ref": "#/components/responses/NotFound"}}}
|
||||
},
|
||||
"/servers/{serverId}/register": {
|
||||
"post": {"security": [{"serverCredential": []}], "operationId": "registerServer", "parameters": [{"$ref": "#/components/parameters/ServerId"}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerRegistration"}}}}, "responses": {"204": {"description": "Registered"}, "409": {"$ref": "#/components/responses/Conflict"}}}
|
||||
},
|
||||
"/servers/{serverId}/result": {
|
||||
"post": {"security": [{"serverCredential": []}], "operationId": "submitMatchResult", "parameters": [{"$ref": "#/components/parameters/ServerId"}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MatchResult"}}}}, "responses": {"202": {"description": "Result accepted"}, "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/Invalid"}}}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {"type": "http", "scheme": "bearer"},
|
||||
"serverCredential": {"type": "http", "scheme": "bearer", "bearerFormat": "match-bound workload credential"}
|
||||
},
|
||||
"parameters": {
|
||||
"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": true, "schema": {"type": "string", "minLength": 16, "maxLength": 128}},
|
||||
"ExpectedRevision": {"name": "If-Match-Revision", "in": "header", "required": true, "schema": {"type": "integer", "minimum": 0}},
|
||||
"TicketId": {"name": "ticketId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}},
|
||||
"ProposalId": {"name": "proposalId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}},
|
||||
"MatchId": {"name": "matchId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}},
|
||||
"ServerId": {"name": "serverId", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/OpaqueId"}}
|
||||
},
|
||||
"responses": {
|
||||
"Unauthorized": {"description": "Authentication failed"},
|
||||
"RateLimited": {"description": "Rate limit exceeded"},
|
||||
"Conflict": {"description": "Revision or idempotency conflict"},
|
||||
"Invalid": {"description": "Invalid state or schema"},
|
||||
"NotFound": {"description": "Resource not found"},
|
||||
"Expired": {"description": "Resource expired"}
|
||||
},
|
||||
"schemas": {
|
||||
"OpaqueId": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"},
|
||||
"SteamLogin": {"type": "object", "required": ["web_api_ticket"], "additionalProperties": false, "properties": {"web_api_ticket": {"type": "string", "minLength": 1, "maxLength": 4096}}},
|
||||
"Session": {"type": "object", "required": ["player_id", "expires_at", "access_token"], "additionalProperties": false, "properties": {"player_id": {"$ref": "#/components/schemas/OpaqueId"}, "expires_at": {"type": "string", "format": "date-time"}, "access_token": {"type": "string"}}},
|
||||
"Profile": {"type": "object", "required": ["player_id", "rating", "rd", "provisional"], "additionalProperties": false, "properties": {"player_id": {"$ref": "#/components/schemas/OpaqueId"}, "rating": {"type": "number"}, "rd": {"type": "number"}, "provisional": {"type": "boolean"}}},
|
||||
"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"}}}},
|
||||
"Assignment": {"type": "object", "required": ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "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"]}, "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"}}},
|
||||
"MatchResult": {"type": "object", "required": ["match_id", "result_nonce", "score", "integrity_state"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "result_nonce": {"type": "string", "minLength": 16, "maxLength": 128}, "score": {"type": "object", "required": ["team_0", "team_1"], "additionalProperties": false, "properties": {"team_0": {"type": "integer", "minimum": 0}, "team_1": {"type": "integer", "minimum": 0}}}, "integrity_state": {"type": "string", "enum": ["CERTIFIED", "SUPPRESSED", "REVIEW"]}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Dependency-free structural checks for the versioned control-plane contract."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
|
||||
|
||||
class ContractTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.openapi = json.loads((ROOT / "openapi.json").read_text())
|
||||
cls.events = json.loads((ROOT / "websocket-events.json").read_text())
|
||||
|
||||
def test_openapi_is_versioned_and_has_core_surfaces(self):
|
||||
self.assertEqual(self.openapi["openapi"], "3.1.0")
|
||||
operations = {
|
||||
operation["operationId"]
|
||||
for path in self.openapi["paths"].values()
|
||||
for operation in path.values()
|
||||
if isinstance(operation, dict) and "operationId" in operation
|
||||
}
|
||||
self.assertTrue({
|
||||
"createSteamSession", "getProfile", "createQueueTicket",
|
||||
"heartbeatQueueTicket", "cancelQueueTicket", "acceptProposal",
|
||||
"declineProposal", "getAssignment", "registerServer",
|
||||
"submitMatchResult",
|
||||
} <= operations)
|
||||
|
||||
def test_mutations_require_idempotency_and_revision(self):
|
||||
parameters = self.openapi["components"]["parameters"]
|
||||
self.assertEqual(parameters["IdempotencyKey"]["name"], "Idempotency-Key")
|
||||
self.assertTrue(parameters["IdempotencyKey"]["required"])
|
||||
self.assertEqual(parameters["ExpectedRevision"]["name"], "If-Match-Revision")
|
||||
for path, methods in self.openapi["paths"].items():
|
||||
for method, operation in methods.items():
|
||||
if method not in {"post", "delete", "put", "patch"} or "operationId" not in operation:
|
||||
continue
|
||||
if operation["operationId"] == "createSteamSession":
|
||||
continue
|
||||
refs = {item.get("$ref") for item in operation.get("parameters", [])}
|
||||
self.assertIn("#/components/parameters/IdempotencyKey", refs, path)
|
||||
|
||||
def test_state_vocabulary_is_shared(self):
|
||||
queue_states = self.openapi["components"]["schemas"]["QueueState"]["enum"]
|
||||
websocket_states = self.events["$defs"]["stateChanged"]["allOf"][1]["properties"]["state"]["enum"]
|
||||
self.assertEqual(queue_states, websocket_states)
|
||||
self.assertIn("ASSIGNMENT_READY", queue_states)
|
||||
self.assertIn("RESULT_PENDING", queue_states)
|
||||
|
||||
def test_events_have_revisioned_envelopes_and_no_credentials(self):
|
||||
envelope = self.events["$defs"]["envelope"]
|
||||
self.assertEqual(envelope["required"], ["event", "revision", "resource_id", "occurred_at"])
|
||||
serialized = json.dumps(self.events).lower()
|
||||
self.assertNotIn("access_token", serialized)
|
||||
self.assertNotIn("web_api_ticket", serialized)
|
||||
self.assertNotIn("relay_ticket", serialized)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://cosmic-clash.invalid/contracts/v1/websocket-events.json",
|
||||
"title": "Cosmic Clash control-plane WebSocket event",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/stateChanged"},
|
||||
{"$ref": "#/$defs/proposalChanged"},
|
||||
{"$ref": "#/$defs/assignmentChanged"},
|
||||
{"$ref": "#/$defs/error"}
|
||||
],
|
||||
"$defs": {
|
||||
"envelope": {"type": "object", "required": ["event", "revision", "resource_id", "occurred_at"], "properties": {"revision": {"type": "integer", "minimum": 0}, "resource_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, "occurred_at": {"type": "string", "format": "date-time"}}},
|
||||
"stateChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "state_changed"}, "state": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]}}, "required": ["event", "state"]}]},
|
||||
"proposalChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "proposal_changed"}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}}, "required": ["event", "state"]}]},
|
||||
"assignmentChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "assignment_changed"}, "match_id": {"type": "string"}, "server_id": {"type": "string"}}, "required": ["event", "match_id", "server_id"]}]},
|
||||
"error": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "error"}, "code": {"type": "string", "enum": ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]}}, "required": ["event", "code"]}]}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user