openapi: 3.1.0
info:
  title: Warrium Platform API
  version: 1.5.0
  description: |
    Platform-owned identity, statistics, and fair-play contracts. These routes
    are intentionally separate from a particular game's rules contract.
servers:
  - url: http://localhost:4000
security:
  - BearerAuth: []
paths:
  /api/auth/register:
    post:
      security: []
      operationId: register
      description: Creates an immediate session whose user has emailVerified=false.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/RegisterRequest"}
      responses:
        "200": {$ref: "#/components/responses/Session"}
        "400": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/login:
    post:
      security: []
      operationId: login
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/LoginRequest"}
      responses:
        "200":
          description: Session or a five-minute TOTP challenge
          content:
            application/json:
              schema:
                oneOf:
                  - {$ref: "#/components/schemas/SessionResponse"}
                  - {$ref: "#/components/schemas/TotpChallenge"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/verify-totp:
    post:
      security: []
      operationId: verifyLoginTotp
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/VerifyTotpRequest"}
      responses:
        "200": {$ref: "#/components/responses/Session"}
        "400": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/forgot-password:
    post:
      security: []
      operationId: forgotPassword
      description: |
        Always returns the same success envelope. Codes never authorize a
        reset for a missing or unverified account.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/EmailRequest"}
      responses:
        "200": {$ref: "#/components/responses/Success"}
        "400": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/reset-password:
    post:
      security: []
      operationId: resetPassword
      description: |
        The response does not disclose whether the email exists or is
        verified. A usable session or TOTP challenge is issued only for a
        verified account and a valid one-time code.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/ResetPasswordRequest"}
      responses:
        "200":
          description: |
            Session, or a five-minute TOTP challenge when MFA is enabled. For
            MFA accounts the password change and prior-session revocation are
            committed only when the challenge is completed.
          content:
            application/json:
              schema:
                oneOf:
                  - {$ref: "#/components/schemas/SessionResponse"}
                  - {$ref: "#/components/schemas/TotpChallenge"}
        "400": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/send-login-code:
    post:
      security: []
      operationId: sendLoginCode
      description: |
        Always returns the same success envelope. Codes never authorize a
        login for a missing or unverified account.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/EmailRequest"}
      responses:
        "200": {$ref: "#/components/responses/Success"}
        "400": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/verify-login-code:
    post:
      security: []
      operationId: verifyLoginCode
      description: |
        The response does not disclose whether the email exists or is
        verified. A usable session or TOTP challenge is issued only for a
        verified account and a valid one-time code.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/VerifyCodeRequest"}
      responses:
        "200":
          description: Session or a five-minute TOTP challenge when MFA is enabled
          content:
            application/json:
              schema:
                oneOf:
                  - {$ref: "#/components/schemas/SessionResponse"}
                  - {$ref: "#/components/schemas/TotpChallenge"}
        "400": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/email-verification/send:
    post:
      operationId: sendEmailVerification
      description: |
        Invalidates any prior email-verification code and sends a new
        ten-minute one-time code to the authenticated user's email. Already
        verified users receive the same idempotent success envelope.
      responses:
        "200": {$ref: "#/components/responses/Success"}
        "401": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/email-verification/confirm:
    post:
      operationId: confirmEmailVerification
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/EmailCodeRequest"}
      responses:
        "200":
          description: Email ownership confirmed
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [success, user]
                properties:
                  success: {type: boolean, const: true}
                  user: {$ref: "#/components/schemas/User"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/me:
    get:
      operationId: currentUser
      responses:
        "200":
          description: Current user
          content:
            application/json:
              schema:
                type: object
                required: [user]
                properties:
                  user: {$ref: "#/components/schemas/User"}
        "401": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/data-export:
    post:
      operationId: exportAccountData
      description: |
        Returns a machine-readable export of the authenticated human account's
        identity, authentication metadata, fair-play records, Classic scores,
        and game memberships. Current password proof is always required; an
        active TOTP or recovery code is additionally required when MFA is
        enabled. Secret values, credential hashes, other players' private data,
        and full shared game-state payloads are never exported.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/CurrentCredentialRequest"}
      responses:
        "200":
          description: Consistent account-data export
          content:
            application/json:
              schema: {$ref: "#/components/schemas/AccountDataExport"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/account:
    delete:
      operationId: eraseAccount
      description: |
        Irreversibly disables the authenticated human account, revokes every
        session and authentication factor, removes public score and fair-play
        data, and pseudonymizes identity-bearing retained game records. Current
        password proof is always required; an active TOTP or recovery code is
        additionally required when MFA is enabled. The operation is rejected
        while the account participates in or owns a pending/active game, or
        while a completed game's projections are unfinished. A pseudonymous
        identity tombstone remains to preserve completed-game integrity.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/CurrentCredentialRequest"}
      responses:
        "200":
          description: Account erased and all sessions revoked
          content:
            application/json:
              schema: {$ref: "#/components/schemas/AccountErasureReceipt"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "409": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/logout:
    post:
      operationId: logout
      responses:
        "200":
          description: Session revoked
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Success"}
        "401": {$ref: "#/components/responses/Error"}
  /api/auth/username:
    post:
      operationId: updateUsername
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [username]
              properties:
                username: {type: string, maxLength: 20}
      responses:
        "200":
          description: Updated user
          content:
            application/json:
              schema:
                type: object
                required: [success, user]
                properties:
                  success: {type: boolean, const: true}
                  user: {$ref: "#/components/schemas/User"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/totp/enable:
    post:
      operationId: enableTotp
      description: |
        Stages a new authenticator secret without changing the active factor.
        Initial enrollment requires current_password so possession of a bearer
        session alone cannot take over the account's second factor.
        When TOTP is already enabled, current_code is required and must prove
        the active factor before a replacement enrollment is staged.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/BeginTotpRequest"}
      responses:
        "200":
          description: Enrollment material; TOTP remains disabled until confirm
          content:
            application/json:
              schema:
                type: object
                required: [success, secret, qr_uri, recovery_codes]
                properties:
                  success: {type: boolean, const: true}
                  secret: {type: string}
                  qr_uri: {type: string}
                  recovery_codes:
                    type: array
                    minItems: 4
                    maxItems: 4
                    items:
                      type: string
                      minLength: 16
                      maxLength: 16
                      pattern: '^[a-z2-7]{16}$'
        "401": {$ref: "#/components/responses/Error"}
        "400": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "409": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/totp/confirm:
    post:
      operationId: confirmTotp
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/SecondFactorCodeRequest"}
      responses:
        "200": {$ref: "#/components/responses/Success"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/auth/totp/disable:
    post:
      operationId: disableTotp
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/SecondFactorCodeRequest"}
      responses:
        "200": {$ref: "#/components/responses/Success"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "413": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/stats:
    get:
      security: []
      operationId: platformStats
      responses:
        "200":
          description: Runtime diagnostics with optional durable game totals
          content:
            application/json:
              schema:
                type: object
                required: [memoryUsedMB, availableMaps, processes, activeGames]
                properties:
                  memoryUsedMB: {type: integer, minimum: 0}
                  availableMaps:
                    type: array
                    items: {type: string}
                  processes: {type: integer, minimum: 0}
                  activeGames: {type: [integer, "null"], minimum: 0}
                  totalGames: {type: integer, minimum: 0}
                  completedGames: {type: integer, minimum: 0}
                  winningPlayers: {type: integer, minimum: 0}
        "503": {$ref: "#/components/responses/Error"}
  /api/scoreboards/game-001:
    get:
      security: []
      operationId: game001Scoreboards
      description: All-time individual results for Warrium Game 001.
      responses:
        "200":
          description: Open, human-only, and bot-only scoreboards
          headers:
            Cache-Control:
              description: Public cache policy for the current scoreboard snapshot.
              schema: {type: string}
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Game001Scoreboards"
        "503": {$ref: "#/components/responses/Error"}
  /api/fair-play/profile:
    get:
      operationId: ownFairPlayProfile
      responses:
        "200": {$ref: "#/components/responses/PrivateFairPlayProfile"}
        "401": {$ref: "#/components/responses/Error"}
  /api/fair-play/profile/{userId}:
    get:
      security: []
      operationId: publicFairPlayProfile
      parameters:
        - name: userId
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200": {$ref: "#/components/responses/PublicFairPlayProfile"}
        "404": {$ref: "#/components/responses/Error"}
  /api/fair-play/play-mode:
    put:
      operationId: updatePlayMode
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [playMode]
              properties:
                playMode: {$ref: "#/components/schemas/PlayMode"}
      responses:
        "200": {$ref: "#/components/responses/Success"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
  /api/fair-play/match-preference:
    put:
      operationId: updateMatchPreference
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [matchPreference]
              properties:
                matchPreference: {type: string, enum: [any, humans_only, bots_ok]}
      responses:
        "200": {$ref: "#/components/responses/Success"}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
  /api/leaderboards/classic:
    get:
      operationId: classicCareerLeaderboard
      responses:
        "200":
          description: Top Classic careers and the authenticated user's service record
          content:
            application/json:
              schema: {$ref: "#/components/schemas/ClassicCareerLeaderboard"}
        "401": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
  /api/leaderboards/classic/visibility:
    put:
      operationId: updateClassicCareerVisibility
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [visible]
              properties:
                visible: {type: boolean}
      responses:
        "200":
          description: Visibility preference updated
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [success, visible]
                properties:
                  success: {type: boolean, const: true}
                  visible: {type: boolean}
        "400": {$ref: "#/components/responses/Error"}
        "401": {$ref: "#/components/responses/Error"}
        "503": {$ref: "#/components/responses/Error"}
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: Authenticated human platform session; bot-game credentials are rejected
  responses:
    Session:
      description: Authenticated session
      content:
        application/json:
          schema: {$ref: "#/components/schemas/SessionResponse"}
    Success:
      description: Operation completed
      content:
        application/json:
          schema: {$ref: "#/components/schemas/Success"}
    Error:
      description: Canonical platform error envelope
      content:
        application/json:
          schema: {$ref: "#/components/schemas/ErrorResponse"}
    PublicFairPlayProfile:
      description: Public consistency profile
      content:
        application/json:
          schema: {$ref: "#/components/schemas/PublicFairPlayProfile"}
    PrivateFairPlayProfile:
      description: Full consistency profile for the authenticated user
      content:
        application/json:
          schema: {$ref: "#/components/schemas/PrivateFairPlayProfile"}
  schemas:
    RegisterRequest:
      type: object
      additionalProperties: false
      required: [email, password]
      properties:
        email: {$ref: "#/components/schemas/EmailAddress"}
        password:
          type: string
          minLength: 8
          maxLength: 72
          description: Must contain at least 8 characters and at most 72 UTF-8 bytes.
        username: {type: string, maxLength: 20}
    LoginRequest:
      type: object
      additionalProperties: false
      required: [email, password]
      properties:
        email: {$ref: "#/components/schemas/EmailAddress"}
        password:
          type: string
          minLength: 1
          maxLength: 72
          description: The UTF-8 encoding must not exceed bcrypt's 72-byte boundary.
    VerifyTotpRequest:
      type: object
      additionalProperties: false
      required: [totp_token, code]
      properties:
        totp_token: {$ref: "#/components/schemas/TotpChallengeToken"}
        code: {$ref: "#/components/schemas/SecondFactorCode"}
    EmailRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email: {$ref: "#/components/schemas/EmailAddress"}
    VerifyCodeRequest:
      type: object
      additionalProperties: false
      required: [email, code]
      properties:
        email: {$ref: "#/components/schemas/EmailAddress"}
        code: {$ref: "#/components/schemas/EmailCode"}
    ResetPasswordRequest:
      type: object
      additionalProperties: false
      required: [email, code, password]
      properties:
        email: {$ref: "#/components/schemas/EmailAddress"}
        code: {$ref: "#/components/schemas/EmailCode"}
        password:
          type: string
          minLength: 8
          maxLength: 72
          description: Must contain at least 8 characters and at most 72 UTF-8 bytes.
    EmailCodeRequest:
      type: object
      additionalProperties: false
      required: [code]
      properties:
        code: {$ref: "#/components/schemas/EmailCode"}
    SecondFactorCodeRequest:
      type: object
      additionalProperties: false
      required: [code]
      properties:
        code: {$ref: "#/components/schemas/SecondFactorCode"}
    BeginTotpRequest:
      type: object
      additionalProperties: false
      properties:
        current_code:
          $ref: "#/components/schemas/SecondFactorCode"
          description: Required when replacing an already-enabled factor.
        current_password:
          type: string
          minLength: 1
          maxLength: 72
          description: |
            Required for initial enrollment when no factor is active. Its UTF-8
            encoding must not exceed bcrypt's 72-byte boundary.
    CurrentCredentialRequest:
      type: object
      additionalProperties: false
      required: [currentPassword]
      properties:
        currentPassword:
          type: string
          minLength: 1
          maxLength: 72
          description: The UTF-8 encoding must not exceed bcrypt's 72-byte boundary.
        currentCode:
          $ref: "#/components/schemas/SecondFactorCode"
          description: Required when TOTP is enabled.
    AccountDataExport:
      type: object
      additionalProperties: false
      required:
        [exportVersion, generatedAt, account, sessions, emailCodes, fairPlay,
         fairPlayEvents, fairPlayGameSummaries, classicScores, games, retention]
      properties:
        exportVersion: {type: integer, const: 1}
        generatedAt: {type: string, format: date-time}
        account:
          type: object
          additionalProperties: false
          required: [id, email, username, emailVerified, totpEnabled, playMode, createdAt]
          properties:
            id: {type: string, format: uuid}
            email: {$ref: "#/components/schemas/EmailAddress"}
            username: {type: [string, "null"]}
            emailVerified: {type: boolean}
            totpEnabled: {type: boolean}
            playMode: {$ref: "#/components/schemas/PlayMode"}
            createdAt: {type: string, format: date-time}
        sessions:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [createdAt, expiresAt]
            properties:
              createdAt: {type: string, format: date-time}
              expiresAt: {type: string, format: date-time}
        emailCodes:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [purpose, used, createdAt, expiresAt, deliveryStatus]
            properties:
              purpose: {type: string, enum: [password_reset, login, email_verification]}
              used: {type: boolean}
              createdAt: {type: string, format: date-time}
              expiresAt: {type: string, format: date-time}
              deliveryStatus: {type: [string, "null"]}
        fairPlay:
          oneOf:
            - {$ref: "#/components/schemas/PrivateFairPlayProfile"}
            - {type: "null"}
        fairPlayEvents:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [eventType, details, createdAt]
            properties:
              eventType: {type: string}
              details: {type: object}
              createdAt: {type: string, format: date-time}
        fairPlayGameSummaries:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
              [gameId, slotId, draftActions, attackActions, transferActions,
               reinforceActions, endTurnActions, territoriesStart,
               territoriesEnd, territoriesPeak, conquests, losses,
               armiesDrafted, armiesLostAttacking, armiesLostDefending,
               attackWinRate, turnHashes, won]
            properties:
              gameId: {type: string, format: uuid}
              slotId: {type: string}
              draftActions: {type: integer, minimum: 0}
              attackActions: {type: integer, minimum: 0}
              transferActions: {type: integer, minimum: 0}
              reinforceActions: {type: integer, minimum: 0}
              endTurnActions: {type: integer, minimum: 0}
              territoriesStart: {type: integer, minimum: 0}
              territoriesEnd: {type: integer, minimum: 0}
              territoriesPeak: {type: integer, minimum: 0}
              conquests: {type: integer, minimum: 0}
              losses: {type: integer, minimum: 0}
              armiesDrafted: {type: integer, minimum: 0}
              armiesLostAttacking: {type: integer, minimum: 0}
              armiesLostDefending: {type: integer, minimum: 0}
              attackWinRate: {type: [number, "null"], minimum: 0, maximum: 1}
              turnHashes: {}
              won: {type: boolean}
        classicScores:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [category, gamesPlayed, wins, lastCompletedAt]
            properties:
              category: {type: string, enum: [open, humans, bots]}
              gamesPlayed: {type: integer, minimum: 1}
              wins: {type: integer, minimum: 0}
              lastCompletedAt: {type: string, format: date-time}
        games:
          type: array
          items:
            type: object
            additionalProperties: false
            required:
              [gameId, status, category, createdByAccount, slotId, callsign,
               won, joinedAt, leftAt, startedAt, finishedAt]
            properties:
              gameId: {type: string, format: uuid}
              status: {type: string, enum: [pending, active, completed, expired, abandoned]}
              category: {type: string, enum: [open, humans, bots]}
              createdByAccount: {type: boolean}
              slotId: {type: [string, "null"]}
              callsign: {type: [string, "null"]}
              won: {type: [boolean, "null"]}
              joinedAt: {type: [string, "null"], format: date-time}
              leftAt: {type: [string, "null"], format: date-time}
              startedAt: {type: [string, "null"], format: date-time}
              finishedAt: {type: [string, "null"], format: date-time}
        retention:
          $ref: "#/components/schemas/AccountRetentionDisclosure"
    AccountRetentionDisclosure:
      type: object
      additionalProperties: false
      required: [accountTombstone, detailedGameplay, backups]
      properties:
        accountTombstone: {type: string}
        detailedGameplay: {type: string}
        backups: {type: string}
    AccountErasureReceipt:
      type: object
      additionalProperties: false
      required: [success, erasedAt, pseudonymizedGames, retention]
      properties:
        success: {type: boolean, const: true}
        erasedAt: {type: string, format: date-time}
        pseudonymizedGames: {type: integer, minimum: 0}
        retention: {$ref: "#/components/schemas/AccountRetentionDisclosure"}
    User:
      type: object
      required: [id, email, username, emailVerified, totpEnabled, playMode]
      properties:
        id: {type: string, format: uuid}
        email: {$ref: "#/components/schemas/EmailAddress"}
        username: {type: [string, "null"]}
        emailVerified: {type: boolean}
        totpEnabled: {type: boolean}
        playMode: {$ref: "#/components/schemas/PlayMode"}
    SessionResponse:
      type: object
      required: [success, token, user]
      properties:
        success: {type: boolean, const: true}
        token: {type: string}
        user: {$ref: "#/components/schemas/User"}
    TotpChallenge:
      type: object
      required: [success, requires_totp, totp_token]
      properties:
        success: {type: boolean, const: true}
        requires_totp: {type: boolean, const: true}
        totp_token: {$ref: "#/components/schemas/TotpChallengeToken"}
    EmailAddress:
      type: string
      format: email
      maxLength: 254
    EmailCode:
      type: string
      pattern: '^[0-9]{6}$'
    SecondFactorCode:
      type: string
      minLength: 6
      maxLength: 16
      pattern: '^(?:[0-9]{6}|[a-z2-7]{8}|[a-z2-7]{16})$'
      description: Six-digit authenticator code or a lowercase legacy/new recovery code.
    TotpChallengeToken:
      type: string
      minLength: 43
      maxLength: 43
      pattern: '^[A-Za-z0-9_-]{43}$'
    Success:
      type: object
      required: [success]
      properties:
        success: {type: boolean, const: true}
        message: {type: string}
    ErrorResponse:
      type: object
      required: [success, code, error]
      properties:
        success: {type: boolean, const: false}
        code: {type: string}
        error: {type: string}
        fields:
          type: object
          additionalProperties:
            type: array
            items: {type: string}
    Game001Scoreboards:
      type: object
      additionalProperties: false
      required: [generatedAt, scoreboards]
      properties:
        generatedAt: {type: string, format: date-time}
        scoreboards:
          type: array
          minItems: 3
          maxItems: 3
          items: {$ref: "#/components/schemas/Game001CategoryScoreboard"}
    Game001CategoryScoreboard:
      type: object
      additionalProperties: false
      required: [category, entries]
      properties:
        category: {type: string, enum: [open, humans, bots]}
        entries:
          type: array
          maxItems: 10
          items: {$ref: "#/components/schemas/Game001ScoreboardEntry"}
    Game001ScoreboardEntry:
      type: object
      additionalProperties: false
      required: [rank, name, publicTag, isBot, gamesPlayed, wins, winRate]
      properties:
        rank: {type: integer, minimum: 1, maximum: 10}
        name: {type: string, minLength: 1, maxLength: 20}
        publicTag:
          type: string
          pattern: '^[A-F0-9]{8}$'
          description: Stable disambiguator derived from the player's public identity.
        isBot: {type: boolean}
        gamesPlayed: {type: integer, minimum: 1}
        wins: {type: integer, minimum: 0}
        winRate: {type: number, minimum: 0, maximum: 1}
    PlayMode:
      type: string
      enum: [human, ai_assisted, automated]
    ClassicCareerLeaderboard:
      type: object
      additionalProperties: false
      required: [entries, me]
      properties:
        entries:
          type: array
          maxItems: 10
          items: {$ref: "#/components/schemas/ClassicCareerEntry"}
        me: {$ref: "#/components/schemas/ClassicCareerRecord"}
    ClassicCareerEntry:
      type: object
      additionalProperties: false
      required: [rank, callsign, playMode, effectiveMode, gamesPlayed, wins, losses, winRate]
      properties:
        rank: {type: integer, minimum: 1}
        callsign: {type: string, minLength: 1, maxLength: 20}
        playMode: {$ref: "#/components/schemas/PlayMode"}
        effectiveMode:
          type: [string, "null"]
          enum: [human, ai_assisted, automated, null]
        gamesPlayed: {type: integer, minimum: 1}
        wins: {type: integer, minimum: 0}
        losses: {type: integer, minimum: 0}
        winRate: {type: number, minimum: 0, maximum: 1}
    ClassicCareerRecord:
      type: object
      additionalProperties: false
      required: [rank, callsign, playMode, effectiveMode, gamesPlayed, wins, losses, winRate, visible, eligible, recordStatus]
      properties:
        rank: {type: [integer, "null"], minimum: 1}
        callsign: {type: [string, "null"], maxLength: 20}
        playMode: {$ref: "#/components/schemas/PlayMode"}
        effectiveMode:
          type: [string, "null"]
          enum: [human, ai_assisted, automated, null]
        gamesPlayed: {type: integer, minimum: 0}
        wins: {type: integer, minimum: 0}
        losses: {type: integer, minimum: 0}
        winRate: {type: number, minimum: 0, maximum: 1}
        visible: {type: boolean}
        eligible: {type: boolean}
        recordStatus: {type: string, enum: [current, updating, delayed]}
    PublicFairPlayProfile:
      type: object
      required: [playMode, effectiveMode, totalGames, consistencyScore, tier]
      properties:
        playMode: {$ref: "#/components/schemas/PlayMode"}
        effectiveMode: {type: [string, "null"]}
        totalGames: {type: integer, minimum: 0}
        consistencyScore: {type: number, minimum: 0, maximum: 1}
        tier: {type: string, enum: [clear, nudged, badged, adjusted]}
    PrivateFairPlayProfile:
      allOf:
        - {$ref: "#/components/schemas/PublicFairPlayProfile"}
        - type: object
          required: [matchPreference, gamesLast24h, concurrentGamesMax, longestSessionMin, avgGamesPerDay, decisionScore, nudgeCount, lastNudgeAt]
          properties:
            matchPreference: {type: string, enum: [any, humans_only, bots_ok]}
            gamesLast24h: {type: integer, minimum: 0}
            concurrentGamesMax: {type: integer, minimum: 0}
            longestSessionMin: {type: integer, minimum: 0}
            avgGamesPerDay: {type: number, minimum: 0}
            decisionScore: {type: [number, "null"]}
            nudgeCount: {type: integer, minimum: 0}
            lastNudgeAt: {type: [string, "null"], format: date-time}
