From 47aa196b593c5e8a8905287192e5036cbd59efa7 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:51:42 +0100 Subject: [PATCH] feat(multiplayer): publish ranked profile contract --- multiplayer-next.md | 2 ++ server/contracts/v1/openapi.json | 5 +++++ server/contracts/v1/test_contracts.py | 9 ++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index eb3e2f02..409be8cc 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1530,3 +1530,5 @@ The ENet gate now auto-detects `/Applications/Godot.app/Contents/MacOS/Godot` wh The client matchmaking projection now preserves the server's `enqueued_at` timestamp through normalization, snapshots, and recovery, and uses it for the displayed queue wait when available. This prevents a client restart or delayed response from resetting the user's perceived wait to local process uptime; a local timer remains the fallback when older responses omit the timestamp. Godot state and normalization tests cover the projection and restore path. The ranked profile projection now also carries the active season's authoritative end timestamp from PostgreSQL through the API and Godot client. Ranked matchmaking displays a bounded days-remaining countdown, while providers without an active season remain compatible and omit the countdown. + +The versioned OpenAPI contract now declares the implemented `/profile/ranked` surface and its server-authoritative ranked profile schema, including optional active-season metadata. Contract tests reject omission of this operation, extra response fields, and credential leakage. diff --git a/server/contracts/v1/openapi.json b/server/contracts/v1/openapi.json index f3b185f0..ab4a3b77 100644 --- a/server/contracts/v1/openapi.json +++ b/server/contracts/v1/openapi.json @@ -19,6 +19,9 @@ "/profile": { "get": {"operationId": "getProfile", "responses": {"200": {"description": "Profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}}} }, + "/profile/ranked": { + "get": {"operationId": "getRankedProfile", "responses": {"200": {"description": "Authoritative ranked profile", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RankedProfile"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, "503": {"$ref": "#/components/responses/Unavailable"}}} + }, "/queue/tickets": { "post": { "operationId": "createQueueTicket", @@ -73,6 +76,7 @@ "Conflict": {"description": "Revision or idempotency conflict"}, "Invalid": {"description": "Invalid state or schema"}, "NotFound": {"description": "Resource not found"}, + "Unavailable": {"description": "Authoritative profile temporarily unavailable"}, "Expired": {"description": "Resource expired"} }, "schemas": { @@ -80,6 +84,7 @@ "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"}}}, + "RankedProfile": {"type": "object", "required": ["rating", "rd", "volatility", "ranked_games", "tier", "provisional"], "additionalProperties": false, "properties": {"rating": {"type": "number", "minimum": 0}, "rd": {"type": "number", "minimum": 0}, "volatility": {"type": "number", "minimum": 0}, "ranked_games": {"type": "integer", "minimum": 0}, "tier": {"type": "string", "enum": ["PROVISIONAL", "BRONZE", "SILVER", "GOLD", "PLATINUM", "DIAMOND"]}, "provisional": {"type": "boolean"}, "season_id": {"$ref": "#/components/schemas/OpaqueId"}, "season_ends_at": {"type": "string", "format": "date-time"}}}, "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"]}, diff --git a/server/contracts/v1/test_contracts.py b/server/contracts/v1/test_contracts.py index e99d5588..d3248f9e 100644 --- a/server/contracts/v1/test_contracts.py +++ b/server/contracts/v1/test_contracts.py @@ -27,9 +27,16 @@ class ContractTest(unittest.TestCase): "createSteamSession", "getProfile", "createQueueTicket", "heartbeatQueueTicket", "cancelQueueTicket", "acceptProposal", "declineProposal", "getAssignment", "registerServer", - "submitMatchResult", + "submitMatchResult", "getRankedProfile", } <= operations) + def test_ranked_profile_contract_is_authoritative_and_optional_season_metadata(self): + schema = self.openapi["components"]["schemas"]["RankedProfile"] + self.assertEqual(schema["required"], ["rating", "rd", "volatility", "ranked_games", "tier", "provisional"]) + self.assertFalse(schema["additionalProperties"]) + self.assertEqual(schema["properties"]["season_ends_at"]["format"], "date-time") + self.assertNotIn("access_token", json.dumps(schema).lower()) + def test_mutations_require_idempotency_and_revision(self): parameters = self.openapi["components"]["parameters"] self.assertEqual(parameters["IdempotencyKey"]["name"], "Idempotency-Key")