openapi: 3.1.0
info:
  title: Warrium API
  version: 2.8.0
  description: |
    Canonical contract for Warrium Game 001. The game ID is always carried in the
    path. Authenticated clients join explicitly, receive a short-lived SSE ticket,
    and submit idempotent commands against an expected state sequence.
servers:
  - url: http://localhost:4000
tags:
  - name: Health
    description: Runtime and required dependency readiness
  - name: Games
    description: Game 001 discovery and membership
  - name: Actions
    description: Idempotent, sequence-checked game commands
  - name: Maps
    description: Shared SVG-independent map logic
  - name: Streaming
    description: Server-sent state and lobby feedback
security:
  - BearerAuth: []
paths:
  /api/health:
    get:
      summary: Check Game 001 service readiness
      tags: [Health]
      security: []
      operationId: health
      responses:
        "200":
          description: Process and database are ready to serve traffic
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
        "503":
          description: Process is running but a required dependency is unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
  /api/games:
    get:
      summary: List open Game 001 matches
      tags: [Games]
      security: []
      operationId: listGames
      responses:
        "200":
          description: Open games
          content:
            application/json:
              schema:
                type: object
                required: [games]
                properties:
                  games:
                    type: array
                    items:
                      $ref: "#/components/schemas/GameSummary"
    post:
      summary: Create a Game 001 match
      tags: [Games]
      operationId: createGame
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateGameRequest"
      responses:
        "200":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [success, gameId]
                properties:
                  success: {type: boolean, const: true}
                  gameId: {type: string, format: uuid}
        "400":
          $ref: "#/components/responses/CommandError"
        "409":
          description: The commandId was already used for different create parameters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/CommandError"
  /api/games/mine:
    get:
      summary: List the caller's current Game 001 memberships
      description: |
        Returns pending and active games in which the authenticated user has an
        active seat. This is the durable, cross-device source for resume links;
        browser-local join history is not authoritative.
      tags: [Games]
      operationId: listMyGames
      responses:
        "200":
          description: Current memberships
          content:
            application/json:
              schema:
                type: object
                required: [games]
                properties:
                  games:
                    type: array
                    items:
                      $ref: "#/components/schemas/MembershipSummary"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/CommandError"
  /api/games/{gameId}/join:
    post:
      summary: Join or reconnect to a game
      tags: [Games]
      operationId: joinGame
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      responses:
        "200":
          description: |
            Joined; ticket is valid for 120 seconds. Idempotent: an existing
            participant is returned their original seat and a fresh ticket, which
            is how a player resumes a game already in progress.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JoinResponse"
        "400":
          $ref: "#/components/responses/CommandError"
        "404":
          description: No such game (GAME_NOT_FOUND)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: |
            The game exists but cannot be joined: it is full or no longer
            pending (JOIN_FAILED), or its category excludes this account kind
            (CATEGORY_RESTRICTED).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: The credential is scoped to a different game
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/CommandError"
  /api/games/{gameId}/membership:
    delete:
      summary: Leave a pending game
      description: |
        Idempotently removes the caller's seat while the game is pending. A
        committed leave frees the seat for another player. Active and terminal
        game membership is durable and cannot be deleted (LEAVE_NOT_ALLOWED).
      tags: [Games]
      operationId: leavePendingGame
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      responses:
        "200":
          description: Membership is absent
          content:
            application/json:
              schema:
                type: object
                required: [success, left]
                properties:
                  success: {type: boolean, const: true}
                  left:
                    type: boolean
                    description: True when this request removed an active seat; false on an idempotent replay.
        "404":
          $ref: "#/components/responses/CommandError"
        "409":
          description: The game is no longer pending (LEAVE_NOT_ALLOWED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/CommandError"
  /api/games/{gameId}/state:
    get:
      summary: Read the current committed game state
      tags: [Games]
      operationId: getGameState
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      responses:
        "200":
          description: Current committed state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GameState"
        "400":
          $ref: "#/components/responses/CommandError"
        "404":
          $ref: "#/components/responses/CommandError"
        "403":
          $ref: "#/components/responses/CommandError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/CommandError"
  /api/games/{gameId}/draft:
    post:
      summary: Place draft armies
      tags: [Actions]
      operationId: draft
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      requestBody:
        $ref: "#/components/requestBodies/Draft"
      responses:
        "200": {$ref: "#/components/responses/CommandSuccess"}
        "400": {$ref: "#/components/responses/CommandError"}
        "403": {$ref: "#/components/responses/CommandError"}
        "404": {$ref: "#/components/responses/CommandError"}
        "409": {$ref: "#/components/responses/CommandError"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/CommandError"}
  /api/games/{gameId}/attack:
    post:
      summary: Attack an adjacent territory
      tags: [Actions]
      operationId: attack
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      requestBody:
        $ref: "#/components/requestBodies/Attack"
      responses:
        "200": {$ref: "#/components/responses/CommandSuccess"}
        "400": {$ref: "#/components/responses/CommandError"}
        "403": {$ref: "#/components/responses/CommandError"}
        "404": {$ref: "#/components/responses/CommandError"}
        "409": {$ref: "#/components/responses/CommandError"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/CommandError"}
  /api/games/{gameId}/transfer:
    post:
      summary: Resolve a post-conquest transfer
      tags: [Actions]
      operationId: transfer
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      requestBody:
        $ref: "#/components/requestBodies/Transfer"
      responses:
        "200": {$ref: "#/components/responses/CommandSuccess"}
        "400": {$ref: "#/components/responses/CommandError"}
        "403": {$ref: "#/components/responses/CommandError"}
        "404": {$ref: "#/components/responses/CommandError"}
        "409": {$ref: "#/components/responses/CommandError"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/CommandError"}
  /api/games/{gameId}/end-attack:
    post:
      summary: End the attack phase
      tags: [Actions]
      operationId: endAttack
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      requestBody:
        $ref: "#/components/requestBodies/Command"
      responses:
        "200": {$ref: "#/components/responses/CommandSuccess"}
        "400": {$ref: "#/components/responses/CommandError"}
        "403": {$ref: "#/components/responses/CommandError"}
        "404": {$ref: "#/components/responses/CommandError"}
        "409": {$ref: "#/components/responses/CommandError"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/CommandError"}
  /api/games/{gameId}/reinforce:
    post:
      summary: Move armies during reinforcement
      tags: [Actions]
      operationId: reinforce
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      requestBody:
        $ref: "#/components/requestBodies/Transfer"
      responses:
        "200": {$ref: "#/components/responses/CommandSuccess"}
        "400": {$ref: "#/components/responses/CommandError"}
        "403": {$ref: "#/components/responses/CommandError"}
        "404": {$ref: "#/components/responses/CommandError"}
        "409": {$ref: "#/components/responses/CommandError"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/CommandError"}
  /api/games/{gameId}/end-turn:
    post:
      summary: End the current turn
      tags: [Actions]
      operationId: endTurn
      security:
        - BearerAuth: []
        - BotGameCredential: []
      parameters:
        - $ref: "#/components/parameters/GameId"
      requestBody:
        $ref: "#/components/requestBodies/Command"
      responses:
        "200": {$ref: "#/components/responses/CommandSuccess"}
        "400": {$ref: "#/components/responses/CommandError"}
        "403": {$ref: "#/components/responses/CommandError"}
        "404": {$ref: "#/components/responses/CommandError"}
        "409": {$ref: "#/components/responses/CommandError"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/CommandError"}
  /api/dev/llm-log:
    post:
      summary: Record one bot decision for its occupied Game 001 seat
      tags: [Actions]
      operationId: createLlmDecisionLog
      security:
        - BotGameCredential: []
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/LlmLogRequest"}
      responses:
        "200":
          description: Decision log accepted
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [ok]
                properties:
                  ok: {type: boolean, const: true}
        "400": {$ref: "#/components/responses/CommandError"}
        "401": {$ref: "#/components/responses/CommandError"}
        "403": {$ref: "#/components/responses/CommandError"}
        "413": {$ref: "#/components/responses/CommandError"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/CommandError"}
  /api/maps:
    get:
      summary: List available Game 001 maps
      tags: [Maps]
      security: []
      operationId: listMaps
      responses:
        "200":
          description: Available map slugs
          content:
            application/json:
              schema:
                type: object
                required: [maps]
                properties:
                  maps:
                    type: array
                    items: {type: string}
  /api/maps/{slug}:
    get:
      summary: Read typed map logic
      tags: [Maps]
      security: []
      operationId: getMap
      parameters:
        - name: slug
          in: path
          required: true
          schema: {type: string}
      responses:
        "200":
          description: Typed map logic
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MapData"
        "404":
          $ref: "#/components/responses/CommandError"
  /games/{gameId}/events:
    get:
      summary: Stream committed game states
      tags: [Streaming]
      security: []
      operationId: streamGame
      parameters:
        - $ref: "#/components/parameters/GameId"
        - name: ticket
          in: query
          description: Short-lived ticket returned by join; required for players
          schema: {type: string}
        - name: observe
          in: query
          description: |
            Request the public observer stream. Any of `1`, `true`, or `yes` is
            accepted; `1` is canonical.
          schema: {type: string, enum: ["1", "true", "yes"]}
      responses:
        "200":
          description: |
            SSE stream. Sends one `identity` event, then a `gamestate` event per
            committed transition. Each gamestate carries its database sequence as
            both the SSE id and `seq`, and sequences are monotonic: a subscriber
            may receive the current snapshot before earlier events and must drop
            any frame whose `seq` is not greater than the last one applied.

            Event payloads:
              - `identity` -> StreamIdentity
              - `gamestate` -> GameState
          content:
            text/event-stream:
              schema:
                description: |
                  Each frame's `data:` line carries one of these payloads,
                  selected by the frame's `event:` name: `identity` ->
                  StreamIdentity, `gamestate` -> GameState.
                oneOf:
                  - $ref: "#/components/schemas/StreamIdentity"
                  - $ref: "#/components/schemas/GameState"
        "401":
          description: Invalid or expired player ticket
        "404":
          description: Game process is not present
        "429":
          description: Per-client attempt or active-stream limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          $ref: "#/components/responses/CommandError"
  /lobby:
    get:
      summary: Stream the open-game lobby
      tags: [Streaming]
      security: []
      operationId: streamLobby
      responses:
        "200":
          description: Public lobby SSE stream
        "429":
          description: Per-client attempt or active-stream limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: Service-wide active lobby-stream limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
webhooks:
  botInvite:
    post:
      summary: Invite one stable bot identity to a standing Game 001 match
      description: |
        The platform posts this callback to a configured machine runner. Retries
        for the same `invitation_id` reuse the exact payload and Idempotency-Key.
        A runner may receive several distinct bot identities for the same game.
        An exact replay remains accepted after completion without starting a
        second session. One bot identity may have only one active invitation.
        The callback bearer is a runner credential and is distinct
        from the game-scoped credential in the request body.
      security:
        - MachineCallbackAuth: []
      operationId: inviteBot
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Must equal the invitation_id UUID in the request body
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BotInviteRequest"
      responses:
        "202":
          description: Invite durably accepted, or an exact active/completed replay
        "400":
          description: Malformed invite
        "401":
          description: Missing or invalid machine callback credential
        "409":
          description: The invitation_id has a different payload, or the bot_id already has another active invitation
        "503":
          description: Runner lacks capacity or cannot durably accept the invite
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: Human platform session; never a bot-game credential
    BotGameCredential:
      type: http
      scheme: bearer
      description: |
        Revocable opaque credential bound to one bot identity and one game.
        It authorizes only that game's join, state, commands, telemetry for the
        bot's occupied slot, and the issuance of a short-lived player SSE ticket.
    MachineCallbackAuth:
      type: http
      scheme: bearer
      description: Credential authenticating platform-to-runner invite callbacks
  parameters:
    GameId:
      name: gameId
      in: path
      required: true
      schema: {type: string, format: uuid}
  requestBodies:
    Command:
      required: true
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Command"
    Draft:
      required: true
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/DraftCommand"
    Attack:
      required: true
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AttackCommand"
    Transfer:
      required: true
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/TransferCommand"
  responses:
    CommandSuccess:
      description: The command was durably committed, or this is its cached result
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/CommandResponse"
    CommandError:
      description: Command or request rejected
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    RateLimited:
      description: Request rate limit exceeded (RATE_LIMITED)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
  schemas:
    LlmLogRequest:
      type: object
      additionalProperties: false
      required: [game_id, bot_slot, model, turn_phase, candidate_count, prompt]
      properties:
        game_id: {type: string, format: uuid}
        bot_slot:
          type: string
          minLength: 1
          maxLength: 4
          description: Limited to 4 UTF-8 bytes by the server.
        model:
          type: string
          minLength: 1
          maxLength: 200
          description: Limited to 200 UTF-8 bytes by the server.
        turn_phase: {type: string, enum: [draft, attack, reinforce]}
        candidate_count: {type: integer, minimum: 0, maximum: 10000}
        prompt:
          type: string
          minLength: 1
          maxLength: 65536
          description: Limited to 65,536 UTF-8 bytes by the server.
        raw_response:
          type: [string, "null"]
          maxLength: 262144
          description: Limited to 262,144 UTF-8 bytes by the server.
        reasoning:
          type: [string, "null"]
          maxLength: 262144
          description: Limited to 262,144 UTF-8 bytes by the server.
        ranked_indices:
          type: [array, "null"]
          maxItems: 10000
          items: {type: integer, minimum: 0, maximum: 9999}
        chosen_idx: {type: [integer, "null"], minimum: 0, maximum: 9999}
        chosen_summary:
          type: [string, "null"]
          maxLength: 16384
          description: Limited to 16,384 UTF-8 bytes by the server.
        heuristic_top:
          type: [string, "null"]
          maxLength: 16384
          description: Limited to 16,384 UTF-8 bytes by the server.
        agreed: {type: boolean, default: true}
        error:
          type: [string, "null"]
          maxLength: 4096
          description: Limited to 4,096 UTF-8 bytes by the server.
        latency_ms: {type: [integer, "null"], minimum: 0}
        input_tokens: {type: [integer, "null"], minimum: 0}
        output_tokens: {type: [integer, "null"], minimum: 0}
    BotInviteRequest:
      type: object
      additionalProperties: false
      required: [invitation_id, bot_id, game_id, base_url, map_slug, token]
      properties:
        invitation_id:
          type: string
          format: uuid
          description: Idempotency identity for this exact invite payload
        bot_id:
          type: string
          format: uuid
          description: Stable bot user identity
        game_id: {type: string, format: uuid}
        base_url: {type: string, format: uri}
        map_slug: {type: string, minLength: 1}
        token:
          type: string
          minLength: 1
          description: |
            Revocable bearer credential scoped to this bot identity and game.
            A separately identified replacement invite invalidates the prior credential.
        groq_model: {type: [string, "null"]}
        strategy: {type: [string, "null"]}
    HealthResponse:
      type: object
      required: [status, service, database, runtime]
      properties:
        status: {type: string, enum: [ok, degraded]}
        service: {type: string, const: warrium}
        database: {type: string, enum: [ok, unavailable]}
        runtime:
          type: string
          enum: [ok, recovering]
          description: |
            `ok` only after every durable pending or active game has been
            reconstructed and its actor is supervising lifecycle and turn deadlines.
    Command:
      type: object
      additionalProperties: false
      required: [commandId, expectedSeq]
      properties:
        commandId:
          type: string
          format: uuid
          description: Stable across retries of this logical command
        expectedSeq:
          type: integer
          minimum: 0
    DraftCommand:
      type: object
      additionalProperties: false
      required: [commandId, expectedSeq, territoryId, count]
      properties:
        commandId: {type: string, format: uuid}
        expectedSeq: {type: integer, minimum: 0}
        territoryId: {type: string, minLength: 1}
        count: {type: integer, minimum: 1}
    AttackCommand:
      type: object
      additionalProperties: false
      required: [commandId, expectedSeq, fromId, toId]
      properties:
        commandId: {type: string, format: uuid}
        expectedSeq: {type: integer, minimum: 0}
        fromId: {type: string, minLength: 1}
        toId: {type: string, minLength: 1}
    TransferCommand:
      type: object
      additionalProperties: false
      required: [commandId, expectedSeq, fromId, toId, count]
      properties:
        commandId: {type: string, format: uuid}
        expectedSeq: {type: integer, minimum: 0}
        fromId: {type: string, minLength: 1}
        toId: {type: string, minLength: 1}
        count: {type: integer, minimum: 1}
    CommandResponse:
      type: object
      required: [success, commandId, seq, message]
      properties:
        success: {type: boolean, const: true}
        commandId: {type: string, format: uuid}
        seq: {type: integer, minimum: 1}
        message: {type: string}
    ErrorResponse:
      type: object
      required: [success, code, error]
      properties:
        success: {type: boolean, const: false}
        code:
          type: string
          description: |
            Stable machine-readable rejection category. Clients should branch on
            this, never on `error`, which is human-facing and may be reworded.

            Transport and request codes:
              - INVALID_INPUT       malformed or out-of-range field, including a malformed
                                    create-game commandId
              - INVALID_COMMAND     action commandId is not a UUID, or expectedSeq is negative
              - VALIDATION_FAILED   field-level validation; detail under `fields`
              - UNAUTHORIZED        missing, invalid, or non-participant credential
              - INVALID_TOKEN       expired or malformed stream ticket
              - RATE_LIMITED        too many requests for this route
              - GAME_NOT_FOUND      no such game, or its process is absent
              - MAP_NOT_FOUND       no such map slug
              - JOIN_FAILED         game is full or otherwise unjoinable
              - CATEGORY_RESTRICTED account kind is excluded by the game category
              - LEAVE_NOT_ALLOWED   membership cannot be deleted after a game starts
              - STALE_STATE         expectedSeq did not match; `seq` carries the current value
              - CREATE_COMMAND_CONFLICT a create commandId was reused with different parameters
              - LIVE_GAME_LIMIT_REACHED the caller already owns the configured number of live games
              - SERVICE_CAPACITY_REACHED the service-wide live-game budget is full
              - STREAM_CAPACITY_REACHED the per-client or service-wide active stream budget is full
              - STORAGE_UNAVAILABLE durable storage is unreachable; retry the same commandId

            Rule rejections, all 400, safe to surface directly to a player:
              - NOT_YOUR_TURN, INVALID_PHASE, NOT_OWNER, NOT_ADJACENT, NO_PATH,
                SELF_ATTACK, INVALID_TERRITORY, INVALID_COUNT, INSUFFICIENT_UNITS,
                PLAYER_NOT_FOUND, PENDING_TRANSFER, NO_PENDING_TRANSFER

            Rule-specific failures may add codes without changing the envelope, so
            treat an unrecognized code as a generic rejection rather than an error.
        error:
          description: Human-readable explanation. Not stable; do not branch on it.
          type: string
        fields:
          description: |
            Per-field validation messages. Present only with VALIDATION_FAILED.
          type: object
          additionalProperties:
            type: array
            items: {type: string}
        seq:
          description: |
            The command sequence the server actually holds. Present with
            STALE_STATE so a client can resubmit against the current value.
          type: integer
          minimum: 0
    CreateGameRequest:
      type: object
      additionalProperties: false
      properties:
        commandId:
          description: |
            Client-generated idempotency key. Retry the same create intent with
            the same commandId. Reusing it with different parameters is a conflict.
            The server guarantees replay/conflict semantics for at least 30 minutes
            after the first accepted request. After that minimum window, a client
            must treat an unresolved intent as expired and generate a new commandId;
            the server may retain the original result for longer.
            It is optional only for one N-1 browser compatibility window; requests
            without it are accepted but cannot be safely replayed after response loss.
          type: string
          format: uuid
        mapSlug:
          type: string
          default: world5
          description: Omit to use the default; explicit null is invalid.
        maxPlayers:
          type: integer
          minimum: 2
          maximum: 6
          default: 2
          description: Omit to use the default; explicit null is invalid.
        category:
          $ref: "#/components/schemas/GameCategory"
    JoinResponse:
      type: object
      required: [success, identity, ticket, expiresIn]
      properties:
        success: {type: boolean, const: true}
        identity:
          $ref: "#/components/schemas/JoinIdentity"
        ticket: {type: string}
        expiresIn: {type: integer, const: 120}
    JoinIdentity:
      description: |
        Seat assignment returned by join. Carries the seat's presentation
        details; it does not carry `role`, because joining is what makes the
        caller a player.
      type: object
      required: [yourId, color, colorHex, name]
      properties:
        yourId: {type: string}
        color: {type: string}
        colorHex: {type: string}
        name: {type: string}
    StreamIdentity:
      description: |
        First event on a game stream, telling the subscriber which seat the
        stream is authenticated for. Observers receive `yourId: observer`.
        Presentation details are not repeated here; they are in `players`.
      type: object
      required: [yourId, role]
      properties:
        yourId: {type: string}
        role: {type: string, enum: [player, observer]}
    GameSummary:
      type: object
      required: [id, map, mapVersion, rulesetVersion, category, players, playerSeats, maxPlayers, status, createdAt]
      properties:
        id: {type: string, format: uuid}
        map: {type: string}
        mapVersion: {type: string, minLength: 1}
        rulesetVersion: {type: string, minLength: 1}
        category:
          $ref: "#/components/schemas/GameCategory"
        players: {type: integer}
        playerNames:
          description: Deprecated compatibility projection of playerSeats.name.
          type: array
          items: {type: string}
        playerSeats:
          type: array
          items:
            $ref: "#/components/schemas/PlayerSeat"
        maxPlayers: {type: integer}
        status: {type: string, enum: [pending, active]}
        createdAt: {type: string, format: date-time}
    MembershipSummary:
      allOf:
        - $ref: "#/components/schemas/GameSummary"
        - type: object
          required: [slotId, canLeave]
          properties:
            slotId: {type: string}
            canLeave:
              type: boolean
              description: True only while the game remains pending.
    GameCategory:
      type: string
      enum: [open, humans, bots]
      default: open
      description: |
        `open` accepts human and bot accounts, `humans` accepts only human
        accounts, and `bots` accepts only bot accounts. Observation is public
        and is never restricted by category.
    PlayerSeat:
      type: object
      required: [slotId, name, isBot]
      properties:
        slotId: {type: string}
        name:
          type: string
          description: Callsign snapshotted when this seat was joined.
        isBot: {type: boolean}
    GameState:
      type: object
      required:
        - seq
        - turnId
        - status
        - mapSlug
        - mapVersion
        - rulesetVersion
        - players
        - territories
        - turnOrder
        - turnPhase
        - turnPlayerId
        - message
        - gameOver
      properties:
        seq: {type: integer, minimum: 0}
        turnId: {type: integer, minimum: 0}
        status:
          type: string
          enum: [pending, active, completed, expired, abandoned]
          description: |
            Authoritative lifecycle state. `expired` means a pending game reached
            30 minutes from creation. `abandoned` means an active game reached 30
            minutes without an accepted participant command; observer traffic and
            server-authored timeout transitions do not reset that interval.
        mapSlug: {type: string}
        mapVersion: {type: string, minLength: 1}
        rulesetVersion: {type: string, minLength: 1}
        turnPlayerId: {type: string}
        turnExpiresAt:
          description: |
            Deadline for the current turn. Standard Game 001 turns last 60 seconds
            across Draft, Attack, and Reinforce; changing phase does not reset the
            deadline. Absent when no turn is running (before start, or after the
            game is over); never an empty string.
          type: string
          format: date-time
        turnPhase: {type: string, enum: [draft, attack, reinforce]}
        message: {type: string}
        players:
          type: object
          additionalProperties: {$ref: "#/components/schemas/Player"}
        territories:
          type: object
          additionalProperties: {$ref: "#/components/schemas/TerritoryState"}
        turnOrder:
          description: |
            Seat ids in turn order, including eliminated seats. Authoritative for
            rendering seating and for determining who acts next.
          type: array
          items: {type: string}
        pendingAction:
          $ref: "#/components/schemas/PendingAction"
        lastAction:
          $ref: "#/components/schemas/LastAction"
        lastParticipantActivityAt:
          description: |
            Server timestamp of the latest accepted human or bot command. Absent
            until the first participant command commits; joins, observers, rejected
            commands, and server-authored timeout transitions do not set it.
          type: string
          format: date-time
        terminalAt:
          description: |
            Server timestamp at which the game entered `completed`, `expired`, or
            `abandoned`. Absent while the game is pending or active.
          type: string
          format: date-time
        gameOver:
          description: True for every terminal lifecycle state, whether or not there is a winner.
          type: boolean
        winnerId:
          description: Present only when `status` is `completed`; expired and abandoned games have no winner.
          type: string
    Player:
      type: object
      required: [id, name, color, colorHex, unplacedArmies, armies, isActive, isBot]
      properties:
        id: {type: string}
        name: {type: string}
        color: {type: string}
        colorHex: {type: string}
        unplacedArmies: {type: integer}
        armies:
          type: integer
          minimum: 0
          description: Total deployed units on territories owned by this player.
        isActive:
          type: boolean
          description: True before play starts, or while the player owns at least one territory after start.
        isBot: {type: boolean}
    TerritoryState:
      type: object
      required: [id, ownerId, numUnits]
      properties:
        id: {type: string}
        ownerId: {type: string}
        numUnits: {type: integer, minimum: 0}
    PendingAction:
      description: |
        What the server is waiting for. Clients should drive their prompts and
        legal-move affordances from this rather than inferring intent from board
        state; `transfer` in particular reports the conquered pair in
        `fromId`/`toId`.
      type: object
      required: [playerId, action, since]
      properties:
        playerId: {type: string}
        action:
          type: string
          enum: [draft, attack_or_end, transfer, reinforce_or_end]
        since: {type: string, format: date-time}
        fromId: {type: string}
        toId: {type: string}
    LastAction:
      description: |
        The most recently committed action, for feedback and observer rendering.
        `data` is action-specific and open by design.
      type: object
      required: [action, playerId, timestamp, data]
      properties:
        action: {type: string}
        playerId: {type: string}
        timestamp: {type: string, format: date-time}
        data: {type: object, additionalProperties: true}
    MapData:
      type: object
      required: [slug, version, name, territories, adjacencies, continents]
      properties:
        slug: {type: string}
        version: {type: string, minLength: 1}
        name: {type: string}
        territories:
          description: |
            Keyed by territory id, which must equal the id of the corresponding
            path in the render SVG. `center` is authored once and cached; clients
            place unit markers with it and must not recompute geometry.
          type: object
          additionalProperties:
            $ref: "#/components/schemas/Territory"
        adjacencies:
          type: array
          items:
            type: object
            required: [from, to, type, bidirectional]
            properties:
              from: {type: string}
              to: {type: string}
              type:
                description: |
                  Edge class. Game 001 rules act on `land` and `sea`; other
                  classes are reserved for future mechanics and must not be
                  treated as attackable by a Game 001 client or bot.
                type: string
                enum: [land, sea, air, teleport, range, visibility]
              bidirectional: {type: boolean}
              cost: {type: number}
        continents:
          type: array
          items:
            type: object
            required: [id, name, bonus, territoryIds]
            properties:
              id: {type: string}
              name: {type: string}
              bonus: {type: integer}
              territoryIds:
                type: array
                items: {type: string}
    Territory:
      type: object
      required: [id, name, continentId, center]
      properties:
        id: {type: string}
        name: {type: string}
        continentId: {type: string}
        center:
          type: object
          required: [x, y]
          properties:
            x: {type: number}
            y: {type: number}
        terrain: {type: string, enum: [land, sea, air, space]}
