diff --git a/docs/Tournament-API.md b/docs/Tournament-API.md new file mode 100644 index 0000000..2cdaac2 --- /dev/null +++ b/docs/Tournament-API.md @@ -0,0 +1,313 @@ +# NowChess Tournament API + +Swiss-system bot tournaments. Bots are paired by score each round; all bots play every round (no eliminations). Game moves flow through the existing board and bot endpoints — the tournament module only orchestrates pairings, standings, and lifecycle. + +--- + +## Base path + +``` +/api/tournament +``` + +Routing: `/api/tournament` → `nowchess-tournament-active:8086` + +--- + +## Authentication + +All endpoints require a valid JWT (`Authorization: Bearer `). +Bot-facing streaming endpoints additionally require the token's subject to match the registered `botId`. + +--- + +## Data models + +### Tournament + +```json +{ + "id": "t7kXq2", + "name": "Friday Night Bots", + "status": "created | started | finished", + "rounds": 5, + "currentRound": 2, + "timeControl": { + "limitSeconds": 300, + "incrementSeconds": 3 + }, + "createdBy": "userId", + "createdAt": "2026-05-13T18:00:00Z", + "startedAt": "2026-05-13T18:05:00Z", + "finishedAt": null +} +``` + +### Standing + +```json +{ + "rank": 1, + "botId": "bot_abc", + "botName": "StockfishClone", + "points": 3.5, + "wins": 3, + "draws": 1, + "losses": 0, + "buchholz": 9.0 +} +``` + +Tiebreaker: Buchholz score (sum of opponents' points). + +### Pairing + +```json +{ + "round": 2, + "whiteBot": "bot_abc", + "blackBot": "bot_xyz", + "gameId": "j0nPtcjl", + "result": "white | black | draw | ongoing" +} +``` + +### TournamentEvent (SSE) + +```json +{ "type": "tournamentStarted", "tournamentId": "t7kXq2" } +{ "type": "roundStarted", "tournamentId": "t7kXq2", "round": 2 } +{ "type": "pairingReady", "tournamentId": "t7kXq2", "round": 2, "gameId": "j0nPtcjl", "color": "white" } +{ "type": "roundFinished", "tournamentId": "t7kXq2", "round": 2 } +{ "type": "tournamentFinished","tournamentId": "t7kXq2" } +``` + +--- + +## Endpoints + +### Tournament lifecycle + +#### Create tournament + +``` +POST /api/tournament +``` + +Body: + +```json +{ + "name": "Friday Night Bots", + "rounds": 5, + "timeControl": { + "limitSeconds": 300, + "incrementSeconds": 3 + } +} +``` + +Response `201 Created`: + +```json +{ "id": "t7kXq2" } +``` + +The creator becomes the tournament director. Only the director can start and delete the tournament. + +--- + +#### Get tournament + +``` +GET /api/tournament/{tournamentId} +``` + +Response `200 OK`: `Tournament` object. + +--- + +#### List tournaments + +``` +GET /api/tournament +``` + +Query params: + +| Param | Type | Default | +|----------|---------------------------------|-----------| +| `status` | `created\|started\|finished` | (all) | +| `limit` | integer (max 50) | 20 | +| `offset` | integer | 0 | + +Response `200 OK`: + +```json +{ + "tournaments": [ /* Tournament[] */ ], + "total": 42 +} +``` + +--- + +#### Start tournament + +``` +POST /api/tournament/{tournamentId}/start +``` + +Requires at least 2 registered bots. Computes round 1 pairings (random for round 1; score-based from round 2). Creates one game per pairing via `POST /api/board/game`. + +Response `200 OK`: updated `Tournament` object. + +--- + +#### Delete tournament + +``` +DELETE /api/tournament/{tournamentId} +``` + +Only allowed while `status == "created"`. Response `204 No Content`. + +--- + +### Bot registration + +#### Register bot + +``` +POST /api/tournament/{tournamentId}/bots +``` + +Registers a bot for the tournament. Must be called before the tournament starts. +The token subject must match the bot being registered. + +Body: + +```json +{ "botId": "bot_abc" } +``` + +Response `200 OK`: + +```json +{ "botId": "bot_abc", "tournamentId": "t7kXq2" } +``` + +--- + +#### Unregister bot + +``` +DELETE /api/tournament/{tournamentId}/bots/{botId} +``` + +Only allowed while `status == "created"`. Response `204 No Content`. + +--- + +#### List registered bots + +``` +GET /api/tournament/{tournamentId}/bots +``` + +Response `200 OK`: + +```json +{ + "bots": [ + { "botId": "bot_abc", "botName": "StockfishClone" } + ] +} +``` + +--- + +### Standings and pairings + +#### Get standings + +``` +GET /api/tournament/{tournamentId}/standings +``` + +Response `200 OK`: + +```json +{ "standings": [ /* Standing[] */ ] } +``` + +--- + +#### Get pairings for a round + +``` +GET /api/tournament/{tournamentId}/rounds/{round}/pairings +``` + +Response `200 OK`: + +```json +{ "pairings": [ /* Pairing[] */ ] } +``` + +--- + +### Bot streaming + +#### Stream tournament events + +``` +GET /api/tournament/{tournamentId}/stream +``` + +Headers: `Accept: text/event-stream` + +Server-Sent Events stream scoped to this tournament. The bot receives `pairingReady` events when it is assigned a game, at which point it should connect to the existing bot game stream: + +``` +GET /bot/stream/game/{gameId} (existing endpoint) +POST /bot/game/{gameId}/move/{uci} (existing endpoint) +``` + +The tournament module never sends moves — bots do that themselves through the existing bot endpoints. + +--- + +## Typical bot flow + +``` +1. POST /api/tournament # director creates tournament +2. POST /api/tournament/{id}/bots # each bot registers +3. POST /api/tournament/{id}/start # director starts +4. GET /api/tournament/{id}/stream (SSE) # each bot opens stream + + -- per round -- +5. receive: pairingReady { gameId, color } +6. GET /bot/stream/game/{gameId} # existing endpoint +7. POST /bot/game/{gameId}/move/{uci} # existing endpoint, repeated + -- game ends -- + +8. receive: roundFinished +9. GET /api/tournament/{id}/standings # optional, inspect scores + -- repeat 5–9 for each round -- + +10. receive: tournamentFinished +11. GET /api/tournament/{id}/standings # final ranking +``` + +--- + +## Error responses + +| Status | Meaning | +|--------|------------------------------------------------------| +| 400 | Invalid request body or parameters | +| 401 | Missing or invalid JWT | +| 403 | Action not allowed (wrong director, wrong bot, etc.) | +| 404 | Tournament or bot not found | +| 409 | Tournament already started / bot already registered | diff --git a/docs/tournament-openapi.yaml b/docs/tournament-openapi.yaml new file mode 100644 index 0000000..4528e21 --- /dev/null +++ b/docs/tournament-openapi.yaml @@ -0,0 +1,623 @@ +openapi: 3.0.3 +info: + title: NowChess Tournament API + description: | + Swiss-system bot tournaments, modelled after the Lichess API style. + + Game moves flow through the existing board and bot endpoints — this module + handles pairings, standings, and lifecycle only. + + ## Streaming + Endpoints marked **NDJSON** return newline-delimited JSON objects + (`application/x-ndjson`). Each line is one complete JSON object. The + connection stays open until the tournament or round ends. + + ## Bot flow + ``` + POST /api/tournament # create + POST /api/tournament/{id}/join # each bot joins + POST /api/tournament/{id}/start # director starts + + GET /api/tournament/{id}/stream (NDJSON) # open before start + + -- per round -- + receive gameStart { gameId, color } + GET /bot/stream/game/{gameId} (existing, NDJSON) + POST /bot/game/{gameId}/move/{uci} (existing) + -- repeat -- + + GET /api/tournament/{id}/results (NDJSON) # final standings + ``` + version: 1.0.0 + +servers: + - url: https://st.nowchess.janis-eccarius.de + description: Staging + - url: https://nowchess.janis-eccarius.de + description: Production + - url: http://localhost:8086 + description: Local + +security: + - bearerAuth: [] + +tags: + - name: Tournament + description: Tournament lifecycle + - name: Participation + description: Join and withdraw + - name: Results + description: Standings, pairings, and game export + - name: Stream + description: NDJSON event streams + +paths: + + /api/tournament: + get: + tags: [Tournament] + summary: Get current tournaments + description: Returns tournaments grouped by status. No auth required. + security: [] + responses: + "200": + description: Tournaments by status + content: + application/json: + schema: + type: object + properties: + created: + type: array + items: + $ref: "#/components/schemas/TournamentInfo" + started: + type: array + items: + $ref: "#/components/schemas/TournamentInfo" + finished: + type: array + items: + $ref: "#/components/schemas/TournamentInfo" + + post: + tags: [Tournament] + summary: Create a new tournament + description: The authenticated user becomes the tournament director. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/CreateTournamentForm" + responses: + "201": + description: Tournament created + content: + application/json: + schema: + $ref: "#/components/schemas/Tournament" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + + /api/tournament/{id}: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Tournament] + summary: Get a tournament + description: Includes the first page of standings in the `standing` field. + security: [] + responses: + "200": + description: Tournament with embedded standings + content: + application/json: + schema: + $ref: "#/components/schemas/Tournament" + "404": + $ref: "#/components/responses/NotFound" + + delete: + tags: [Tournament] + summary: Terminate a tournament + description: Only the director may terminate. Only allowed while status is `created`. + responses: + "204": + description: Terminated + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/start: + parameters: + - $ref: "#/components/parameters/id" + + post: + tags: [Tournament] + summary: Start the tournament + description: | + Only the director may start. Requires at least 2 joined bots. + Computes round 1 pairings and creates games via `POST /api/board/game`. + responses: + "200": + description: Tournament started + content: + application/json: + schema: + $ref: "#/components/schemas/Tournament" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/join: + parameters: + - $ref: "#/components/parameters/id" + + post: + tags: [Participation] + summary: Join a tournament + description: | + Register the authenticated bot for the tournament. Only allowed while + status is `created`. The token subject must be a bot account. + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/Ok" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/withdraw: + parameters: + - $ref: "#/components/parameters/id" + + post: + tags: [Participation] + summary: Withdraw from a tournament + description: Only allowed while status is `created`. + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/Ok" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/results: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Results] + summary: Get results as NDJSON stream + description: | + Streams one `Result` object per line, sorted by rank ascending. + Available at any point during or after the tournament. + security: [] + parameters: + - name: nb + in: query + description: Max number of results to stream (default all) + schema: + type: integer + minimum: 1 + responses: + "200": + description: NDJSON stream of results + content: + application/x-ndjson: + schema: + $ref: "#/components/schemas/Result" + "404": + $ref: "#/components/responses/NotFound" + + /api/tournament/{id}/round/{round}: + parameters: + - $ref: "#/components/parameters/id" + - name: round + in: path + required: true + schema: + type: integer + minimum: 1 + + get: + tags: [Results] + summary: Get pairings for a round + security: [] + responses: + "200": + description: Pairings for the specified round + content: + application/json: + schema: + type: object + properties: + round: + type: integer + example: 2 + pairings: + type: array + items: + $ref: "#/components/schemas/Pairing" + "404": + $ref: "#/components/responses/NotFound" + + /api/tournament/{id}/export/games: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Results] + summary: Export all games + description: | + Returns all games of the tournament. Accepts both PGN and NDJSON via + the `Accept` header. + security: [] + parameters: + - name: Accept + in: header + schema: + type: string + enum: + - application/x-chess-pgn + - application/x-ndjson + default: application/x-chess-pgn + responses: + "200": + description: Games in the requested format + content: + application/x-chess-pgn: + schema: + type: string + description: Standard PGN, one game per block + application/x-ndjson: + schema: + $ref: "#/components/schemas/GameExport" + "404": + $ref: "#/components/responses/NotFound" + + /api/tournament/{id}/stream: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Stream] + summary: Stream tournament events + description: | + NDJSON stream scoped to one tournament. Keep this connection open for + the full tournament lifetime. + + On `gameStart` the bot connects to the existing bot endpoints: + - `GET /bot/stream/game/{gameId}` — stream game state (existing) + - `POST /bot/game/{gameId}/move/{uci}` — submit moves (existing) + responses: + "200": + description: NDJSON event stream + content: + application/x-ndjson: + schema: + $ref: "#/components/schemas/TournamentEvent" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + id: + name: id + in: path + required: true + schema: + type: string + example: t7kXq2 + + schemas: + + Clock: + type: object + required: [limit, increment] + properties: + limit: + type: integer + description: Base time in seconds + example: 300 + increment: + type: integer + description: Increment per move in seconds + example: 3 + + Variant: + type: object + properties: + key: + type: string + example: standard + name: + type: string + example: Standard + + BotRef: + type: object + properties: + id: + type: string + example: bot_abc + name: + type: string + example: StockfishClone + + Standing: + type: object + properties: + page: + type: integer + example: 1 + players: + type: array + items: + $ref: "#/components/schemas/Result" + + TournamentInfo: + description: Lightweight tournament summary used in list responses. + type: object + properties: + id: + type: string + example: t7kXq2 + fullName: + type: string + example: Friday Night Bots Swiss + clock: + $ref: "#/components/schemas/Clock" + variant: + $ref: "#/components/schemas/Variant" + rated: + type: boolean + example: true + nbPlayers: + type: integer + example: 8 + nbRounds: + type: integer + example: 5 + createdBy: + type: string + example: userId + startsAt: + type: string + format: date-time + + Tournament: + allOf: + - $ref: "#/components/schemas/TournamentInfo" + - type: object + properties: + status: + type: string + enum: [created, started, finished] + example: started + round: + type: integer + description: Current round number + example: 2 + standing: + $ref: "#/components/schemas/Standing" + winner: + description: Present only when status is `finished` + allOf: + - $ref: "#/components/schemas/BotRef" + nullable: true + + CreateTournamentForm: + type: object + required: [name, nbRounds, clockLimit, clockIncrement] + properties: + name: + type: string + example: Friday Night Bots + nbRounds: + type: integer + minimum: 1 + example: 5 + clockLimit: + type: integer + description: Base time in seconds + example: 300 + clockIncrement: + type: integer + description: Increment per move in seconds + example: 3 + rated: + type: boolean + default: true + + Result: + type: object + properties: + rank: + type: integer + example: 1 + points: + type: number + format: double + example: 3.5 + tieBreak: + type: number + format: double + description: Buchholz score (sum of opponents' points) + example: 9.0 + bot: + $ref: "#/components/schemas/BotRef" + nbGames: + type: integer + example: 4 + wins: + type: integer + example: 3 + draws: + type: integer + example: 1 + losses: + type: integer + example: 0 + + Pairing: + type: object + properties: + round: + type: integer + example: 2 + white: + $ref: "#/components/schemas/BotRef" + black: + $ref: "#/components/schemas/BotRef" + gameId: + type: string + example: j0nPtcjl + winner: + type: string + enum: [white, black, draw] + nullable: true + description: Null while the game is ongoing + + GameExport: + description: One game object per NDJSON line. + type: object + properties: + id: + type: string + example: j0nPtcjl + round: + type: integer + example: 2 + white: + $ref: "#/components/schemas/BotRef" + black: + $ref: "#/components/schemas/BotRef" + winner: + type: string + enum: [white, black, draw] + nullable: true + moves: + type: string + description: Space-separated UCI moves + example: e2e4 e7e5 g1f3 + + TournamentEvent: + description: | + One JSON object per NDJSON line. Discriminate on `type`. + + | type | extra fields | + |------|-------------| + | `tournamentStarted` | — | + | `roundStarted` | `round` | + | `gameStart` | `round`, `gameId`, `color` | + | `roundFinished` | `round` | + | `tournamentFinished` | `winner` | + type: object + required: [type] + properties: + type: + type: string + enum: + - tournamentStarted + - roundStarted + - gameStart + - roundFinished + - tournamentFinished + round: + type: integer + example: 2 + gameId: + type: string + example: j0nPtcjl + color: + type: string + enum: [white, black] + winner: + $ref: "#/components/schemas/BotRef" + + Ok: + type: object + properties: + ok: + type: boolean + example: true + + Error: + type: object + properties: + error: + type: string + example: tournament already started + + responses: + BadRequest: + description: Invalid request body or parameters + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: Missing or invalid JWT + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Forbidden: + description: Action not permitted for this user or bot + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: Tournament not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Conflict: + description: Conflicting state (e.g. already started, bot already joined) + content: + application/json: + schema: + $ref: "#/components/schemas/Error"