openapi: 3.0.3
info:
  title: Crux Game Backend API
  version: 1.0.0
  contact:
    name: Crux Support
    url: https://crux.supercraft.host
  description: |
    Crux (GSB) is a multi-tenant backend-as-a-service for games: identity,
    persistent player documents, seasonal leaderboards, economy (currencies /
    virtual items / inventory), matchmaking, and social.

    ## Authentication

    Crux uses five credentials. The `Authorization` header carries a scheme
    prefix (`ApiKey` / `ServerToken` / `Bearer`); the dashboard uses a cookie.

    | Credential | Header / cookie | Used for |
    |---|---|---|
    | **Publishable API key** | `Authorization: ApiKey <publishable_key>` | Safe to ship in a game client. It can only bootstrap player sessions via `/auth/*`. |
    | **Secret API key** | `Authorization: ApiKey <secret_key>` | Server-side/admin calls: project config, keys, economy setup, leaderboard admin, and player data. Never ship it in a game client. |
    | **Server token** | `Authorization: ServerToken <server_token>` | Trusted game servers acting on behalf of any player (documents, scores, economy). |
    | **Player token** | `Authorization: Bearer <access_token>` | A single player's own runtime calls. This JWT is returned by `/auth/*`. |
    | **Dashboard session** | `crux_session` cookie **+** `x-csrf` header | Interactive dashboard use. The `x-csrf` header must equal the `csrf` claim in the session JWT. Anywhere an API key is accepted, a session+CSRF pair works too. |

    API keys and server tokens are opaque strings. You never see them again
    after they are issued, so store them securely.

    ## Making your first call (anonymous player, ~3 steps)

    1. **Get credentials.** Create a project in the dashboard, or `POST /projects`
       with your session. The response's `bootstrap_credentials.publishable_key`
       is the key safe to pass to a player SDK; keep `secret_key` server-side.
       Both are scoped to that one project. Note the project's default
       environment id from `GET /projects/{project_id}/environments`.
    2. **Mint a player token.** Call `POST /auth/anonymous` with
       `Authorization: ApiKey <api_key>` and a client-generated `anonymous_id`.
       You get back a player `access_token` (JWT) and a `refresh_token`.
    3. **Call the runtime.** Use `Authorization: Bearer <access_token>` to read
       and write that player's documents, submit leaderboard scores, and read
       their economy under
       `/projects/{project_id}/environments/{environment_id}/...`.

    Registered (email) players use `POST /auth/register` then `POST /auth/login`
    instead of step 2; both return the same token pair. Rotate an expiring
    access token with `POST /auth/refresh`.

servers:
  - url: https://crux.supercraft.host/v1
    description: Production
  - url: http://localhost:8080/v1
    description: Local development

