diff --git a/public/specs/service-match/openapi.json b/public/specs/service-match/openapi.json index 6b674f7..76b862d 100644 --- a/public/specs/service-match/openapi.json +++ b/public/specs/service-match/openapi.json @@ -2,10 +2,29 @@ "openapi" : "3.1.0", "components" : { "schemas" : { + "ActiveMatchResponse" : { + "description" : "The match a player is playing, for a proxy offering them a rejoin.", + "type" : "object", + "required" : [ "matchId" ], + "properties" : { + "matchId" : { + "type" : "string" + }, + "modeId" : { + "type" : "string", + "description" : "Mode within the project.", + "examples" : [ "duel" ] + }, + "gameServerName" : { + "type" : "string", + "description" : "The GameServer, as the proxy has it registered." + } + } + }, "AssignmentResponse" : { "description" : "The server a matched player should be routed to.", "type" : "object", - "required" : [ "matchId", "gameServerName", "address" ], + "required" : [ "matchId", "port" ], "properties" : { "matchId" : { "type" : "string" @@ -60,13 +79,40 @@ } } }, + "BoardBody" : { + "description" : "A stat board a mode writes.", + "type" : "object", + "properties" : { + "stat" : { + "type" : [ "string", "null" ], + "description" : "Matches [a-z0-9_-]{1,32}.", + "examples" : [ "wins" ] + }, + "displayName" : { + "type" : [ "string", "null" ], + "description" : "Max 64 characters. Omit to show the stat." + } + } + }, + "BoardResponse" : { + "type" : "object", + "required" : [ "stat", "displayName" ], + "properties" : { + "stat" : { + "type" : "string" + }, + "displayName" : { + "type" : "string" + } + } + }, "CancelTicketResponse" : { "description" : "Whether a ticket was withdrawn.", "type" : "object", "properties" : { "cancelled" : { "type" : "boolean", - "description" : "False when the ticket was already past QUEUED — a match had formed — or is not this player's. A legitimate answer, not an error." + "description" : "False when the ticket is past withdrawing — its match is being allocated or its players have been sent — or is not this player's. A legitimate answer, not an error. A ticket whose match's server is still preparing the round (PREPARING) can be withdrawn." } } }, @@ -83,6 +129,11 @@ "type" : [ "string", "null" ], "description" : "Mode within the project.", "examples" : [ "bedwars-squads" ] + }, + "map" : { + "type" : [ "string", "null" ], + "description" : "The map to play, `/`, from the mode's pool (GET /v1/match/modes/{modeId}/maps). Omit for any map. The player is then only matched with players who asked for the same map or for none. 400 if the map is not in the pool, or the mode has no maps.", + "examples" : [ "survivalgames/aura" ] } } }, @@ -106,9 +157,157 @@ } } }, + "MapsBody" : { + "description" : "Where a mode's maps come from and how one is chosen.", + "type" : "object", + "required" : [ "namespace" ], + "properties" : { + "namespace" : { + "type" : [ "string", "null" ], + "description" : "Every map pinned for this environment under `/` is in the pool. A namespace may itself contain a slash.", + "examples" : [ "survivalgames" ] + }, + "selection" : { + "type" : [ "string", "null" ], + "description" : "How a match's map is chosen when none of its players asked for one. RANDOM (default): uniformly from the pool, preferring maps that ship a pre-lit world.", + "examples" : [ "RANDOM" ] + } + } + }, + "MatchPresenceRequestBody" : { + "description" : "Whether the player is in their match right now.", + "type" : "object", + "required" : [ "connected" ], + "properties" : { + "connected" : { + "type" : [ "boolean", "null" ], + "description" : "False when the player dropped off the network: the match is kept for the rejoin grace only. True once they are back in it." + } + } + }, + "MatchReadyRequestBody" : { + "description" : "Whether the round a server was preparing for a match is ready.", + "type" : "object", + "required" : [ "gameServerName", "ok" ], + "properties" : { + "gameServerName" : { + "type" : [ "string", "null" ], + "description" : "The reporting server's GameServer name; must be the match's server." + }, + "ok" : { + "type" : [ "boolean", "null" ], + "description" : "True when the round is ready for its players, false when it could not be." + }, + "reason" : { + "type" : [ "string", "null" ], + "description" : "Why not, for operators. Never interpreted." + } + } + }, + "MatchReadyResponse" : { + "description" : "Whether the match's players are being sent to the server.", + "type" : "object", + "properties" : { + "routed" : { + "type" : "boolean", + "description" : "True: the players are routed (also on a retry after that). False: the match is no longer waiting for this server — it timed out, failed, too many players left, or the server reported it could not prepare — and the server should discard the round." + } + } + }, + "ModeMapsResponse" : { + "description" : "The maps a mode can be queued for.", + "type" : "object", + "properties" : { + "maps" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "description" : "Pinned map addresses, sorted. Empty for a mode without maps.", + "examples" : [ [ "survivalgames/aura", "survivalgames/breeze" ] ] + }, + "selection" : { + "type" : [ "string", "null" ], + "description" : "How a map is chosen when a ticket asks for none; null without maps.", + "examples" : [ "RANDOM" ] + } + } + }, + "ModeResponse" : { + "description" : "A live mode, as the lobby lists it.", + "type" : "object", + "required" : [ "modeId", "teamSize", "teamCount", "playersPerMatch", "minPlayers", "ranked", "rejoinEnabled", "boards", "ticketsQueued" ], + "properties" : { + "modeId" : { + "type" : "string" + }, + "displayName" : { + "type" : "string", + "description" : "The mode id when the mode registered none." + }, + "icon" : { + "type" : [ "string", "null" ] + }, + "teamSize" : { + "type" : "integer", + "format" : "int32" + }, + "teamCount" : { + "type" : "integer", + "format" : "int32" + }, + "playersPerMatch" : { + "type" : "integer", + "format" : "int32" + }, + "minPlayers" : { + "type" : "integer", + "format" : "int32" + }, + "ranked" : { + "type" : "boolean" + }, + "rejoinEnabled" : { + "type" : "boolean" + }, + "boards" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/BoardResponse" + } + }, + "ticketsQueued" : { + "type" : "integer", + "format" : "int32" + }, + "leases" : { + "type" : "integer", + "format" : "int32", + "description" : "Unexpired leases. Zero for a permanent mode." + }, + "permanent" : { + "type" : "boolean", + "description" : "True for a mode never registered with a lease." + } + } + }, + "ModesResponse" : { + "description" : "Every live mode: permanent, or leased with at least one live lease.", + "type" : "object", + "properties" : { + "modes" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ModeResponse" + }, + "description" : "Sorted by modeId." + } + } + }, "PlayerResultBody" : { "description" : "Where one player finished.", "type" : "object", + "required" : [ "playerId" ], "properties" : { "playerId" : { "type" : [ "string", "null" ] @@ -123,7 +322,7 @@ "ProblemDetails" : { "description" : "RFC 9457 problem details.", "type" : "object", - "required" : [ "title", "code" ], + "required" : [ "title", "status", "detail" ], "properties" : { "title" : { "type" : "string" @@ -138,13 +337,14 @@ "code" : { "type" : "string", "description" : "Stable machine-readable code.", - "examples" : [ "ticket_exists", "unknown_mode", "unknown_match", "not_in_match" ] + "examples" : [ "ticket_exists", "unknown_mode", "unknown_match", "not_in_match", "wrong_server" ] } } }, "QueueStatsResponse" : { "description" : "Queue depth for a mode, for UI.", "type" : "object", + "required" : [ "ticketsQueued" ], "properties" : { "ticketsQueued" : { "type" : "integer", @@ -159,6 +359,7 @@ "RatingResponse" : { "description" : "A player's Weng-Lin rating for one mode. Ratings are global, not per region.", "type" : "object", + "required" : [ "mu", "sigma", "gamesPlayed" ], "properties" : { "mu" : { "type" : "number", @@ -208,17 +409,20 @@ "rated" : { "type" : "boolean", "description" : "True when the match was ranked and ratings actually moved." + }, + "queued" : { + "type" : "boolean", + "description" : "True when the result was accepted onto the result stream and the ladder moves asynchronously, within seconds. `applied` and `rated` then say what will happen. False when it was applied before this answer." } } }, "TicketResponse" : { "description" : "Where a ticket stands.", "type" : "object", - "required" : [ "state" ], "properties" : { "state" : { "type" : "string", - "description" : "QUEUED, MATCHED, ASSIGNED, CANCELLED or FAILED.", + "description" : "QUEUED, MATCHED, PREPARING, ASSIGNED, CANCELLED or FAILED. PREPARING: the match has a server that is preparing the round; the player is not routed until it is ASSIGNED. A caller that does not know a state treats it as still waiting.", "examples" : [ "QUEUED" ] }, "assignment" : { @@ -234,7 +438,8 @@ "type" : "integer", "format" : "int32" } - } + }, + "required" : [ "queueDepth" ] }, "UpsertQueueRequestBody" : { "description" : "A mode's matchmaking configuration, as forge pushes it from the `matchmaking:` block in grounds.yaml. There is no hot reload: the next queue tick picks it up.", @@ -266,12 +471,60 @@ "fleetName" : { "type" : [ "string", "null" ], "description" : "The Agones Fleet that serves this mode. Omit when the fleet is named after the mode, which is the usual case. Set it when one image serves several modes — a duel server builds a different arena per mode and runs all of them from one pool, and a fleet per mode would idle a set of servers for each." + }, + "minPlayers" : { + "type" : [ "integer", "null" ], + "format" : "int32", + "description" : "The fewest players a match may start with. Omit for teamSize * teamCount, which always waits for a full match. Lower only for a free-for-all (teamSize 1): after fillSeconds an under-filled match forms from whoever is queued, as long as that is at least this many." + }, + "fillSeconds" : { + "type" : [ "integer", "null" ], + "format" : "int32", + "description" : "How long the longest-waiting player waits for a full match before an under-filled one forms. Ignored unless minPlayers is lowered." + }, + "leaseId" : { + "type" : [ "string", "null" ], + "description" : "Identity of the registering game server, its HOSTNAME. Present makes this registration a lease: the mode then exists only while at least one lease is unexpired. Omit for a permanent mode." + }, + "leaseSeconds" : { + "type" : [ "integer", "null" ], + "format" : "int32", + "description" : "Lease TTL in seconds, 15..600. Renewed by repeating the PUT. Default 60." + }, + "displayName" : { + "type" : [ "string", "null" ], + "description" : "Shown in the lobby. Max 64 characters. Omit to show the mode id." + }, + "icon" : { + "type" : [ "string", "null" ], + "description" : "Minecraft item key for the lobby, e.g. `minecraft:iron_sword`." + }, + "boards" : { + "type" : [ "array", "null" ], + "items" : { + "$ref" : "#/components/schemas/BoardBody" + }, + "description" : "Stat boards the game writes as `.`. The first is the default for /leaderboard." + }, + "rejoinEnabled" : { + "type" : "boolean", + "description" : "Whether a disconnected player may rejoin a running match." + }, + "maps" : { + "description" : "The mode's map pool. Omit for a mode without maps: its matches are pushed with no map, and a ticket cannot ask for one.", + "type" : [ "object", "null" ], + "anyOf" : [ { + "$ref" : "#/components/schemas/MapsBody" + }, { + "type" : "null" + } ] } } }, "UpsertQueueResponse" : { "description" : "Whether the upsert created the mode or replaced an existing config.", "type" : "object", + "required" : [ "created" ], "properties" : { "created" : { "type" : "boolean" @@ -299,6 +552,9 @@ }, { "name" : "Ratings", "description" : "Weng-Lin ratings, global per player and mode." + }, { + "name" : "Rejoin", + "description" : "The match a player is playing, for offering a rejoin." }, { "name" : "Results", "description" : "Report a finished match and move the ladder." @@ -307,6 +563,55 @@ "description" : "Queue for a match, withdraw, and read where a ticket stands." } ], "paths" : { + "/v1/match/matches/{matchId}/ready" : { + "post" : { + "summary" : "Report a prepared round ready", + "description" : "`ok: true` on a match that is PREPARING on this server routes its players (`routed: true`; a retry once routed answers the same). `ok: false` puts them back on the queue with the wait they had earned. A match that is no longer waiting for this server — timed out, failed, left by too many players, or unknown — answers `routed: false`, and the server should discard the round.", + "tags" : [ "Results" ], + "parameters" : [ { + "name" : "matchId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/MatchReadyRequestBody" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "Whether the players are being sent.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/MatchReadyResponse" + } + } + } + }, + "400" : { + "description" : "matchId is not a UUID, or gameServerName or ok is missing." + }, + "401" : { + "description" : "Authentication is missing or invalid." + }, + "409" : { + "description" : "The match is on another server." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + } + }, "/v1/match/matches/{matchId}/result" : { "post" : { "summary" : "Report a match result", @@ -359,6 +664,132 @@ } ] } }, + "/v1/match/modes" : { + "get" : { + "summary" : "Every live mode", + "description" : "Permanent modes, and leased modes with at least one unexpired lease, with their lobby metadata and queue depth. Sorted by modeId.", + "tags" : [ "Modes" ], + "responses" : { + "200" : { + "description" : "The live modes.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModesResponse" + } + } + } + }, + "401" : { + "description" : "Authentication is missing or invalid." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + } + }, + "/v1/match/modes/{modeId}" : { + "delete" : { + "summary" : "Remove a mode", + "description" : "Admin only. Removes the mode whether permanent or leased, and cancels every ticket still waiting in its queue.", + "tags" : [ "Modes" ], + "parameters" : [ { + "name" : "modeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + } ], + "responses" : { + "204" : { + "description" : "The mode is gone." + }, + "401" : { + "description" : "Authentication is missing or invalid." + }, + "403" : { + "description" : "The caller is not an admin." + }, + "404" : { + "description" : "There is no such mode." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + } + }, + "/v1/match/modes/{modeId}/leases/{leaseId}" : { + "delete" : { + "summary" : "Release a lease", + "description" : "Called by a game server on graceful shutdown. Idempotent. When it was the mode's last lease, the mode is removed and its waiting tickets are cancelled.", + "tags" : [ "Modes" ], + "parameters" : [ { + "name" : "leaseId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + }, { + "name" : "modeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + } ], + "responses" : { + "204" : { + "description" : "The lease is gone." + }, + "401" : { + "description" : "Authentication is missing or invalid." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + } + }, + "/v1/match/modes/{modeId}/maps" : { + "get" : { + "summary" : "The maps a mode can be queued for", + "description" : "Every map pinned for this environment under the mode's map namespace — what `map` on POST /v1/match/tickets accepts, and what a proxy completes `/queue ` from. Empty for a mode without maps.", + "tags" : [ "Modes" ], + "parameters" : [ { + "name" : "modeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + } ], + "responses" : { + "200" : { + "description" : "The mode's map pool.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModeMapsResponse" + } + } + } + }, + "401" : { + "description" : "Authentication is missing or invalid." + }, + "404" : { + "description" : "There is no such mode." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + } + }, "/v1/match/modes/{modeId}/queue" : { "put" : { "summary" : "Create or replace a mode's matchmaking config", @@ -394,7 +825,7 @@ } }, "400" : { - "description" : "teamSize or teamCount is out of range." + "description" : "A field is missing or out of range." }, "401" : { "description" : "Authentication is missing or invalid." @@ -438,6 +869,117 @@ } ] } }, + "/v1/match/players/{playerId}/match" : { + "delete" : { + "summary" : "Leave the match for good", + "description" : "The player went back to a lobby, or the match server sent them away. Idempotent.", + "tags" : [ "Rejoin" ], + "parameters" : [ { + "name" : "playerId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + } ], + "responses" : { + "204" : { + "description" : "No match is offered to the player." + }, + "400" : { + "description" : "playerId is not a UUID." + }, + "401" : { + "description" : "Authentication is missing or invalid." + }, + "503" : { + "description" : "Valkey is out of connections; retry after the Retry-After seconds." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + }, + "get" : { + "summary" : "Read the match a player is playing", + "description" : "404 is the ordinary answer: the player is in no match, it has ended, they left it, or they were away longer than the rejoin grace.", + "tags" : [ "Rejoin" ], + "parameters" : [ { + "name" : "playerId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + } ], + "responses" : { + "200" : { + "description" : "The player's running match.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ActiveMatchResponse" + } + } + } + }, + "400" : { + "description" : "playerId is not a UUID." + }, + "401" : { + "description" : "Authentication is missing or invalid." + }, + "404" : { + "description" : "The player has no running match." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + } + }, + "/v1/match/players/{playerId}/match/presence" : { + "put" : { + "summary" : "Report the player dropping off, or coming back", + "description" : "`connected: false` keeps the match for the rejoin grace only; `connected: true` gives it the lifetime of the match again.", + "tags" : [ "Rejoin" ], + "parameters" : [ { + "name" : "playerId", + "in" : "path", + "required" : true, + "schema" : { + "type" : [ "string", "null" ] + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/MatchPresenceRequestBody" + } + } + }, + "required" : true + }, + "responses" : { + "204" : { + "description" : "Recorded." + }, + "400" : { + "description" : "playerId is not a UUID, or connected is missing." + }, + "401" : { + "description" : "Authentication is missing or invalid." + }, + "404" : { + "description" : "The player has no running match." + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ] + } + }, "/v1/match/players/{playerId}/ratings/{modeId}" : { "get" : { "summary" : "Read a player's rating", @@ -508,7 +1050,7 @@ } }, "400" : { - "description" : "playerId or modeId is missing or not a UUID." + "description" : "playerId or modeId is missing or not a UUID, or the requested map is not in the mode's pool (or the mode has no maps)." }, "401" : { "description" : "Authentication is missing or invalid." @@ -562,7 +1104,7 @@ }, "delete" : { "summary" : "Withdraw a ticket", - "description" : "Only succeeds while the ticket is still QUEUED — once a match has formed the ticket is committed. `cancelled: false` is the answer for that case and for a ticket that is not this player's; neither is an error.", + "description" : "Succeeds while the ticket is QUEUED, and while it is PREPARING — its match's server is still preparing the round. A preparing match left with fewer than the mode's minPlayers fails and its other players go back on the queue; otherwise it goes ahead without this one. Once a match is being allocated (MATCHED) or its players are sent (ASSIGNED) the ticket is committed: `cancelled: false` is the answer for that case and for a ticket that is not this player's; neither is an error.", "tags" : [ "Tickets" ], "parameters" : [ { "name" : "ticketId", @@ -577,7 +1119,8 @@ "schema" : { "description" : "Checked against the ticket's owner.", "type" : [ "string", "null" ] - } + }, + "required" : true } ], "responses" : { "200" : {