tags:
  - name: Webhooks
    description: >
      Outbound signed webhooks. Crux does not execute your code - it calls YOUR service
      when a subscribed event happens, with an HMAC-SHA256 signature so you can trust the
      caller. This is the migration path for PlayFab CloudScript and Nakama RPCs: the
      logic stays yours, only the trigger moves.
  - name: Player Auth
    description: Mint and refresh player tokens. Bootstrapped with an API key.
  - name: Projects
    description: Projects and their environments (dashboard session).
  - name: API Keys
    description: >
      Issue and revoke API keys. Keys have a KIND. A `publishable` key is safe to
      embed in a shipped game client: it authenticates players via /auth/* and
      cannot change project configuration or address a player directly. A
      `secret` key is server-side only and keeps full control-plane reach.
      Creating a key defaults to publishable, so the careless path is the safe
      one. Creating a key at all requires a dashboard session.
  - name: Player Documents
    description: Per-player persistent JSON documents (runtime).
  - name: Leaderboards
    description: Seasonal leaderboards, score submission, and rankings.
  - name: Economy
    description: >
      Currencies, virtual items, and per-player balances / inventory. Reading a player's
      balances takes any runtime credential; ADJUSTING THEM REQUIRES A SERVER TOKEN, so a
      game client cannot mint its own currency (a player token gets 403).
  - name: Stats
    description: >
      Named 64-bit counters per player - kills, distance, matches won. Reading takes any
      runtime credential; WRITING REQUIRES A SERVER TOKEN, so a game client cannot inflate
      its own progression (a player token gets 403). A write settles every achievement bound
      to that stat in the same transaction and returns what was unlocked, so a reward is
      granted exactly once even if the client retries.
  - name: Achievements
    description: >
      The achievement catalogue and per-player unlocks. Bind one to a stat key and a
      threshold and it settles automatically on the write that crosses it. Reading takes any
      runtime credential; unlocking REQUIRES A SERVER TOKEN. Unlocking is idempotent -
      the response reports whether this call was the one that granted it.
  - name: Social
    description: >
      Per-player friend graph and blocks. PLAYER TOKEN ONLY: the handlers identify the
      player from the token, not from the URL, and only a player token carries one - a
      server token gets 401. The {player_id} in the path is required but IGNORED for
      identity, so passing another player's id silently operates on your own.
  - name: Matchmaking
    description: >
      Queue by game mode + region, then poll for a match. A formed legacy match is placed on the
      emptiest live server in its region and game mode, when one is heartbeating. Supplying a
      ready `runtime_build_id` opts the queue into an ephemeral authoritative Runtime session.
      The queue endpoints are PLAYER TOKEN ONLY, for the same reason as Social: the player
      comes from the token, so a server token gets 401 and any `player_id` in the request
      body is ignored. The two lifecycle endpoints are the exact opposite - they are keyed
      on the match and require a SERVER token. A match nobody starts is swept to `expired`
      after five minutes so its players are not pinned to it forever.
  - name: Matchmaking Admin
    description: Inspect and steer the queue and formed matches (dashboard / API key).
  - name: Servers
    description: >
      Dedicated-server registry - register, heartbeat, deregister, and browse. Every endpoint
      here REQUIRES A SERVER TOKEN, including the browse call. Liveness is the heartbeat: a
      server that stops heartbeating drops out of the browse results and stops being a
      matchmaking destination within about 30 seconds.
  - name: Runtime
    description: >
      Crux Runtime control plane. v0 accepts a Godot 4 Linux dedicated-server ZIP,
      creates immutable builds, and records ephemeral session requests in the EU region.
      This surface requires a dashboard session or secret API key; it never executes an
      uploaded artifact inside the API process. Execution belongs to the isolated Runtime
      Agent. A running process receives a separate short-lived RuntimeSession credential
      that can read its own context and submit its own trusted match result only.
  - name: Live Config
    description: Versioned config bundles, activated per environment.
  - name: Generators
    description: World generators - a stored spec, rendered on demand. Renders are never stored.
  - name: Project Documents
    description: >
      Project-wide JSON documents, shared by every player in an environment.
      RESERVED NAMESPACE: any key starting with `server:` is readable and writable
      ONLY with a server token - a player token gets 403. Use it for data players
      must not see, such as loot tables or anti-cheat thresholds.
  - name: Server Tokens
    description: Issue and revoke the tokens that authenticate trusted game servers.
  - name: Player Moderation
    description: Player status, bans, and unbans (dashboard / API key).
  - name: Project Admin
    description: Usage summary, audit log, and admin views over player data.
  - name: Service
    description: Unauthenticated service endpoints.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: Authorization
      description: 'Send `ApiKey <api_key>` (note the literal `ApiKey ` prefix).'
    ServerTokenAuth:
      type: apiKey
      in: header
      name: Authorization
      description: 'Send `ServerToken <server_token>`.'
    RuntimeSessionAuth:
      type: apiKey
      in: header
      name: Authorization
      description: 'Send `RuntimeSession <session_credential>` from the running Runtime process. This credential is session-bound and scoped.'
    PlayerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: 'Send `Bearer <access_token>`, the player JWT from `/auth/*`.'
    CustomerSession:
      type: apiKey
      in: cookie
      name: crux_session
      description: Dashboard session JWT. Must be paired with the `CsrfToken` header.
    CsrfToken:
      type: apiKey
      in: header
      name: x-csrf
      description: Must equal the `csrf` claim in the `crux_session` JWT.

  parameters:
    ProjectId:
      name: project_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    EnvironmentId:
      name: environment_id
      in: path
      required: true
      description: Environment UUID (from `GET /projects/{project_id}/environments`).
      schema: { type: string, format: uuid }
    PlayerId:
      name: player_id
      in: path
      required: true
      description: Player UUID. For player-token calls it must match the token's player.
      schema: { type: string }

  schemas:
    WebhookEventType:
      type: string
      description: >-
        The catalogue of deliverable events. Subscriptions are validated against it, because
        a typo'd event type would otherwise be silently never delivered.
      enum:
        - player.created
        - player.logged_in
        - player.banned
        - document.updated
        - stat.updated
        - achievement.unlocked
        - leaderboard.score_submitted
        - economy.changed
        - match.created
        - match.completed
        - server.registered
        - server.deregistered

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        event_types:
          type: array
          items: { $ref: '#/components/schemas/WebhookEventType' }
        description: { type: string }
        is_active: { type: boolean }
        failure_count:
          type: integer
          description: Consecutive delivery failures. Reset by any success.
        disabled_at:
          type: string
          description: >-
            Set when Crux stopped delivering to this endpoint after sustained failure,
            or when it answered 410 Gone. The endpoint is kept rather than deleted so you
            can see that it happened.
        created_at: { type: string }
        secret:
          type: string
          description: >-
            Present ONLY in the create response. Sign-verification key for this endpoint.
            There is no way to read it back.
          example: whsec_1f4c...

    WebhookDelivery:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Stable across retries of the same event - use it for idempotency on your side.
        endpoint_id: { type: string, format: uuid }
        event_type: { $ref: '#/components/schemas/WebhookEventType' }
        attempts: { type: integer }
        status:
          type: string
          enum: [pending, delivered, failed]
        last_status:
          type: integer
          description: The HTTP status your endpoint returned, or absent if it never responded.
        last_error: { type: string }
        next_attempt_at: { type: string }
        delivered_at: { type: string }
        created_at: { type: string }

    Error:
      type: object
      description: >
        The envelope EVERY failure uses. Note the nesting: the code and message live
        under `error`, not at the top level. This schema previously declared them at the
        top level, which is wrong in the worst way - a generated client parses it, finds
        nothing, and reports the raw body instead of the careful message the server
        wrote. See internal/platform/apierror/apierror.go.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable, machine-readable. Match on this rather than the prose.
              example: unauthorized
            message: { type: string, example: invalid api key }
            details:
              type: object
              additionalProperties: true

    AuthResponse:
      description: Player token pair returned by every `/auth/*` success.
      type: object
      properties:
        access_token: { type: string, description: Player JWT. Send as `Bearer <access_token>`. }
        token_type: { type: string, example: Bearer }
        expires_in: { type: integer, description: Access-token lifetime in seconds (86400). }
        player_id: { type: string, format: uuid }
        refresh_token: { type: string, description: Opaque, single-use. Exchange via `/auth/refresh`. }
        refresh_token_expires_in: { type: integer, description: Refresh-token lifetime in seconds (2592000). }

    Project:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        organization_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CreateProjectResponse:
      type: object
      properties:
        project: { $ref: '#/components/schemas/Project' }
        bootstrap_credentials:
          type: object
          description: Shown once. Publishable is client-safe; secret_key and server_token must stay server-side.
          properties:
            api_key: { type: string, deprecated: true, description: Alias of publishable_key retained for compatibility. }
            publishable_key: { type: string, description: Safe to embed in a shipped game client; only bootstraps player sessions. }
            secret_key: { type: string, description: Server-side project control credential. Never ship this in a client. }
            server_token: { type: string }
            credential_warning: { type: string }

    Environment:
      type: object
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        name: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ApiKey:
      type: object
      description: API key metadata. The secret itself is never returned after creation.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        kind: { type: string, enum: [publishable, secret, legacy_readonly], description: legacy_readonly appears only on used pre-split keys, has publishable-level authority, and should be rotated. }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the most recent authenticated call with this key.
        first_used_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the FIRST authenticated call with this key (write-once activation signal).

    IssuedApiKey:
      type: object
      description: Returned only at creation. `key` is shown exactly once.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        kind: { type: string, enum: [publishable, secret] }
        key: { type: string, description: "The secret. Send it in the header as: Authorization ApiKey <key>." }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }

    PaginatedApiKeys:
      type: object
      properties:
        total: { type: integer, format: int64 }
        items:
          type: array
          items: { $ref: '#/components/schemas/ApiKey' }

    Leaderboard:
      type: object
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        environment_id: { type: string, format: uuid }
        key: { type: string }
        name: { type: string }
        sort_order: { type: string, enum: [asc, desc] }
        update_strategy: { type: string, enum: [replace, best, sum] }
        reset_schedule: { type: string }
        current_season: { type: integer }
        next_reset_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    LeaderboardStanding:
      type: object
      properties:
        player_id: { type: string }
        display_name: { type: string }
        score: { type: number }
        rank: { type: integer }
        metadata: { type: object, additionalProperties: true }
        player_metadata: { type: object, additionalProperties: true }
        updated_at: { type: string, format: date-time }

    LeaderboardSubmission:
      type: object
      properties:
        accepted: { type: boolean, description: False when the score did not beat the stored one under a 'best' strategy. }
        entry: { $ref: '#/components/schemas/LeaderboardStanding' }

    Currency:
      type: object
      description: 'Currency definition. NOTE: this response mirrors the stored row and uses PascalCase keys.'
      properties:
        ID: { type: string, format: uuid }
        ProjectID: { type: string, format: uuid }
        Key: { type: string }
        Name: { type: string }
        Symbol: { type: string, nullable: true }
        InitialBalance: { type: integer, format: int64 }
        CreatedAt: { type: string, format: date-time }
        UpdatedAt: { type: string, format: date-time }

    VirtualItem:
      type: object
      description: 'Virtual item definition. NOTE: this response mirrors the stored row and uses PascalCase keys.'
      properties:
        ID: { type: string, format: uuid }
        ProjectID: { type: string, format: uuid }
        Key: { type: string }
        Name: { type: string }
        Description: { type: string, nullable: true }
        Metadata: { type: string, nullable: true, description: Raw JSON metadata (base64-encoded in the response). }
        CreatedAt: { type: string, format: date-time }

    PlayerEconomy:
      type: object
      description: 'A player''s balances and inventory. Row-shaped objects use PascalCase keys.'
      properties:
        balances:
          type: array
          items:
            type: object
            properties:
              Key: { type: string }
              Name: { type: string }
              Symbol: { type: string, nullable: true }
              Amount: { type: integer, format: int64 }
              UpdatedAt: { type: string, format: date-time }
        inventory:
          type: array
          items:
            type: object
            properties:
              Key: { type: string }
              Name: { type: string }
              Description: { type: string, nullable: true }
              Quantity: { type: integer }
              Metadata: { type: string, nullable: true }
              UpdatedAt: { type: string, format: date-time }

    PlayerDocument:
      type: object
      properties:
        player_id: { type: string }
        key: { type: string }
        value: { description: Arbitrary JSON document body. }
        version: { type: integer, description: Increments on every write; used for optimistic concurrency. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PlayerStat:
      type: object
      properties:
        player_id: { type: string }
        key: { type: string }
        value: { type: integer, format: int64, description: 64-bit; lifetime counters overflow a 32-bit column. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Achievement:
      type: object
      properties:
        id: { type: string, format: uuid }
        key: { type: string }
        name: { type: string }
        description: { type: string }
        stat_key: { type: string, description: Present only on an auto-unlocking achievement. }
        threshold: { type: integer, format: int64, description: Present only alongside stat_key. }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PlayerAchievement:
      allOf:
        - $ref: '#/components/schemas/Achievement'
        - type: object
          properties:
            unlocked_at:
              type: string
              format: date-time
              nullable: true
              description: Null when this player has not earned it.

    ProjectDocument:
      type: object
      description: Project-wide document, shared by every player in the environment.
      properties:
        key: { type: string }
        value: { description: Arbitrary JSON document body. }
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Player:
      type: object
      description: >
        The list shape. Note it carries NO email and NO ban flag - fetch
        `/projects/{project_id}/players/{player_id}/status` for those.
      properties:
        id: { type: string, format: uuid }
        anonymous_id: { type: string }
        display_name: { type: string }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PlayerDocumentSummary:
      type: object
      properties:
        player_id: { type: string }
        document_count: { type: integer, format: int64 }
        latest_version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Friendship:
      type: object
      description: >
        NOTE: this response mirrors the stored row and uses PascalCase keys.
        Timestamps are ISO 8601 WITHOUT a timezone offset (e.g. 2026-08-12T09:31:04.12345),
        or null.
      properties:
        FriendID: { type: string }
        Status: { type: string, enum: [pending, accepted], description: A row is 'pending' until the recipient accepts. }
        CreatedAt: { type: string, nullable: true }
        UpdatedAt: { type: string, nullable: true }

    PendingFriendRequest:
      type: object
      description: 'NOTE: PascalCase keys, as above. PlayerID is the player who SENT the request.'
      properties:
        PlayerID: { type: string }
        CreatedAt: { type: string, nullable: true }

    Server:
      type: object
      description: >
        A registered dedicated server. Fields the registrant did not supply are omitted
        entirely rather than sent as null.
      properties:
        id: { type: string, format: uuid, description: Crux's row id. }
        server_id: { type: string, description: Your own identifier for the server, supplied at registration. }
        name: { type: string }
        region: { type: string }
        ip_address: { type: string }
        port: { type: integer, format: int32 }
        map_name: { type: string }
        game_mode: { type: string }
        player_count: { type: integer, format: int32 }
        server_version: { type: string }
        last_heartbeat: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    RuntimeBuild:
      type: object
      description: Immutable Godot dedicated-server build metadata.
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        environment_id: { type: string, format: uuid }
        version: { type: string }
        status: { type: string, enum: [draft, processing, ready, failed, disabled] }
        artifact_sha256: { type: string, nullable: true }
        artifact_size_bytes: { type: integer, format: int64, nullable: true }
        entrypoint: { type: string, nullable: true }
        manifest: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time, nullable: true }
        ready_at: { type: string, format: date-time, nullable: true }
        failed_at: { type: string, format: date-time, nullable: true }
        failure_reason: { type: string, nullable: true }

    RuntimeSession:
      type: object
      description: Ephemeral Runtime server session and its lifecycle telemetry.
      properties:
        id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        environment_id: { type: string, format: uuid }
        build_id: { type: string, format: uuid }
        node_id: { type: string, format: uuid, nullable: true }
        region: { type: string, enum: [eu] }
        state: { type: string, enum: [requested, scheduling, starting, ready, draining, stopping, stopped, failed] }
        external_host: { type: string, nullable: true }
        external_port: { type: integer, format: int32, nullable: true }
        max_players: { type: integer, format: int32 }
        cpu_millis: { type: integer, format: int32 }
        memory_bytes: { type: integer, format: int64 }
        created_at: { type: string, format: date-time, nullable: true }
        scheduled_at: { type: string, format: date-time, nullable: true }
        starting_at: { type: string, format: date-time, nullable: true }
        ready_at: { type: string, format: date-time, nullable: true }
        stopped_at: { type: string, format: date-time, nullable: true }
        last_heartbeat: { type: string, format: date-time, nullable: true }
        stop_reason: { type: string, nullable: true }
        failure_reason: { type: string, nullable: true }
        runtime_seconds: { type: integer, format: int64 }
        egress_bytes: { type: integer, format: int64 }

    RuntimeJoinTicket:
      type: object
      description: Short-lived player admission ticket for a ready Runtime session.
      properties:
        session_id: { type: string, format: uuid }
        player_id: { type: string }
        endpoint:
          type: object
          required: [host, port]
          properties:
            host: { type: string }
            port: { type: integer, format: int32 }
        ticket: { type: string, description: Signed five-minute ticket. }
        expires_at: { type: string, format: date-time }

    RuntimeAdmission:
      type: object
      description: Runtime evaluation/metering counters for one project.
      properties:
        plan_index: { type: integer, format: int32 }
        metered: { type: boolean }
        evaluation_seconds: { type: integer, format: int64 }
        used_seconds: { type: integer, format: int64 }
        remaining_seconds: { type: integer, format: int64 }
        max_concurrent_sessions: { type: integer, format: int32 }
        active_sessions: { type: integer, format: int32 }
        max_session_seconds: { type: integer, format: int32 }

    RuntimeDeployment:
      type: object
      description: Active immutable Runtime build and the previous ready build used for rollback.
      properties:
        project_id: { type: string, format: uuid }
        environment_id: { type: string, format: uuid }
        active_build_id: { type: string, format: uuid, nullable: true }
        previous_build_id: { type: string, format: uuid, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }

    RuntimeSessionContext:
      type: object
      description: The narrow context visible to the authoritative process that owns this session.
      properties:
        session_id: { type: string, format: uuid }
        project_id: { type: string, format: uuid }
        environment_id: { type: string, format: uuid }
        build_id: { type: string, format: uuid }
        region: { type: string, enum: [eu] }
        state: { type: string }
        external_host: { type: string, nullable: true }
        external_port: { type: integer, format: int32, nullable: true }
        max_players: { type: integer, format: int32 }
        scopes:
          type: array
          items: { type: string }
        match:
          nullable: true
          type: object
          properties:
            id: { type: string, format: uuid }
            game_mode: { type: string }
            region: { type: string }
            status: { type: string }
            runtime_status: { type: string }
            participants:
              type: array
              items: { $ref: '#/components/schemas/MatchParticipant' }
        result:
          type: object
          nullable: true
          additionalProperties: true

    RuntimeSessionResult:
      type: object
      required: [result]
      properties:
        session_id: { type: string, format: uuid }
        match_id: { type: string, format: uuid, nullable: true }
        result: { type: object, additionalProperties: true }
        complete: { type: boolean }

    RuntimeSessionActivity:
      type: object
      description: Application-level liveness heartbeat for the owning Runtime process.
      properties:
        session_id: { type: string, format: uuid }
        state: { type: string, nullable: true }
        last_activity_at: { type: string, format: date-time, nullable: true }

    MatchParticipant:
      type: object
      properties:
        player_id: { type: string }
        team: { type: string, description: Omitted when the match did not assign teams. }

    Match:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          description: >
            The stored match state, NOT the envelope's own `status` field. A match is
            created `forming`; the game server reports `active` and then `completed`.
            `cancelled` is an admin action. `expired` is the backstop for a match that
            never started - see the lifecycle endpoints.
          enum: [forming, active, completed, cancelled, expired]
        game_mode: { type: string }
        region: { type: string }
        server:
          allOf: [{ $ref: '#/components/schemas/Server' }]
          nullable: true
          description: >
            The server this match was placed on. Crux picks the emptiest server that is
            heartbeating in the match's region AND game_mode at the moment the match forms.
            ABSENT when nothing suitable was live - the match is still valid, so treat this
            as optional and fall back to reading the registry (`GET .../browser`) yourself.
            Because only heartbeating servers are eligible, a crashed server is never
            chosen.
        participants:
          type: array
          items: { $ref: '#/components/schemas/MatchParticipant' }
        runtime:
          type: object
          nullable: true
          description: Present when the queue opted into Crux Runtime matchmaking.
          properties:
            build_id: { type: string, format: uuid }
            session_id: { type: string, format: uuid }
            status: { type: string, enum: [provisioning, ready, active, finished, failed] }
            session_state: { type: string, enum: [requested, scheduling, starting, ready, stopping, stopped, failed] }
            external_host: { type: string }
            external_port: { type: integer, format: int32 }

    MatchmakingQueueTicket:
      type: object
      properties:
        id: { type: string, format: uuid }
        game_mode: { type: string }
        region: { type: string }
        runtime_build_id: { type: string, format: uuid, description: Present when this queue will provision a Crux Runtime session. }
        created_at: { type: string, format: date-time }

    MatchmakingStatus:
      type: object
      description: The one response shape for polling. Read `status` first, then the matching sub-object.
      properties:
        status:
          type: string
          enum: [none, queued, matched]
          description: '`none` = not queued and not matched; `queued` = waiting; `matched` = a match exists.'
        queue:
          allOf: [{ $ref: '#/components/schemas/MatchmakingQueueTicket' }]
          description: Present only when status is `queued`.
        match:
          allOf: [{ $ref: '#/components/schemas/Match' }]
          description: Present only when status is `matched`.

    AdminQueueEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        player_id: { type: string }
        game_mode: { type: string }
        region: { type: string }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }

    AdminMatchSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [forming, active, completed, cancelled, expired] }
        game_mode: { type: string }
        region: { type: string }
        server_id: { type: string, format: uuid, nullable: true, description: The allocated server, or null when none was live at form time. See Match.server. }
        created_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }

    AdminMatchDetail:
      allOf:
        - $ref: '#/components/schemas/AdminMatchSummary'
        - type: object
          properties:
            metadata: { type: object, additionalProperties: true }
            server:
              allOf: [{ $ref: '#/components/schemas/Server' }]
              nullable: true
              description: The allocated server, or absent when none was live at form time. See Match.server.
            participants:
              type: array
              items: { $ref: '#/components/schemas/MatchParticipant' }

    ConfigVersion:
      type: object
      properties:
        id: { type: string, format: uuid }
        version: { type: string, description: Your own version label, supplied at upload. }
        storage_backend: { type: string }
        storage_key: { type: string }
        storage_object_version: { type: string }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        activated_at: { type: string, format: date-time }
        bundle_url: { type: string, description: Absolute URL to download this version's bundle. }

    Generator:
      type: object
      description: A stored world-generator spec. Renders are deterministic and never stored.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        version: { type: integer, format: int32, description: Bumped on every update. }
        spec: { type: object, additionalProperties: true, description: The generator spec. Omitted from list responses. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        schema_url: { type: string }
        render_url: { type: string }
        preview_url: { type: string }

    ServerToken:
      type: object
      description: Server-token metadata. The secret is never returned after creation.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time }

    IssuedServerToken:
      type: object
      description: Returned only at creation. `token` is shown exactly once.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        token: { type: string, description: 'The secret. Send it as: Authorization ServerToken <token>.' }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    PlayerStatus:
      type: object
      properties:
        player_id: { type: string, format: uuid }
        email: { type: string }
        display_name: { type: string }
        anonymous_id: { type: string }
        is_banned: { type: boolean }
        email_verified: { type: boolean }
        last_login_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    AuditLogEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        actor_type: { type: string, description: 'e.g. player_token, api_key, customer, anonymous.' }
        actor_id: { type: string }
        action: { type: string, description: 'e.g. document.set, login.create.failed.' }
        resource_type: { type: string }
        resource_id: { type: string }
        metadata: { type: object, additionalProperties: true }
        ip_address: { type: string }
        created_at: { type: string, format: date-time }

    ProjectUsageSummary:
      type: object
      properties:
        project: { $ref: '#/components/schemas/Project' }
        runtime: { $ref: '#/components/schemas/RuntimeValidationSummary' }
        environment_count: { type: integer, format: int64 }
        leaderboard_count: { type: integer, format: int64 }
        api_key_count: { type: integer, format: int64 }
        server_token_count: { type: integer, format: int64 }
        player_document_count: { type: integer, format: int64 }
        server_count: { type: integer, format: int64 }
        config_version_count: { type: integer, format: int64 }
        current_month_mau: { type: integer, format: int64 }
        # Every player account that currently exists in the project, guests
        # included. A traction indicator, not a billed quantity.
        total_player_count: { type: integer, format: int64 }
        current_month_api_calls: { type: integer, format: int64 }
        plan_name: { type: string }
        plan_max_mau: { type: integer, format: int64 }
        plan_max_api_requests: { type: integer, format: int64 }
        plan_max_environments_per_project: { type: integer, format: int32 }
        plan_max_player_documents_stored: { type: integer, format: int32 }
        plan_max_total_document_storage_gb: { type: integer, format: int32 }
        latest_player_document_at: { type: string, format: date-time }
        latest_server_heartbeat_at: { type: string, format: date-time }
        latest_config_version_at: { type: string, format: date-time }

    RuntimeValidationSummary:
      type: object
      description: >
        Observed Runtime control-plane evidence for this project. This does not
        claim that an external pilot game or paying customer exists; those
        validation gates are tracked outside the API.
      properties:
        build_count: { type: integer, format: int64 }
        ready_build_count: { type: integer, format: int64 }
        session_count: { type: integer, format: int64 }
        ready_session_count: { type: integer, format: int64 }
        failed_session_count: { type: integer, format: int64 }
        stopped_session_count: { type: integer, format: int64 }
        active_session_count: { type: integer, format: int64 }
        total_runtime_seconds: { type: integer, format: int64 }
        total_egress_bytes: { type: integer, format: int64 }
        result_count: { type: integer, format: int64 }
        average_startup_seconds: { type: number, format: double }
        p95_startup_seconds: { type: number, format: double }
        last_session_at: { type: string, format: date-time, nullable: true }

paths:
  # ------------------------------------------------------------------ Player Auth
  /auth/anonymous:
    post:
      tags: [Player Auth]
      summary: Anonymous player login
      description: Creates (or re-fetches) an anonymous player for the API key's project and returns a token pair.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [anonymous_id]
              properties:
                anonymous_id: { type: string, description: Stable client-generated device/player id. }
                display_name: { type: string }
      responses:
        '200':
          description: Token pair
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { description: anonymous_id missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '401': { description: Missing or invalid API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  /auth/register:
    post:
      tags: [Player Auth]
      summary: Register an email/password player
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 8 }
                display_name: { type: string }
      responses:
        '201':
          description: Player created; token pair returned
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { description: Invalid email or password too short }
        '409': { description: Email already registered }

  /auth/login:
    post:
      tags: [Player Auth]
      summary: Email/password player login
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        '200':
          description: Token pair
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '401': { description: Invalid credentials }
        '403': { description: Player is banned }

  /auth/refresh:
    post:
      tags: [Player Auth]
      summary: Rotate an access token
      description: Exchanges a valid, unused refresh token for a fresh token pair. Refresh tokens are single-use; reuse revokes the whole chain.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        '200':
          description: New token pair
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '401': { description: Refresh token invalid, expired, revoked, or already used }

  /auth/logout:
    post:
      tags: [Player Auth]
      summary: Log out the current player
      description: Revokes the presented player token.
      security:
        - PlayerAuth: []
      responses:
        '200': { description: Logged out }
        '401': { description: Missing or invalid player token }

  # ------------------------------------------------------------------ Projects
  /projects:
    post:
      tags: [Projects]
      summary: Create a project
      description: Creates a project plus a default environment, API key, and server token. The bootstrap credentials are returned once.
      security:
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                api_key_name: { type: string, default: default }
                server_token_name: { type: string, default: default }
                environment_name: { type: string, default: development }
      responses:
        '201':
          description: Project created with bootstrap credentials
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateProjectResponse' }
        '400': { description: name is required }
        '401': { description: No dashboard session }
        '403': { description: CSRF validation failed }
    get:
      tags: [Projects]
      summary: List your projects
      security:
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Projects in your organization
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Project' }

  /projects/{project_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: Get a project
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Project
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '404': { description: Project not found }
    delete:
      tags: [Projects]
      summary: Delete a project
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Deleted }

  /projects/{project_id}/environments:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: List environments
      description: Use this to discover the environment_id needed for runtime calls.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Environments
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Environment' }
    post:
      tags: [Projects]
      summary: Create an environment
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        '201':
          description: Environment created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Environment' }

  # ------------------------------------------------------------------ API Keys
  /projects/{project_id}/keys:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [API Keys]
      summary: Create an API key
      description: Returns the secret exactly once in the `key` field.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                expires_at: { type: string, format: date-time, nullable: true }
                kind:
                  type: string
                  enum: [publishable, secret]
                  default: publishable
                  description: Publishable is client-safe and auth-only; secret is server-side and reaches project control routes.
      responses:
        '201':
          description: Key created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssuedApiKey' }
    get:
      tags: [API Keys]
      summary: List API keys
      description: Includes last_used_at and the write-once first_used_at activation timestamp. Never returns the secret.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
      responses:
        '200':
          description: Paginated keys
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginatedApiKeys' }

  /projects/{project_id}/keys/{key_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - { name: key_id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [API Keys]
      summary: Revoke an API key
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Revoked }
        '403': { description: Key does not belong to this project }

  # ------------------------------------------------------------------ Economy (admin)
  /projects/{project_id}/economy/currencies:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Economy]
      summary: Create a currency
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key, name]
              properties:
                key: { type: string, description: Stable identifier used in economy adjustments (e.g. gold). }
                name: { type: string }
                symbol: { type: string }
                initial_balance: { type: integer, format: int64, default: 0 }
      responses:
        '201':
          description: Currency created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Currency' }
        '400': { description: key and name are required }
    get:
      tags: [Economy]
      summary: List currencies
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Currencies
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Currency' }

  /projects/{project_id}/economy/items:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Economy]
      summary: Create a virtual item
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key, name]
              properties:
                key: { type: string }
                name: { type: string }
                description: { type: string }
                metadata: { type: object, additionalProperties: true }
      responses:
        '201':
          description: Item created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VirtualItem' }
        '400': { description: key and name are required }
    get:
      tags: [Economy]
      summary: List virtual items
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Items
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/VirtualItem' }

  # ------------------------------------------------------------------ Economy (runtime)
  /projects/{project_id}/environments/{environment_id}/players/{player_id}/economy:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Economy]
      summary: Get a player's balances and inventory
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Balances and inventory
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerEconomy' }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/economy/adjust:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Economy]
      summary: Adjust a player's balances and inventory
      description: Applies signed deltas atomically. Currencies/items are referenced by their `key` and must already exist.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                balance_adjustments:
                  type: array
                  items:
                    type: object
                    properties:
                      currency_key: { type: string }
                      amount: { type: integer, format: int64, description: Signed delta. }
                inventory_adjustments:
                  type: array
                  items:
                    type: object
                    properties:
                      item_key: { type: string }
                      quantity: { type: integer, description: Signed delta. }
                      metadata: { type: object, additionalProperties: true }
      responses:
        '200': { description: Adjusted }
        '400': { description: Unknown currency_key or item_key }

  # ------------------------------------------------------------------ Leaderboards
  /projects/{project_id}/environments/{environment_id}/leaderboards:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Leaderboards]
      summary: Create a leaderboard
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key: { type: string }
                name: { type: string, description: Defaults to key. }
                sort_order: { type: string, enum: [asc, desc], default: desc }
                update_strategy: { type: string, enum: [replace, best, sum], default: best }
                reset_schedule: { type: string, description: Optional seasonal reset schedule. }
                min_score: { type: number, nullable: true }
                max_score: { type: number, nullable: true }
      responses:
        '201':
          description: Leaderboard created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Leaderboard' }
        '400': { description: key is required }
    get:
      tags: [Leaderboards]
      summary: List leaderboards
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
      responses:
        '200':
          description: Paginated leaderboards
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer, format: int64 }
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Leaderboard' }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/scores:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: The board's UUID from the create/list response, OR the key you gave it (unique per environment). A UUID is resolved as an id first. }
    post:
      tags: [Leaderboards]
      summary: Submit a score
      description: With a player token, player_id must be the token's player. A server token may submit for any player.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [player_id, score]
              properties:
                player_id: { type: string }
                score: { type: number }
                metadata: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Score processed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaderboardSubmission' }
        '400': { description: Non-finite score or out of the leaderboard's min/max range }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/top:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: The board's UUID from the create/list response, OR the key you gave it (unique per environment). A UUID is resolved as an id first. }
      - { name: limit, in: query, schema: { type: integer, default: 10 } }
      - { name: season, in: query, schema: { type: integer }, description: Defaults to the current season. }
    get:
      tags: [Leaderboards]
      summary: Top ranked players
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Ranked standings (highest/lowest first per sort_order)
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeaderboardStanding' }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/players/{player_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: The board's UUID from the create/list response, OR the key you gave it (unique per environment). A UUID is resolved as an id first. }
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Leaderboards]
      summary: A single player's standing
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Standing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaderboardStanding' }
        '404': { description: Player has no entry }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/entries:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: The board's UUID from the create/list response, OR the key you gave it (unique per environment). A UUID is resolved as an id first. }
    post:
      tags: [Leaderboards]
      summary: Submit a score (admin)
      description: API-key/session equivalent of /scores, for tools and back-office use.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [player_id, score]
              properties:
                player_id: { type: string }
                score: { type: number }
                metadata: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Score processed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaderboardSubmission' }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/entries/top:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: The board's UUID from the create/list response, OR the key you gave it (unique per environment). A UUID is resolved as an id first. }
      - { name: limit, in: query, schema: { type: integer, default: 10 } }
      - { name: season, in: query, schema: { type: integer } }
    get:
      tags: [Leaderboards]
      summary: Top ranked players (admin)
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Ranked standings
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeaderboardStanding' }

  # ------------------------------------------------------------------ Player Documents
  /projects/{project_id}/environments/{environment_id}/players/{player_id}/documents:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Player Documents]
      summary: List a player's document keys
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Document keys with versions
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    key: { type: string }
                    version: { type: integer }
                    created_at: { type: string, format: date-time }
                    updated_at: { type: string, format: date-time }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/documents/batch-read:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Player Documents]
      summary: Read multiple documents
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keys]
              properties:
                keys: { type: array, items: { type: string } }
      responses:
        '200':
          description: Documents
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PlayerDocument' }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/documents/batch-write:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Player Documents]
      summary: Write multiple documents
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                items:
                  type: array
                  description: Preferred field. (`writes` is accepted as an alias.)
                  items:
                    type: object
                    properties:
                      key: { type: string }
                      value: { description: Arbitrary JSON. }
      responses:
        '200':
          description: Written documents
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PlayerDocument' }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/documents/{key}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
      - { name: key, in: path, required: true, schema: { type: string } }
    get:
      tags: [Player Documents]
      summary: Get a document
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Document
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerDocument' }
        '404': { description: No such document }
    put:
      tags: [Player Documents]
      summary: Create or replace a document
      description: Pass the current `version` for optimistic concurrency; omit it to force-write.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: { description: Arbitrary JSON document body. }
                version: { type: integer, nullable: true }
      responses:
        '200':
          description: Saved document
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerDocument' }
        '409': { description: Version conflict }
    patch:
      tags: [Player Documents]
      summary: Partially update a document
      description: Applies an ordered list of set/remove operations at JSON paths.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operations]
              properties:
                version: { type: integer, nullable: true }
                operations:
                  type: array
                  items:
                    type: object
                    required: [path]
                    properties:
                      op: { type: string, enum: [set, remove], default: set }
                      path: { type: array, items: { type: string } }
                      value: { nullable: true }
                      create_missing: { type: boolean }
      responses:
        '200':
          description: Patched document
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerDocument' }
        '409': { description: Version conflict }
    delete:
      tags: [Player Documents]
      summary: Delete a document
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '204': { description: Deleted }

  # ------------------------------------------------------------------ Service
  /health:
    get:
      tags: [Service]
      summary: Liveness probe
      description: No authentication. HEAD is registered alongside GET for uptime monitors.
      responses:
        '200': { description: Service is healthy }

  # ------------------------------------------------------- Project admin
  /projects/{project_id}/summary:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Project Admin]
      summary: Usage summary and plan limits
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Counts, current-month usage, and the plan's ceilings
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectUsageSummary' }

  /projects/{project_id}/runtime/validation:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Runtime]
      summary: Get Runtime validation evidence
      description: >
        Returns observed build, session, startup, cleanup, result, runtime-time,
        and egress counters for the project. External-game handoffs and paid
        continuation are intentionally not inferred from these counters.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Runtime validation evidence
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeValidationSummary' }

  /projects/{project_id}/audit-logs:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Project Admin]
      summary: List audit-log entries
      description: >
        Records successful MUTATIONS (POST/PUT/PATCH/DELETE with a 2xx) and any non-2xx
        response, including auth rejections. Successful GETs are deliberately NOT recorded,
        so this log over-represents failure and must not be read as an error rate.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 }, description: Values outside 1..200 fall back to 50. }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Audit entries, newest first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/AuditLogEntry' }

  /projects/{project_id}/all-players:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Project Admin]
      summary: List every player in the project
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 }, description: Silently capped at 100. }
        - { name: page, in: query, schema: { type: integer, default: 1, minimum: 1 } }
      responses:
        '200':
          description: Paginated players
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer, format: int64 }
                  items: { type: array, items: { $ref: '#/components/schemas/Player' } }

  # ------------------------------------------------------- Server tokens
  /projects/{project_id}/tokens:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Server Tokens]
      summary: Issue a server token
      description: >
        Requires a signed-in account owner, NOT a bare API key - an API key is the
        credential shipped inside the game binary, and minting further credentials
        must not be reachable from it.
      security:
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                expires_at: { type: string, format: date-time, nullable: true }
      responses:
        '201':
          description: Token created. The secret is shown exactly once.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssuedServerToken' }
    get:
      tags: [Server Tokens]
      summary: List server tokens
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
      responses:
        '200':
          description: Token metadata (no secrets)
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer, format: int64 }
                  items: { type: array, items: { $ref: '#/components/schemas/ServerToken' } }

  /projects/{project_id}/tokens/{token_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - { name: token_id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [Server Tokens]
      summary: Revoke a server token
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Revoked }

  # ------------------------------------------------------- Player moderation
  /projects/{project_id}/players/{player_id}/status:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Player Moderation]
      summary: Get a player's account status
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Player status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerStatus' }
        '400': { description: 'player_id is not a UUID. It wants the UUID from anonymous login (auth.player_id), not a display name.' }
        '404': { description: No such player in this project }

  /projects/{project_id}/players/{player_id}/ban:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Player Moderation]
      summary: Ban a player
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Player banned
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  player_id: { type: string, format: uuid }
                  banned: { type: boolean }
        '404': { description: No such player in this project }
        '409': { description: Player is already banned }

  /projects/{project_id}/players/{player_id}/unban:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Player Moderation]
      summary: Unban a player
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Player unbanned
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  player_id: { type: string, format: uuid }
                  banned: { type: boolean }
        '404': { description: No such player in this project }
        '409': { description: Player is not banned }

  # ------------------------------------------------------- Environment
  /projects/{project_id}/environments/{environment_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Projects]
      summary: Get an environment
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Environment
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Environment' }
        '404': { description: Not found }
    delete:
      tags: [Projects]
      summary: Delete an environment
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Deleted }

  /projects/{project_id}/environments/{environment_id}/auth/roblox/verify:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Player Auth]
      summary: Verify a Roblox user and upsert the matching player
      description: >
        Confirms the user id exists on Roblox, then finds-or-creates a Crux player whose
        anonymous_id is `roblox:<user_id>`.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id: { type: string, description: Roblox user id, as a decimal string. }
      responses:
        '200':
          description: Verified. Returns the Crux player.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: user_id missing, non-numeric, or not verifiable with Roblox }

  # ------------------------------------------------------- Runtime
  /projects/{project_id}/environments/{environment_id}/runtime/builds:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Runtime]
      summary: Create an immutable Runtime build record
      description: Create the build metadata before uploading its raw Godot dedicated-server ZIP.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version, entrypoint]
              properties:
                version: { type: string, maxLength: 128 }
                entrypoint: { type: string, description: Normalized relative path to the Linux server executable inside the ZIP. }
                manifest: { type: object, additionalProperties: true }
      responses:
        '201':
          description: Draft build created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeBuild' }
        '400': { description: Invalid version, entrypoint, manifest, or request body }
        '409': { description: A build with this version already exists in the environment }
    get:
      tags: [Runtime]
      summary: List Runtime builds
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - name: limit
          in: query
          schema: { type: integer, format: int32, default: 50, minimum: 1, maximum: 100 }
      responses:
        '200':
          description: Builds newest first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/RuntimeBuild' }

  /projects/{project_id}/environments/{environment_id}/runtime/builds/{build_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: build_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Runtime]
      summary: Get a Runtime build
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Runtime build
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeBuild' }
        '400': { description: Invalid build_id }
        '404': { description: Runtime build not found in this environment }

  /projects/{project_id}/environments/{environment_id}/runtime/builds/{build_id}/artifact:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: build_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    put:
      tags: [Runtime]
      summary: Upload a Runtime build artifact
      description: Upload a raw ZIP exported from a Godot 4 Linux dedicated-server build. The API validates and stores it without executing it.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/zip:
            schema: { type: string, format: binary }
      responses:
        '201':
          description: Ready immutable build
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeBuild' }
        '400': { description: Artifact is not a safe ZIP, is too large, or lacks the declared entrypoint }
        '404': { description: Runtime build not found in this environment }
        '409': { description: Build is no longer awaiting an artifact }

  /projects/{project_id}/environments/{environment_id}/runtime/deployment:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Runtime]
      summary: Read the active Runtime deployment
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Active and previous ready builds
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeDeployment' }
        '404': { description: No Runtime build has been activated in this environment }
    post:
      tags: [Runtime]
      summary: Activate a ready Runtime build
      description: New sessions that omit build_id use this immutable build. Existing sessions are unchanged.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [build_id]
              properties:
                build_id: { type: string, format: uuid }
      responses:
        '200':
          description: Activated Runtime deployment
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeDeployment' }
        '400': { description: Invalid build_id }
        '409': { description: Build is not ready in this environment }

  /projects/{project_id}/environments/{environment_id}/runtime/deployment/rollback:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Runtime]
      summary: Roll back the active Runtime deployment
      description: Swap the active build with the previous ready immutable build.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Rolled-back Runtime deployment
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeDeployment' }
        '409': { description: No previous ready build is available }

  /projects/{project_id}/environments/{environment_id}/runtime/sessions:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Runtime]
      summary: Request an ephemeral Runtime session
      description: Create a session record for a ready build. v0 accepts only the EU region; an isolated Runtime Agent performs placement and execution.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                build_id: { type: string, format: uuid, nullable: true, description: Omit to use the active deployment. }
                region: { type: string, enum: [eu], default: eu }
                max_players: { type: integer, format: int32, minimum: 1, maximum: 16, default: 16 }
                cpu_millis: { type: integer, format: int32, minimum: 100, maximum: 4000, default: 1000 }
                memory_bytes: { type: integer, format: int64, minimum: 67108864, maximum: 8589934592, default: 1073741824 }
      responses:
        '201':
          description: Session requested
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeSession' }
        '400': { description: Invalid build_id, region, or resource limits }
        '404': { description: Runtime build not found in this environment }
        '409': { description: Runtime build is not ready }
    get:
      tags: [Runtime]
      summary: List Runtime sessions
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - name: limit
          in: query
          schema: { type: integer, format: int32, default: 50, minimum: 1, maximum: 100 }
      responses:
        '200':
          description: Sessions newest first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/RuntimeSession' }

  /projects/{project_id}/environments/{environment_id}/runtime/admission:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Runtime]
      summary: Get Runtime allowance and active-session counters
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Current Runtime entitlement and usage counters
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeAdmission' }
        '404': { description: Project or environment not found }

  /projects/{project_id}/environments/{environment_id}/runtime/sessions/{session_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: session_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Runtime]
      summary: Get a Runtime session
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Runtime session
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeSession' }
        '400': { description: Invalid session_id }
        '404': { description: Runtime session not found in this environment }
    delete:
      tags: [Runtime]
      summary: Stop a Runtime session
      description: Requests an idempotent stop. A running session transitions to stopping; an unassigned session stops immediately.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Updated or already-terminal Runtime session
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeSession' }
        '400': { description: Invalid session_id }
        '404': { description: Runtime session not found in this environment }
        '409': { description: Concurrent lifecycle transition; reload the session }

  /projects/{project_id}/environments/{environment_id}/runtime/sessions/{session_id}/logs:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: session_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Runtime]
      summary: Get Runtime session logs
      parameters:
        - name: limit
          in: query
          schema: { type: integer, format: int32, default: 500, minimum: 1, maximum: 1000 }
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Ordered stdout, stderr, and system log records
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    session_id: { type: string, format: uuid }
                    stream: { type: string, enum: [stdout, stderr, system] }
                    sequence: { type: integer, format: int64 }
                    message: { type: string }
                    created_at: { type: string, format: date-time }
        '400': { description: Invalid session_id or limit }
        '404': { description: Runtime session not found in this environment }

  /projects/{project_id}/environments/{environment_id}/runtime/sessions/{session_id}/join:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: session_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Runtime]
      summary: Issue a Runtime join ticket
      description: >
        Requires a player token, server token, or secret API key. A player token
        may only issue its own ticket; a server-side credential must provide
        player_id. The session must already be ready. The ticket expires in five
        minutes and is signed with a secret delivered only to that session's
        isolated server process.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                player_id: { type: string }
      responses:
        '200':
          description: Join ticket and public session endpoint
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeJoinTicket' }
        '400': { description: Invalid session_id or missing server-side player_id }
        '403': { description: Publishable key or player tenancy violation }
        '404': { description: Runtime session not found in this environment }
        '409': { description: Runtime session is not ready }

  /projects/{project_id}/environments/{environment_id}/runtime/sessions/{session_id}/context:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: session_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Runtime]
      summary: Read the owning Runtime process context
      description: >
        RuntimeSession-only. The credential is injected into the authoritative server
        process by the isolated agent and can read only the matching session, its optional
        match roster, and its own previously submitted result.
      security:
        - RuntimeSessionAuth: []
      responses:
        '200':
          description: Session-scoped context
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeSessionContext' }
        '400': { description: Invalid session_id }
        '401': { description: Missing, invalid, or expired Runtime session credential }
        '403': { description: Credential does not match this project, environment, session, or scope }
        '404': { description: Runtime session not found in this environment }

  /projects/{project_id}/environments/{environment_id}/runtime/sessions/{session_id}/result:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: session_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Runtime]
      summary: Submit the owning Runtime process result
      description: >
        RuntimeSession-only. Stores one JSON object for this session. Set complete=true
        to complete the optional associated matchmaking match and request the ephemeral
        Runtime session to stop. Repeating the call updates the same result row safely.
      security:
        - RuntimeSessionAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [result]
              properties:
                result: { type: object, additionalProperties: true, maxProperties: 256 }
                complete: { type: boolean, default: false }
      responses:
        '200':
          description: Stored session result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeSessionResult' }
        '400': { description: Result is not a JSON object or exceeds 64 KiB }
        '401': { description: Missing, invalid, or expired Runtime session credential }
        '403': { description: Credential does not match this project, environment, session, or scope }
        '404': { description: Runtime session not found in this environment }

  /projects/{project_id}/environments/{environment_id}/runtime/sessions/{session_id}/heartbeat:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - name: session_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Runtime]
      summary: Record owning Runtime process activity
      description: >
        RuntimeSession-only. Records an application-level heartbeat from the
        authoritative game process. It is separate from the Agent/node heartbeat
        and is used only when the build manifest opts into idle_timeout_seconds.
        Send it periodically while the process is accepting players or simulating.
      security:
        - RuntimeSessionAuth: []
      responses:
        '200':
          description: Activity recorded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeSessionActivity' }
        '401': { description: Missing, invalid, or expired Runtime session credential }
        '403': { description: Credential does not match this project, environment, session, or scope }
        '409': { description: Runtime session is no longer active }

  # ------------------------------------------------------- Servers
  /projects/{project_id}/environments/{environment_id}/servers:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Servers]
      summary: Register a dedicated server
      description: Requires a server token. Re-registering the same server_id updates the existing row.
      security:
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [server_id]
              properties:
                server_id: { type: string, description: Your identifier for this server. Unique per environment. }
                name: { type: string }
                region: { type: string }
                ip_address: { type: string }
                port: { type: integer, format: int32, nullable: true }
                map_name: { type: string }
                game_mode: { type: string }
                player_count: { type: integer, format: int32, nullable: true }
                server_version: { type: string }
      responses:
        '201':
          description: Registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Server' }
        '400': { description: server_id is required }
    get:
      tags: [Servers]
      summary: List registered servers
      security:
        - ServerTokenAuth: []
      responses:
        '200':
          description: Servers
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Server' } }

  /projects/{project_id}/environments/{environment_id}/servers/heartbeat:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Servers]
      summary: Heartbeat
      description: >
        Keeps the server visible in the browser. Liveness is held in Redis with a TTL, so a
        server that stops heartbeating disappears from `GET .../browser` without being deleted.
      security:
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [server_id]
              properties:
                server_id: { type: string }
                player_count: { type: integer, format: int32, nullable: true }
                map_name: { type: string }
                game_mode: { type: string }
      responses:
        '200':
          description: Heartbeat recorded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Server' }
        '404': { description: No server registered with that server_id }

  /projects/{project_id}/environments/{environment_id}/servers/deregister:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Servers]
      summary: Deregister a server
      security:
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [server_id]
              properties:
                server_id: { type: string }
      responses:
        '204': { description: Deregistered }

  /projects/{project_id}/environments/{environment_id}/servers/admin:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Servers]
      summary: List servers (dashboard)
      description: Same data as the server-token list, reachable with an API key or dashboard session.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Servers
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Server' } }

  /projects/{project_id}/environments/{environment_id}/browser:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Servers]
      summary: Search live servers (server browser)
      description: >
        Only servers with a live heartbeat are returned. Sorted by player_count descending,
        then newest first. This is the endpoint a game client uses to fill a server list.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      parameters:
        - { name: region, in: query, schema: { type: string }, description: Exact match. }
        - { name: map_name, in: query, schema: { type: string }, description: Exact match. }
        - { name: game_mode, in: query, schema: { type: string }, description: Exact match. }
      responses:
        '200':
          description: Matching live servers
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Server' } }

  # ------------------------------------------------------- Matchmaking
  /projects/{project_id}/environments/{environment_id}/matchmaking/join:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Matchmaking]
      summary: Join the queue
      description: >
        Queues the calling player. Matching is by exact (game_mode, region) and currently
        forms groups of two. `metadata` is stored and returned to admin views but does not
        yet affect matching.
      security:
        - PlayerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [game_mode, region]
              properties:
                game_mode: { type: string }
                region: { type: string }
                runtime_build_id: { type: string, format: uuid, description: Optional ready Runtime build for an authoritative ephemeral session. Runtime v0 requires region `eu`. }
                metadata: { type: object, additionalProperties: true }
      responses:
        '202': { description: Queued. Poll `.../matchmaking/status`. }
        '400': { description: game_mode or region missing }

  /projects/{project_id}/environments/{environment_id}/matchmaking/leave:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Matchmaking]
      summary: Leave the queue
      security:
        - PlayerAuth: []
      responses:
        '204': { description: Removed from the queue (also when not queued) }

  /projects/{project_id}/environments/{environment_id}/matchmaking/status:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Matchmaking]
      summary: Poll queue / match status
      description: >
        Checks for a match first, then the queue. A match is reported until it is cancelled.
      security:
        - PlayerAuth: []
      responses:
        '200':
          description: Current status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MatchmakingStatus' }

  /projects/{project_id}/environments/{environment_id}/matchmaking/matches/{match_id}/start:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: match_id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Matchmaking]
      summary: Report that a match has begun
      description: >
        Moves a match from `forming` to `active`. SERVER TOKEN, not a player token: the
        game server is the only party that knows a session actually started, and unlike
        the queue endpoints this is keyed on the match rather than on the caller.
        Idempotent - only a `forming` match advances, so a repeat call answers 404
        rather than reopening a finished match.
      security:
        - ServerTokenAuth: []
      responses:
        '204': { description: Match is now active }
        '400': { description: match_id is not a UUID }
        '404': { description: No match in this environment is waiting to start with that id }

  /projects/{project_id}/environments/{environment_id}/matchmaking/matches/{match_id}/complete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: match_id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Matchmaking]
      summary: Report that a match has ended
      description: >
        Moves a match to `completed` and releases its players to queue again. Accepts a
        match still in `forming` as well as one that is `active`, because a short session
        may end without ever having reported a start - refusing that would pin the match
        to its players for good. SERVER TOKEN, as with start.
      security:
        - ServerTokenAuth: []
      responses:
        '204': { description: Match completed }
        '400': { description: match_id is not a UUID }
        '404': { description: No open match in this environment with that id }

  /projects/{project_id}/environments/{environment_id}/matchmaking/admin/queue:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Matchmaking Admin]
      summary: List queued players
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Queue entries
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/AdminQueueEntry' } }

  /projects/{project_id}/environments/{environment_id}/matchmaking/admin/queue/{entry_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: entry_id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [Matchmaking Admin]
      summary: Remove one player from the queue
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Removed }
        '404': { description: No such queue entry }

  /projects/{project_id}/environments/{environment_id}/matchmaking/admin/matches:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Matchmaking Admin]
      summary: List matches
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Matches
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/AdminMatchSummary' } }

  /projects/{project_id}/environments/{environment_id}/matchmaking/admin/matches/{match_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: match_id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Matchmaking Admin]
      summary: Get one match with participants
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Match detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminMatchDetail' }
        '404': { description: No such match }

  /projects/{project_id}/environments/{environment_id}/matchmaking/admin/matches/{match_id}/cancel:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: match_id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Matchmaking Admin]
      summary: Cancel a match
      description: Only a match in `forming` or `active` can be cancelled.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Cancelled }
        '404': { description: No such match, or it was already cancelled }

  /projects/{project_id}/environments/{environment_id}/matchmaking/admin/form:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Matchmaking Admin]
      summary: Force-form a match from named players
      description: >
        Builds a match immediately from the players you list, bypassing the queue and its
        group size. Useful for testing a matched flow with fewer players than the matcher wants.
        Allocates a server the same way the queue does, so a force-formed match behaves like a
        real one.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [game_mode, region, player_ids]
              properties:
                game_mode: { type: string }
                region: { type: string }
                player_ids: { type: array, items: { type: string } }
      responses:
        '201':
          description: Match formed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminMatchDetail' }
        '400': { description: Missing fields or empty player_ids }

  # ------------------------------------------------------- Social
  /projects/{project_id}/environments/{environment_id}/players/{player_id}/social/friends:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Social]
      summary: List friends
      description: >
        CAUTION: returns JSON `null`, not `[]`, when the player has no friends. Guard for it
        before iterating.
      security:
        - PlayerAuth: []
      responses:
        '200':
          description: Friendships, or null when there are none
          content:
            application/json:
              schema:
                type: array
                nullable: true
                items: { $ref: '#/components/schemas/Friendship' }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/social/friends/request:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Social]
      summary: Send a friend request
      security:
        - PlayerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [friend_id]
              properties:
                friend_id: { type: string }
      responses:
        '202': { description: Request recorded as pending }
        '400': { description: friend_id missing, or equal to the caller }
        '403': { description: The target has blocked this player }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/social/friends/requests:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Social]
      summary: List incoming friend requests
      description: 'CAUTION: returns JSON `null`, not `[]`, when there are none.'
      security:
        - PlayerAuth: []
      responses:
        '200':
          description: Pending requests sent TO this player, or null
          content:
            application/json:
              schema:
                type: array
                nullable: true
                items: { $ref: '#/components/schemas/PendingFriendRequest' }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/social/friends/accept:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Social]
      summary: Accept a friend request
      security:
        - PlayerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [friend_id]
              properties:
                friend_id: { type: string, description: The player who sent the request. }
      responses:
        '200': { description: Accepted }
        '400': { description: No pending request from that player, or an attempt to friend yourself }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/social/friends/{friend_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
      - { name: friend_id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [Social]
      summary: Remove a friend
      description: Also withdraws a still-pending request. Succeeds even if there was no friendship.
      security:
        - PlayerAuth: []
      responses:
        '204': { description: Removed }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/social/blocks:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    post:
      tags: [Social]
      summary: Block a player
      description: Blocking is idempotent. A blocked player cannot send this player friend requests.
      security:
        - PlayerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_id]
              properties:
                target_id: { type: string }
      responses:
        '202': { description: Blocked }
        '400': { description: target_id missing, or equal to the caller }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/social/blocks/{target_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
      - { name: target_id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [Social]
      summary: Unblock a player
      security:
        - PlayerAuth: []
      responses:
        '204': { description: Unblocked }

  # ------------------------------------------------------- Live config
  /projects/{project_id}/environments/{environment_id}/configs:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Live Config]
      summary: Upload a config bundle
      description: 'multipart/form-data with a `config` file part and a `version` text field. Uploading does not activate.'
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [config, version]
              properties:
                config: { type: string, format: binary }
                version: { type: string, description: Your own version label. }
      responses:
        '201':
          description: Version stored
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigVersion' }
        '400': { description: Missing version or config part, or the bundle exceeds the request body limit (the message names the limit in bytes). }
    get:
      tags: [Live Config]
      summary: List config versions
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Versions
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/ConfigVersion' } }

  /projects/{project_id}/environments/{environment_id}/configs/active:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Live Config]
      summary: Get the active config version
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: The active version
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigVersion' }
        '404': { description: No version has been activated in this environment yet. Expected before the first activate. }

  /projects/{project_id}/environments/{environment_id}/configs/active/bundle:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Live Config]
      summary: Download the active bundle
      description: What a game server calls at boot to fetch live config.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Bundle bytes
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '404': { description: "No config has been activated in this environment yet. This is the expected answer for a new environment, not an auth fault - a bad key or token is 401 or 403." }

  /projects/{project_id}/environments/{environment_id}/configs/{config_id}/bundle:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: config_id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Live Config]
      summary: Download a specific version's bundle
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Bundle bytes
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '404': { description: No such version }

  /projects/{project_id}/environments/{environment_id}/configs/activate/{config_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: config_id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Live Config]
      summary: Activate a config version
      description: Exactly one version is active per environment; activating deactivates the previous one.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Now active
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigVersion' }
        '404': { description: No such version }

  # ------------------------------------------------------- Generators
  /projects/{project_id}/environments/{environment_id}/webhooks:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      description: >-
        Crux does not execute your code; it calls your service. Register an HTTPS endpoint
        and Crux POSTs a signed JSON body to it when a subscribed event happens.
        The response contains the signing `secret` and is the ONLY time it is returned -
        store it before you close the response. Verify it with the `Crux-Signature`
        header, which is `t=<unix>,v1=<hex hmac-sha256>` over `"<t>.<raw body>"`.

        Receiver checklist: the signature only helps if you also REJECT stale
        timestamps (recompute the HMAC, then refuse `t` older than ~5 minutes -
        that is what closes replay), and deduplicate on the `Crux-Delivery`
        header, because retries deliver at-least-once. Answer 2xx quickly;
        redirects are NOT followed and count as failure. The destination must be
        publicly reachable over HTTPS - private, loopback and link-local
        addresses are refused at registration and again at delivery time.
        Player events (`player.*`) are project-wide: players exist per project,
        so those payloads carry `project_id` and are delivered to every
        subscribed endpoint in the project, whatever environment it sits under.
        Every event in the catalogue is emitted by the backend today.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, event_types]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS only. Payloads carry player data, so a plaintext hop exposes it.
                  example: https://api.yourgame.com/crux/webhooks
                event_types:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/WebhookEventType' }
                  description: >-
                    Validated against the catalogue. An unknown or duplicated value is rejected
                    here rather than becoming an endpoint that is never called and never errors.
                description: { type: string }
      responses:
        '201':
          description: Created. `secret` is present here and nowhere else.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { description: url is missing or not https, or an event type is unknown or duplicated }
        '409': { description: This environment already has the maximum number of webhook endpoints }
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      description: Secrets are never included. If you lost one, delete the endpoint and create another.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Endpoints
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WebhookEndpoint' }

  /projects/{project_id}/environments/{environment_id}/webhooks/deliveries:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Webhooks]
      summary: Recent webhook deliveries
      description: >-
        Answers "why did my webhook not arrive". Includes failures and the status your
        endpoint returned, so you can see a 500 loop without asking us.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Deliveries, newest first
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WebhookDelivery' }
        '400': { description: limit is not a positive integer }

  /projects/{project_id}/environments/{environment_id}/webhooks/{webhook_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: webhook_id, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      description: Also discards that endpoint's queued deliveries.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Deleted }
        '400': { description: webhook_id is not a UUID }
        '404': { description: No such webhook endpoint in this environment }

  /projects/{project_id}/environments/{environment_id}/generators:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Generators]
      summary: Create a generator
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, spec]
              properties:
                name: { type: string }
                spec: { type: object, additionalProperties: true, description: The generator spec. Rejected if it does not compile. }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Generator' }
        '400': { description: Missing name/spec, or the spec does not compile }
    get:
      tags: [Generators]
      summary: List generators
      description: The `spec` body is omitted from list entries; fetch one generator to see it.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Generators
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Generator' } }

  /projects/{project_id}/environments/{environment_id}/generators/{generator_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: generator_id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Generators]
      summary: Get a generator, including its spec
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Generator
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Generator' }
        '404': { description: Not found }
    put:
      tags: [Generators]
      summary: Update a generator
      description: Bumps `version`.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, spec]
              properties:
                name: { type: string }
                spec: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Generator' }
        '400': { description: Spec does not compile }
        '404': { description: Not found }
    delete:
      tags: [Generators]
      summary: Delete a generator
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Deleted }

  /projects/{project_id}/environments/{environment_id}/generators/{generator_id}/schema:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: generator_id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Generators]
      summary: Get the generator's parameter schema
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: JSON Schema describing the spec's tunable parameters
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /projects/{project_id}/environments/{environment_id}/generators/{generator_id}/render:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: generator_id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Generators]
      summary: Render a world pack
      description: >
        Deterministic in the seed: the same seed and dimensions always produce the same world.
        Nothing is stored. Returns a zip. An empty body is allowed and renders at the defaults.
        Give either `size` (square) or `width` + `height`.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                seed: { type: integer, format: int64 }
                size: { type: integer, description: Square shorthand. }
                width: { type: integer }
                height: { type: integer }
      responses:
        '200':
          description: A .worldpack zip
          content:
            application/zip:
              schema: { type: string, format: binary }
        '400': { description: Requested dimensions exceed the plan's maximum }

  /projects/{project_id}/environments/{environment_id}/generators/{generator_id}/preview.png:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: generator_id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Generators]
      summary: Render a top-down preview PNG
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: seed, in: query, schema: { type: integer, format: int64 } }
        - { name: size, in: query, schema: { type: integer } }
      responses:
        '200':
          description: PNG preview
          content:
            image/png:
              schema: { type: string, format: binary }
        '400': { description: Requested size exceeds the preview maximum }


  # ------------------------------------------------------- Stats & Achievements
  /projects/{project_id}/environments/{environment_id}/achievements:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Achievements]
      summary: Define an achievement
      description: >
        `stat_key` and `threshold` must be supplied together or not at all. Half a
        binding is rejected: a stat with no threshold never fires, and a threshold with
        no stat has nothing to watch - either would look like a working achievement that
        silently never unlocks. Omit both for one the game server unlocks explicitly.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key: { type: string }
                name: { type: string, description: Defaults to key. }
                description: { type: string }
                stat_key: { type: string, description: Stat that unlocks this when it reaches threshold. }
                threshold: { type: integer, format: int64, nullable: true }
                metadata: { type: object, additionalProperties: true }
      responses:
        '201':
          description: Achievement defined
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Achievement' }
        '400': { description: key missing, or stat_key/threshold given only one of the two }
        '409': { description: An achievement with that key already exists in this environment }
    get:
      tags: [Achievements]
      summary: List the achievement catalogue
      description: Readable at runtime so a client can show everything, including what it has not earned.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Achievements
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Achievement' } }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/stats:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Stats]
      summary: List a player's stats
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: Stats, ordered by key. Empty array when the player has none.
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/PlayerStat' } }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/stats/{key}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
      - { name: key, in: path, required: true, schema: { type: string, maxLength: 255 } }
    get:
      tags: [Stats]
      summary: Read one stat
      description: >
        A stat that has never been written reads as 0 rather than 404 - "no kills yet"
        and "0 kills" are the same fact, and making callers handle both is a needless trap.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: The stat, or a zero value if it has never been written
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerStat' }
    put:
      tags: [Stats]
      summary: Set or increment a stat, and settle any achievements it unlocks
      description: >
        SERVER TOKEN ONLY. A guest token is free to anyone who downloads the game, so a
        client-writable stat is a client-writable achievement and reward.

        Send `increment: true` to add `value` to the current total, or omit it to set the
        total outright. The write and any resulting unlocks commit in ONE transaction: a
        stat that rose without its achievement firing would be a permanent silent
        inconsistency, because the threshold has already been passed and nothing would
        re-trigger it.

        `unlocked` lists only what THIS call earned, so a retry that re-crosses the same
        threshold returns an empty list and rewards cannot be paid twice.
      security:
        - ServerTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: { type: integer, format: int64 }
                increment: { type: boolean, default: false, description: Add to the current value instead of replacing it. }
      responses:
        '200':
          description: The stat's new value, plus anything it just unlocked
          content:
            application/json:
              schema:
                type: object
                properties:
                  stat: { $ref: '#/components/schemas/PlayerStat' }
                  unlocked:
                    type: array
                    description: Only achievements unlocked by THIS write. Usually empty.
                    items: { $ref: '#/components/schemas/Achievement' }
        '400': { description: value missing, or the key is empty or over 255 characters }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/achievements:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Achievements]
      summary: List the catalogue with this player's unlock state
      description: >
        Returns EVERY achievement, with `unlocked_at` null for the ones this player has
        not earned - so a UI can render locked and unlocked together instead of fetching
        both and joining by hand.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      responses:
        '200':
          description: The full catalogue, annotated for this player
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/PlayerAchievement' } }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/achievements/{key}/unlock:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
      - { name: key, in: path, required: true, schema: { type: string } }
    post:
      tags: [Achievements]
      summary: Award an achievement outright
      description: >
        For the ones no counter can express - "finished the tutorial". SERVER TOKEN ONLY.
        Idempotent: `unlocked` reports whether THIS call did it, so a retry cannot pay a
        reward twice.
      security:
        - ServerTokenAuth: []
      responses:
        '200':
          description: Unlock result
          content:
            application/json:
              schema:
                type: object
                properties:
                  unlocked: { type: boolean, description: False when the player already had it. }
                  achievement: { $ref: '#/components/schemas/Achievement' }
        '404': { description: No achievement with that key in this environment }

  # ------------------------------------------------------- Project documents
  /projects/{project_id}/environments/{environment_id}/documents:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Project Documents]
      summary: List project documents (metadata only)
      description: Returns each document's key and version WITHOUT its value. API-key callers must use a secret key; player clients use their Bearer token after login.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      responses:
        '200':
          description: Document metadata
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    key: { type: string }
                    version: { type: integer, format: int32 }
                    created_at: { type: string, format: date-time }
                    updated_at: { type: string, format: date-time }

  /projects/{project_id}/environments/{environment_id}/documents/batch-read:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Project Documents]
      summary: Read several project documents at once
      description: API-key callers must use a secret key; player clients use their Bearer token after login.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keys]
              properties:
                keys: { type: array, items: { type: string } }
      responses:
        '200':
          description: Documents that exist. Missing keys are simply absent.
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/ProjectDocument' } }
        '400': { description: Too many keys in one call }

  /projects/{project_id}/environments/{environment_id}/documents/batch-write:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    post:
      tags: [Project Documents]
      summary: Write several project documents at once
      description: Writes are server-authoritative and require a server token or secret API key. Publishable and legacy keys are rejected.
      security:
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [documents]
              properties:
                documents:
                  type: array
                  items:
                    type: object
                    required: [key, value]
                    properties:
                      key: { type: string }
                      value: { description: Arbitrary JSON. }
                      version: { type: integer, nullable: true, description: Optimistic concurrency; the write fails if it does not match. }
      responses:
        '200':
          description: Written
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/ProjectDocument' } }
        '409': { description: Version conflict }

  /projects/{project_id}/environments/{environment_id}/documents/{key}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: key, in: path, required: true, schema: { type: string } }
    get:
      tags: [Project Documents]
      summary: Read a project document
      description: API-key callers must use a secret key; player clients use their Bearer token after login.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      responses:
        '200':
          description: Document
          headers:
            ETag:
              description: The document's version, for conditional requests.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectDocument' }
        '403': { description: 'The key starts with `server:` and the caller is not a server.' }
        '404': { description: No document with that key }
    put:
      tags: [Project Documents]
      summary: Write a project document
      description: Requires a server token or secret API key. Publishable and legacy keys are rejected.
      security:
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: { description: Arbitrary JSON. }
                version: { type: integer, nullable: true }
      responses:
        '200':
          description: Written
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectDocument' }
        '409': { description: Version conflict }
    delete:
      tags: [Project Documents]
      summary: Delete a project document
      description: Requires a server token or secret API key. Publishable and legacy keys are rejected.
      security:
        - ServerTokenAuth: []
        - ApiKeyAuth: []
      responses:
        '204': { description: Deleted }

  # ------------------------------------------------------- Leaderboards (remaining)
  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/players/{player_id}/around:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: Board UUID or key. }
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Leaderboards]
      summary: Get the entries surrounding a player
      description: Returns the player's neighbours in rank order - the "you are here" slice.
      security:
        - PlayerAuth: []
        - ServerTokenAuth: []
      parameters:
        - { name: radius, in: query, schema: { type: integer, default: 3 }, description: Entries either side of the player. }
        - { name: season, in: query, schema: { type: integer }, description: Defaults to the board's current season. }
      responses:
        '200':
          description: Surrounding standings
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/LeaderboardStanding' } }
        '404': { description: No such leaderboard, or the player has no entry on it }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/warm-cache:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: Board UUID or key. }
    post:
      tags: [Leaderboards]
      summary: Rebuild the board's ranking cache
      description: Repopulates the Redis ranking set from the database. Useful after a bulk import.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200': { description: Cache warmed }
        '404': { description: No such leaderboard }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/entries/players/{player_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: Board UUID or key. }
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Leaderboards]
      summary: Get a player's standing (dashboard)
      description: Same data as the runtime route, reachable with an API key or dashboard session.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Standing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaderboardStanding' }
        '404': { description: No such leaderboard, or no entry for that player }

  /projects/{project_id}/environments/{environment_id}/leaderboards/{leaderboard_id}/entries/players/{player_id}/around:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - { name: leaderboard_id, in: path, required: true, schema: { type: string }, description: Board UUID or key. }
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Leaderboards]
      summary: Get the entries surrounding a player (dashboard)
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: radius, in: query, schema: { type: integer, default: 3 } }
        - { name: season, in: query, schema: { type: integer }, description: Defaults to the board's current season. }
      responses:
        '200':
          description: Surrounding standings
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/LeaderboardStanding' } }
        '404': { description: No such leaderboard, or no entry for that player }

  # ------------------------------------------------------- Admin player data
  /projects/{project_id}/environments/{environment_id}/players:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
    get:
      tags: [Project Admin]
      summary: List players with document counts
      description: 'Returns a bare array, not a paginated envelope - unlike /all-players.'
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1 } }
        - { name: q, in: query, schema: { type: string }, description: Substring search over the player id. }
      responses:
        '200':
          description: Summaries
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/PlayerDocumentSummary' } }
        '400': { description: limit is not a positive integer }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/all-documents:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Project Admin]
      summary: Get every document for one player
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: All documents
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/PlayerDocument' } }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/economy/admin:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Project Admin]
      summary: Get a player's economy (dashboard)
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Balances and inventory
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerEconomy' }

  # ------------------------------------------------------- Player auth (rest)
  /auth/upgrade-guest:
    post:
      tags: [Player Auth]
      summary: Attach an email + password to a guest account
      description: >
        Converts the CALLING guest into a registered player in place, keeping the same
        player_id and therefore every document, score and balance they already own. This is
        the endpoint that stops "I lost my save when I made an account".
      security:
        - PlayerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
      responses:
        '200':
          description: Upgraded. The existing tokens stay valid.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { description: Missing or malformed email/password }
        '409': { description: The player is already registered, or the email is taken }

  /auth/send-verification:
    post:
      tags: [Player Auth]
      summary: Send (or resend) the email-verification message
      security:
        - PlayerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email, description: Defaults to the player's stored email. }
      responses:
        '200': { description: Sent }
        '400': { description: The player has no email address }

  /auth/verify-email:
    post:
      tags: [Player Auth]
      summary: Verify an email address with the token
      description: No authentication - the token in the body is the credential.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '200': { description: Verified }
        '400': { description: Missing, unknown, or expired token }
    get:
      tags: [Player Auth]
      summary: Verify an email address from the emailed link
      description: >
        The link in our verification emails points here. Renders an HTML page rather than
        JSON. It exists because the POST-only version answered 405 with an empty body to
        every player who clicked a verification link.
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: An HTML confirmation page, content: { text/html: { schema: { type: string } } } }

  /auth/forgot-password:
    post:
      tags: [Player Auth]
      summary: Request a password-reset email
      description: Always answers 200, whether or not the address is registered, so it cannot be used to enumerate accounts.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200': { description: Accepted (sent only if the address exists) }
        '429': { description: Rate limited }

  /auth/reset-password:
    post:
      tags: [Player Auth]
      summary: Set a new password with a reset token
      description: No authentication - the token in the body is the credential.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string }
                password: { type: string, format: password }
      responses:
        '200': { description: Password changed }
        '400': { description: Missing, unknown, or expired token }
        '429': { description: Rate limited }
    get:
      tags: [Player Auth]
      summary: Render the password-reset form
      description: The target of the link in our reset emails. Returns HTML that posts back to this path.
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: An HTML form, content: { text/html: { schema: { type: string } } } }

  /auth/revoke-all-tokens:
    post:
      tags: [Player Auth]
      summary: Revoke every refresh token for the calling player
      description: The "sign out everywhere" control. Issued access tokens still run to their natural expiry.
      security:
        - PlayerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token: { type: string, description: Optional; revoke this one only. }
      responses:
        '200': { description: Revoked }

  # ------------------------------------------------------- Player OAuth
  /auth/oauth/connections:
    get:
      tags: [Player Auth]
      summary: List the calling player's linked OAuth accounts
      security:
        - PlayerAuth: []
      responses:
        '200':
          description: Linked providers
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    provider: { type: string, enum: [google, github, discord] }
                    email: { type: string }
                    display_name: { type: string }
                    avatar_url: { type: string }
                    connected_at: { type: string, format: date-time }
                    last_updated_at: { type: string, format: date-time }

  /auth/oauth/google/initiate:
    get:
      tags: [Player Auth]
      summary: Start a Google sign-in
      description: Returns the URL to send the player to. Crux stores the `state` and checks it on callback.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Authorization URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth_url: { type: string }
                  state: { type: string }
        '400': { description: Google OAuth is not configured for this project }

  /auth/oauth/google/callback:
    get:
      tags: [Player Auth]
      summary: Google redirect target
      description: >
        Google sends the player here. Not called by your game directly. Finds or creates the
        matching player and returns a token pair, or completes a link started via `/link`.
      parameters:
        - { name: state, in: query, required: true, schema: { type: string } }
        - { name: code, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Signed in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { description: Missing state/code, or the state is unknown or expired }

  /auth/oauth/google/link:
    post:
      tags: [Player Auth]
      summary: Link a Google account to the calling player
      description: Returns an authorization URL, exactly like `/initiate`; the callback completes the link.
      security:
        - PlayerAuth: []
      responses:
        '200':
          description: Authorization URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth_url: { type: string }
                  state: { type: string }
        '409': { description: A Google account is already linked }

  /auth/oauth/google/unlink:
    delete:
      tags: [Player Auth]
      summary: Unlink the player's Google account
      security:
        - PlayerAuth: []
      responses:
        '200': { description: Unlinked }
        '404': { description: No Google account is linked }

  /auth/oauth/github/initiate:
    get:
      tags: [Player Auth]
      summary: Start a GitHub sign-in
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Authorization URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth_url: { type: string }
                  state: { type: string }
        '400': { description: GitHub OAuth is not configured for this project }

  /auth/oauth/github/callback:
    get:
      tags: [Player Auth]
      summary: GitHub redirect target
      parameters:
        - { name: state, in: query, required: true, schema: { type: string } }
        - { name: code, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Signed in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { description: Missing state/code, or the state is unknown or expired }

  /auth/oauth/github/link:
    post:
      tags: [Player Auth]
      summary: Link a GitHub account to the calling player
      security:
        - PlayerAuth: []
      responses:
        '200':
          description: Authorization URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth_url: { type: string }
                  state: { type: string }
        '409': { description: A GitHub account is already linked }

  /auth/oauth/github/unlink:
    delete:
      tags: [Player Auth]
      summary: Unlink the player's GitHub account
      security:
        - PlayerAuth: []
      responses:
        '200': { description: Unlinked }
        '404': { description: No GitHub account is linked }

  /auth/oauth/discord/initiate:
    get:
      tags: [Player Auth]
      summary: Start a Discord sign-in
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Authorization URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth_url: { type: string }
                  state: { type: string }
        '400': { description: Discord OAuth is not configured for this project }

  /auth/oauth/discord/callback:
    get:
      tags: [Player Auth]
      summary: Discord redirect target
      parameters:
        - { name: state, in: query, required: true, schema: { type: string } }
        - { name: code, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Signed in
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { description: Missing state/code, or the state is unknown or expired }

  /auth/oauth/discord/link:
    post:
      tags: [Player Auth]
      summary: Link a Discord account to the calling player
      security:
        - PlayerAuth: []
      responses:
        '200':
          description: Authorization URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth_url: { type: string }
                  state: { type: string }
        '409': { description: A Discord account is already linked }

  /auth/oauth/discord/unlink:
    delete:
      tags: [Player Auth]
      summary: Unlink the player's Discord account
      security:
        - PlayerAuth: []
      responses:
        '200': { description: Unlinked }
        '404': { description: No Discord account is linked }

  # ------------------------------------------------------- Admin player documents
  /projects/{project_id}/environments/{environment_id}/players/{player_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
    get:
      tags: [Project Admin]
      summary: List one player's documents (metadata only)
      description: Returns each document's key and version WITHOUT its value. Use `/all-documents` for bodies.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Document metadata; `value` is absent on every entry
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/PlayerDocument' } }
    delete:
      tags: [Project Admin]
      summary: Delete ALL of one player's documents
      description: Irreversible. Removes every document for this player in this environment.
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Deleted }

  /projects/{project_id}/environments/{environment_id}/players/{player_id}/{key}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EnvironmentId'
      - $ref: '#/components/parameters/PlayerId'
      - name: key
        in: path
        required: true
        schema: { type: string }
        description: >
          Document key. RESERVED WORDS: `documents`, `economy`, `social`, `stats`,
          `achievements` and `all-documents` are sibling routes and win the match, so a
          document with one of those keys cannot be reached through this admin path. Use the runtime
          `.../players/{player_id}/documents/{key}` route for those.
    get:
      tags: [Project Admin]
      summary: Read one player document (dashboard)
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '200':
          description: Document
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerDocument' }
        '404': { description: No document with that key }
    put:
      tags: [Project Admin]
      summary: Write one player document (dashboard)
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: { description: Arbitrary JSON. }
                version: { type: integer, nullable: true }
      responses:
        '200':
          description: Written
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerDocument' }
        '409': { description: Version conflict }
    patch:
      tags: [Project Admin]
      summary: Patch one player document (dashboard)
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operations]
              properties:
                version: { type: integer, nullable: true }
                operations:
                  type: array
                  items:
                    type: object
                    required: [path]
                    properties:
                      op: { type: string, enum: [set, remove], default: set }
                      path: { type: array, items: { type: string } }
                      value: { nullable: true }
                      create_missing: { type: boolean }
      responses:
        '200':
          description: Patched
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlayerDocument' }
        '409': { description: Version conflict }
    delete:
      tags: [Project Admin]
      summary: Delete one player document (dashboard)
      security:
        - ApiKeyAuth: []
        - CustomerSession: []
          CsrfToken: []
      responses:
        '204': { description: Deleted }
