# elite 租戶整合 API（/api/tenant/v1）— OpenAPI 3.1
#
# 單一真相（docs/13 §1）：/doc 的 API 參考頁、Postman 集合與錯誤碼表都由這份檔案產生。
# 內容以實作為準：workers/edge/src/tenant-api.ts、http.ts、players.ts，workers/wallet/src/tenant-gate.ts，
# packages/shared/src/signing.ts、keys.ts、money.ts。改 API 就要同步改這裡（packages/api-spec 的測試會檢查格式與 $ref）。
#
# 多語系：summary／description／title 以繁體中文為主；英文放在同一層的 x-summary-en、x-description-en、x-title-en。
# 簡體中文由 /doc 建置時自動從繁體轉換，不另外維護。

openapi: 3.1.0
jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base

info:
  title: elite 租戶整合 API
  x-title-en: elite Tenant Integration API
  version: 1.0.0
  summary: 真人百家樂／龍虎／德州撲克／牛牛 SaaS 的租戶後端整合 API（v1）
  x-summary-en: Server-to-server integration API for the elite live casino SaaS — baccarat, dragon tiger, Texas Hold'em and Niu Niu (v1)
  description: |
    租戶（營運商）後端呼叫的整合 API：啟動玩家遊戲、轉帳錢包、注單同步、桌檯與局結果、帳務查詢。

    - **只能由伺服器呼叫**：每個請求都以 API 金鑰的 Secret 做 HMAC-SHA256 簽章，Secret 絕不可放在瀏覽器或 App。
    - **回應格式**：成功 `{"ok": true, "data": {…}}`；失敗 `{"ok": false, "error": {"code": "…", "message": "…"}}`，HTTP 狀態碼有意義。
    - **金額一律是十進位字串**：玩家金額最多 4 位小數（例如 `"100"`、`"0.95"`），帳務金額（USD）最多 6 位小數。
    - **時間**：回應中的時間是 UTC ISO-8601（例如 `2026-09-24T03:00:00.000Z`）。
    - **沙箱與正式**：沙箱金鑰（`ek_s_…`）連到成對的沙箱租戶（測試幣、模擬器桌），正式金鑰（`ek_l_…`）連到正式租戶；兩者使用同一個主機與同一套 API。
  x-description-en: |
    The API your (the operator's) back end calls to launch players into the game, move money in and out of the transfer wallet, sync bets, read tables and round results, and check your account.

    - **Server-to-server only**: every request is signed with HMAC-SHA256 using your API key's secret. Never ship the secret to a browser or app.
    - **Response envelope**: success `{"ok": true, "data": {…}}`; failure `{"ok": false, "error": {"code": "…", "message": "…"}}`. HTTP status codes are meaningful.
    - **Amounts are decimal strings**: player amounts have at most 4 decimals (for example `"100"`, `"0.95"`); account (USD) amounts have at most 6 decimals.
    - **Times** in responses are UTC ISO-8601 (for example `2026-09-24T03:00:00.000Z`).
    - **Sandbox and live**: a sandbox key (`ek_s_…`) belongs to your paired sandbox tenant (test coins, simulator tables); a live key (`ek_l_…`) belongs to your live tenant. Both use the same host and the same API.
  contact:
    name: elite
    url: https://elite.ewin888.com/doc/support

servers:
  - url: https://elite.ewin888.com/api/tenant/v1
    description: 正式環境（沙箱金鑰與正式金鑰都使用這個主機）
    x-description-en: Production (both sandbox and live keys use this host)
  - url: https://elite-dev.ewin888.com/api/tenant/v1
    description: 測試環境（獨立部署；帳號、金鑰與資料都和正式環境分開）
    x-description-en: Test environment (separate deployment; accounts, keys and data are separate from production)

security:
  - ApiKey: []
    Timestamp: []
    Nonce: []
    Signature: []

tags:
  - name: player
    x-displayName: 玩家
    x-displayName-en: Players
    description: 啟動遊戲、登出、查詢與更新玩家。玩家第一次 launch（或第一次轉入）時自動建立，不需要另外註冊。
    x-description-en: Launch, log out, read and update players. A player is created automatically on the first launch (or first deposit); there is no separate sign-up call.
    x-guide: guide/launch
  - name: wallet
    x-displayName: 錢包（轉帳）
    x-displayName-en: Wallet (transfer)
    description: 轉帳錢包：轉入、轉出、查詢轉帳結果與餘額。轉帳以 `txnId` 冪等。
    x-description-en: 'Transfer wallet: deposit, withdraw, look up a transfer and read the balance. Transfers are idempotent on `txnId`.'
    x-guide: guide/wallet
  - name: bets
    x-displayName: 注單
    x-displayName-en: Bets
    description: 以游標增量同步已結算注單，以及每日彙總。
    x-description-en: Incremental, cursor-based sync of settled bets, and daily summaries.
    x-guide: guide/bets
  - name: tables
    x-displayName: 桌檯與局
    x-displayName-en: Tables and rounds
    description: 桌檯清單、以 API 啟用或停用桌檯（受方案配額限制），以及查詢單局結果。
    x-description-en: List tables, enable or disable tables through the API (subject to your plan quota), and read a round result.
    x-guide: guide/tables-rounds
  - name: account
    x-displayName: 帳務
    x-displayName-en: Account
    description: 本平台向你收費的部分：方案與帳務狀態、預付額度、每日用量、月結單。儲值只能在 Console 進行。
    x-description-en: 'What the platform bills you: plan and account state, prepaid credit, daily usage and monthly statements. Top-ups are done in the Console only.'
    x-guide: guide/billing

x-signature:
  description: |
    簽章字串為下列五行以 `\n`（LF）連接，不含結尾換行：

    ```
    METHOD            大寫的 HTTP 方法，例如 POST
    PATH_AND_QUERY    路徑加上查詢字串，例如 /api/tenant/v1/wallet/balance?username=alice
    X-Timestamp       Unix 秒（字串照原樣）
    X-Nonce           16–64 個英數字
    hex(SHA-256(body))  請求本文位元組的 SHA-256（小寫 hex）；沒有本文時為空字串的雜湊
    ```

    `X-Signature = hex(HMAC-SHA256(secret, 簽章字串))`，以小寫 hex 表示。
  x-description-en: |
    The string to sign is these five lines joined with `\n` (LF), with no trailing newline:

    ```
    METHOD            the HTTP method in upper case, e.g. POST
    PATH_AND_QUERY    path plus query string, e.g. /api/tenant/v1/wallet/balance?username=alice
    X-Timestamp       Unix seconds (the header string as sent)
    X-Nonce           16–64 letters and digits
    hex(SHA-256(body))  SHA-256 of the request body bytes (lower-case hex); the hash of the empty string when there is no body
    ```

    `X-Signature = hex(HMAC-SHA256(secret, string to sign))` in lower-case hex.
  algorithm: HMAC-SHA256
  maxSkewSeconds: 300
  timestampPattern: '^\d{9,11}$'
  noncePattern: '^[A-Za-z0-9]{16,64}$'
  nonceTtlSeconds: 600
  maxBodyBytes: 65536
  emptyBodySha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  workedExamples:
    - name: launch
      keyId: ek_s_1a_XXXXXXXXXXXXXXXX
      secret: es_0123456789abcdefghijABCDEFGHIJ0123456789
      method: POST
      path: /api/tenant/v1/player/launch
      timestamp: '1790218800'
      nonce: n0nce8H2kQ9xYz4LmP0aBcDe
      body: '{"username":"alice","lang":"ENG","device":"mobile"}'
      bodySha256: a0c1cfba44962853266340b6610d542620041fae49e6bcb10e6446ca0f73ad6a
      signature: 6e4885c5d9f191b2e4ee5f4cb40ae148b13b277e606181bf853e040a06a0ac80
    - name: balance
      keyId: ek_s_1a_XXXXXXXXXXXXXXXX
      secret: es_0123456789abcdefghijABCDEFGHIJ0123456789
      method: GET
      path: /api/tenant/v1/wallet/balance?username=alice
      timestamp: '1790218800'
      nonce: n0nce8H2kQ9xYz4LmP0aBcDf
      body: ''
      bodySha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
      signature: 273b761bda9ca095a8c7ae22908844bc1ce728212fceda3d0b2129fc9bb03032

paths:
  /player/launch:
    post:
      tags: [player]
      operationId: launchPlayer
      summary: 啟動遊戲
      x-summary-en: Launch a player
      description: |
        取得玩家進入遊戲的**一次性網址**（60 秒內有效、只能開啟一次）。玩家不存在時自動建立。

        - **單一登入**：呼叫 launch 時，這位玩家已開啟的 session 立即失效並被踢出（原因 `SESSION_REPLACED`）；玩家**開啟**網址時會再做一次。所以同時發出多個網址時，以最後開啟的那一個為準，先開啟的 session 會被踢出。
        - 取得網址後，把玩家的瀏覽器導向（或以 iframe 開啟）這個網址；過期或已使用的網址會顯示「連結已失效」（HTTP 410）。
        - `nickname`、`currency` 只在**建立**玩家時使用；之後要改暱稱請用 `POST /player/update`，幣別建立後不能變更。
        - `currency` 要是租戶在 Console「限紅方案 → 幣別」開啟的幣別（不帶＝主要幣別），否則回 `CURRENCY_NOT_ENABLED`。
        - `limitProfileId` 會儲存為這位玩家的限紅方案（與 `POST /player/update` 相同），之後的 launch 不帶就沿用。
        - 被鎖定（`locked`）的玩家回 `PLAYER_LOCKED`；`no_bet` 的玩家可以進入但不能下注。
      x-description-en: |
        Returns a **one-time URL** that takes the player into the game (valid for 60 seconds, usable once). The player is created if it does not exist.

        - **Single session**: calling launch immediately invalidates and kicks the player's open sessions (reason `SESSION_REPLACED`), and the same happens again when the player **opens** the URL. If several URLs were issued, the one opened last wins and earlier sessions are kicked.
        - Send the player's browser to the URL (or open it in an iframe). An expired or used URL shows "link expired" (HTTP 410).
        - `nickname` and `currency` are only used when the player is **created**. Use `POST /player/update` to change the nickname later; the currency cannot change.
        - `currency` must be one the operator has enabled in Console (Bet limits → Currencies); leave it out for the primary currency. Otherwise `CURRENCY_NOT_ENABLED`.
        - `limitProfileId` is saved as the player's bet-limit profile (same as `POST /player/update`), so later launches without it keep it.
        - A `locked` player gets `PLAYER_LOCKED`; a `no_bet` player can enter but cannot bet.
      x-error-codes: [INVALID_PARAMETER, INVALID_USERNAME, INVALID_CURRENCY, CURRENCY_NOT_ENABLED, PLAYER_LOCKED, PLAYER_LIMIT]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LaunchRequest' }
            examples:
              default:
                summary: 手機、英文、直接進桌
                x-summary-en: Mobile, English, straight into a table
                value: { username: alice, lang: ENG, device: mobile, table: S01 }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/LaunchResult' }
              examples:
                default:
                  value: { ok: true, data: { url: 'https://elite.ewin888.com/Launch?t=k1.eyJ0aWQiOjQ2fQ.3xAmPlE-TiCkEt', expiresIn: 60 } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /player/logout:
    post:
      tags: [player]
      operationId: logoutPlayer
      summary: 登出玩家
      x-summary-en: Log a player out
      description: 讓玩家所有 session 立即失效並踢出遊戲（原因 `LOGGED_OUT`），並送出 Webhook `player.kicked`（`reason` 為 `logged_out`）。玩家之後要再進入，需要重新 launch。
      x-description-en: Invalidates all of the player's sessions and kicks them out of the game (reason `LOGGED_OUT`), and sends the webhook `player.kicked` (`reason` is `logged_out`). The player needs a new launch to come back.
      x-error-codes: [INVALID_PARAMETER, INVALID_USERNAME, PLAYER_NOT_FOUND]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UsernameRequest' }
            examples:
              default:
                value: { username: alice }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/LogoutResult' }
              examples:
                default:
                  value: { ok: true, data: { kicked: true } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /player:
    get:
      tags: [player]
      operationId: getPlayer
      summary: 查詢玩家
      x-summary-en: Get a player
      description: 玩家資料、狀態、目前餘額與是否在線。
      x-description-en: Player details, status, current balance and whether the player is online.
      x-error-codes: [INVALID_USERNAME, PLAYER_NOT_FOUND]
      parameters:
        - $ref: '#/components/parameters/UsernameQuery'
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/Player' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      username: alice
                      nickname: Alice
                      status: active
                      currency: TWD
                      balance: '1500.5'
                      online: true
                      createdAt: '2026-09-20T08:15:30.120Z'
                      lastLoginAt: '2026-09-24T02:59:40.004Z'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /player/update:
    post:
      tags: [player]
      operationId: updatePlayer
      summary: 更新玩家
      x-summary-en: Update a player
      description: |
        只更新有帶的欄位，回傳更新後的玩家資料（格式同 `GET /player`）。

        - `status: locked`：鎖定並立即踢出遊戲（原因 `LOCKED`），並送出 Webhook `player.kicked`（`reason` 為 `locked`）；之後 launch 會回 `PLAYER_LOCKED`。
        - `status: no_bet`：可以進入、觀看，但不能下注。`active` 恢復正常。
        - `limitProfileId`：必須是你自己的限紅方案或平台範本，且幣別與玩家相同，否則回 `INVALID_PARAMETER`；`null` 改回預設。
        - `password`：只有使用平台「公用登入」（`/Login`）的租戶需要；以 launch 串接的租戶不需要設定玩家密碼。
      x-description-en: |
        Updates only the fields you send and returns the updated player (same shape as `GET /player`).

        - `status: locked` locks the player and kicks them out immediately (reason `LOCKED`) and sends the webhook `player.kicked` (`reason` is `locked`); later launches return `PLAYER_LOCKED`.
        - `status: no_bet` lets the player enter and watch but not bet. `active` restores normal play.
        - `limitProfileId` must be one of your own bet-limit profiles or a platform template, in the player's currency, otherwise `INVALID_PARAMETER`; `null` goes back to the default.
        - `password` is only needed by operators that use the platform's public login page (`/Login`). If you launch players through the API you do not need player passwords.
      x-error-codes: [INVALID_PARAMETER, INVALID_USERNAME, PLAYER_NOT_FOUND]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PlayerUpdateRequest' }
            examples:
              lock:
                summary: 鎖定玩家
                x-summary-en: Lock a player
                value: { username: alice, status: locked }
              nickname:
                summary: 修改暱稱
                x-summary-en: Change the nickname
                value: { username: alice, nickname: Alice W. }
      responses:
        '200':
          description: 成功（更新後的玩家資料）
          x-description-en: OK (the updated player)
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/Player' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      username: alice
                      nickname: Alice
                      status: locked
                      currency: TWD
                      balance: '1500.5'
                      online: false
                      createdAt: '2026-09-20T08:15:30.120Z'
                      lastLoginAt: '2026-09-24T02:59:40.004Z'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /wallet/deposit:
    post:
      tags: [wallet]
      operationId: deposit
      summary: 轉入（加點）
      x-summary-en: Deposit (transfer in)
      description: |
        把金額轉入玩家的遊戲錢包。**單階段、冪等**：同一個 `txnId` 重送不會重複入帳，而是回傳**第一次的結果**（同一個 `balance`），`duplicate` 為 `true`。同一個 `txnId` 但金額或方向不同，或最近兩個月內已用在另一位玩家，回 `TXN_CONFLICT`。

        - 玩家不存在時會以請求的 `currency` 自動建立。
        - `currency` 必須等於玩家的幣別，否則回 `CURRENCY_MISMATCH`。
        - `balance` 是這筆轉帳完成當下的餘額。
        - 回應成功的轉帳一定查得到：`GET /wallet/transfer?txnId=…&username=…`。
        - 逾時或收到 5xx 時，**以相同 `txnId` 與相同內容重送**，或先以 `GET /wallet/transfer` 查詢；不要換一個新的 `txnId`。
      x-description-en: |
        Moves money into the player's game wallet. **Single-step and idempotent**: resending the same `txnId` never credits twice; it returns **the original result** (the same `balance`) with `duplicate: true`. The same `txnId` with a different amount or direction, or one used for another player within the last two months, returns `TXN_CONFLICT`.

        - If the player does not exist it is created with the request's `currency`.
        - `currency` must equal the player's currency, otherwise `CURRENCY_MISMATCH`.
        - `balance` is the balance right after this transfer.
        - A transfer that returned success can always be looked up: `GET /wallet/transfer?txnId=…&username=…`.
        - After a timeout or a 5xx, **resend with the same `txnId` and the same content**, or look it up with `GET /wallet/transfer` first. Never switch to a new `txnId`.
      x-error-codes: [INVALID_PARAMETER, INVALID_AMOUNT, INVALID_USERNAME, INVALID_CURRENCY, CURRENCY_NOT_ENABLED, CURRENCY_MISMATCH, PLAYER_LIMIT, TXN_CONFLICT]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransferRequest' }
            examples:
              default:
                value: { username: alice, txnId: dep-20260924-000123, amount: '1000', currency: TWD }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/TransferResult' }
              examples:
                default:
                  summary: 第一次處理
                  x-summary-en: First time
                  value: { ok: true, data: { txnId: dep-20260924-000123, status: done, balance: '2500.5', duplicate: false } }
                duplicate:
                  summary: 重送同一個 txnId（回傳第一次的結果）
                  x-summary-en: Same txnId resent (the original result)
                  value: { ok: true, data: { txnId: dep-20260924-000123, status: done, balance: '2500.5', duplicate: true } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /wallet/withdraw:
    post:
      tags: [wallet]
      operationId: withdraw
      summary: 轉出（扣點）
      x-summary-en: Withdraw (transfer out)
      description: |
        從玩家的遊戲錢包轉出。冪等規則同轉入：同一個 `txnId` 重送會回傳第一次的結果。

        - 只能轉出**可用餘額**：下注時本金已先扣除，未結算的注不會被轉走；不足時回 `INSUFFICIENT_BALANCE`。
        - 玩家必須已存在（`PLAYER_NOT_FOUND`）。
      x-description-en: |
        Moves money out of the player's game wallet. Same idempotency rules as deposit: resending the same `txnId` returns the original result.

        - Only the **available balance** can be withdrawn: stakes are deducted when bets are placed, so unsettled bets can never be withdrawn. Otherwise `INSUFFICIENT_BALANCE`.
        - The player must exist (`PLAYER_NOT_FOUND`).
      x-error-codes: [INVALID_PARAMETER, INVALID_AMOUNT, INVALID_USERNAME, CURRENCY_MISMATCH, PLAYER_NOT_FOUND, TXN_CONFLICT, INSUFFICIENT_BALANCE]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransferRequest' }
            examples:
              default:
                value: { username: alice, txnId: wd-20260924-000077, amount: '500.25', currency: TWD }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/TransferResult' }
              examples:
                default:
                  value: { ok: true, data: { txnId: wd-20260924-000077, status: done, balance: '2000.25', duplicate: false } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /wallet/transfer:
    get:
      tags: [wallet]
      operationId: getTransfer
      summary: 查詢轉帳
      x-summary-en: Look up a transfer
      description: |
        依 `txnId` 查詢轉帳結果。用在轉入、轉出逾時之後，確認是否已經處理。

        - **請一併帶 `username`**：這樣只要轉帳已經成功就一定查得到（包含租戶流水的副本還在送達途中的時候）。
        - 租戶流水的查詢範圍是當月與前 3 個月（依 UTC 月份）。
        - 回 `TXN_NOT_FOUND` 表示這筆轉帳沒有執行（或仍在處理中）：請以相同 `txnId`、相同內容重送，直到得到明確的成功或 4xx 結果；不要改用新的 `txnId`。
        - `dir` 為 `adjust` 表示這個單號是 Console 的人工上下分。
      x-description-en: |
        Looks up a transfer by `txnId`. Use it after a deposit or withdrawal timed out, to find out whether it was processed.

        - **Pass `username` as well**: then any transfer that succeeded is always found (even while the tenant ledger copy is still on its way).
        - The tenant ledger search covers the current month and the previous 3 months (UTC months).
        - `TXN_NOT_FOUND` means the transfer was not executed (or is still being processed): resend it with the same `txnId` and the same content until you get a definite success or 4xx answer. Never switch to a new `txnId`.
        - `dir: adjust` means the ID belongs to a manual adjustment made in the Console.
      x-error-codes: [INVALID_PARAMETER, TXN_NOT_FOUND]
      parameters:
        - $ref: '#/components/parameters/TxnIdQuery'
        - name: username
          in: query
          required: false
          description: 轉帳的玩家帳號（建議帶）。租戶流水查不到時，改以這位玩家錢包自己的紀錄回答。
          x-description-en: The player of the transfer (recommended). When the tenant ledger has no record, the player's own wallet record is used.
          schema: { $ref: '#/components/schemas/Username' }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/TransferRecord' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      txnId: dep-20260924-000123
                      username: alice
                      dir: in
                      amount: '1000'
                      status: done
                      balance: '2500.5'
                      at: '2026-09-24T03:00:01.250Z'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /wallet/balance:
    get:
      tags: [wallet]
      operationId: getBalance
      summary: 查詢餘額
      x-summary-en: Get a balance
      description: 玩家目前的可用餘額（已扣除未結算注單的本金）。
      x-description-en: The player's current available balance (stakes of unsettled bets already deducted).
      x-error-codes: [INVALID_USERNAME, PLAYER_NOT_FOUND]
      parameters:
        - $ref: '#/components/parameters/UsernameQuery'
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/Balance' }
              examples:
                default:
                  value: { ok: true, data: { balance: '2500.5', currency: TWD } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /bets:
    get:
      tags: [bets]
      operationId: listBets
      summary: 注單增量同步
      x-summary-en: Sync bets (cursor)
      description: |
        依寫入順序讀取已結算的注單。每一頁回傳 `nextCursor`，下一次呼叫原樣帶回即可從上次的位置繼續；請把游標持久化。

        - 第一次（沒有 `cursor`）從當月（UTC）第一筆開始，或從 `from` 指定的時間開始。
        - 有 `cursor` 時忽略 `from`。
        - 重算與作廢以**同一個 `slipId`、較大的 `rev`** 出現為新的一筆紀錄；請以 `(slipId, rev)` 當唯一鍵，同一 `slipId` 以最大的 `rev` 為準。
        - `hasMore` 為 `true` 時立即再讀下一頁（游標還沒追到目前的 UTC 月份時也是 `true`）；為 `false` 表示已追上，稍後（例如 5–30 秒）再以 `nextCursor` 輪詢。
        - 重算與作廢的紀錄依發生的時間寫入，一定出現在你的游標之後，不會漏掉。
        - `recSeq` 只在同一個 UTC 月份內遞增，跨月重新起算。
        - 牛牛（`game: niuniu`）的注單多 `hold`（翻倍的預扣合計），`payout` 含退回的預扣：輸贏請一律用 `winLoss`（＝`payout − stake − hold`），不要用 `payout − stake` 推算。
      x-description-en: |
        Reads settled bets in the order they were written. Each page returns `nextCursor`; pass it back unchanged to continue where you left off, and persist it.

        - The first call (no `cursor`) starts at the first record of the current UTC month, or at the time given in `from`.
        - `from` is ignored when `cursor` is present.
        - Recalculations and voids appear as a **new record with the same `slipId` and a higher `rev`**. Use `(slipId, rev)` as the unique key and treat the highest `rev` of a `slipId` as final.
        - While `hasMore` is `true` read the next page right away (it is also `true` while the cursor has not reached the current UTC month); when it is `false` you have caught up, so poll again later (for example every 5–30 seconds) with `nextCursor`.
        - Recalculations and voids are written at the time they happen, so they always appear after your cursor and are never missed.
        - `recSeq` increases within a UTC month only and restarts in the next month.
        - Niu Niu slips (`game: niuniu`) add `hold` (the total hold for double bets), and their `payout` includes the returned hold: always take the win/loss from `winLoss` (= `payout − stake − hold`), never from `payout − stake`.
      x-error-codes: [INVALID_CURSOR, INVALID_PARAMETER]
      parameters:
        - name: cursor
          in: query
          required: false
          description: 上一頁回應的 `nextCursor`（格式 `yyyymm.序號`）。
          x-description-en: '`nextCursor` from the previous page (format `yyyymm.sequence`).'
          schema: { $ref: '#/components/schemas/Cursor' }
        - name: limit
          in: query
          required: false
          description: 每頁筆數，1–1000，預設 500。
          x-description-en: Page size, 1–1000, default 500.
          schema: { type: integer, minimum: 1, maximum: 1000, default: 500 }
        - name: from
          in: query
          required: false
          description: 沒有 `cursor` 時的起點，ISO-8601 時間（例如 `2026-09-24T00:00:00Z`），以分鐘為單位對齊。
          x-description-en: Starting point when there is no `cursor`, an ISO-8601 time (for example `2026-09-24T00:00:00Z`), aligned to the minute.
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/BetPage' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      items:
                        - recSeq: 1024
                          slipId: 01K5Y0B8Z6R2M4N7P9Q3S5T8VW.7H3KQ2XA
                          rev: 1
                          status: settled
                          username: alice
                          currency: TWD
                          tableId: S01
                          game: baccarat
                          variant: nocomm
                          roundId: 01K5Y0B8Z6R2M4N7P9Q3S5T8VW
                          shoe: '260924-03'
                          round: 12
                          bets:
                            - { zone: B, amount: '10000', return: '15000' }
                            - { zone: S6, amount: '1000', return: '13000' }
                          stake: '11000'
                          validStake: '6000'
                          rolling: '0'
                          payout: '28000'
                          winLoss: '17000'
                          delta: '28000'
                          result: { code: '1', cardInfo: '122334424000' }
                          placedAt: '2026-09-24T03:00:41.120Z'
                          settledAt: '2026-09-24T03:01:20.480Z'
                      nextCursor: '202609.1024'
                      hasMore: false
                holdem:
                  summary: 德州撲克（玩家對荷官、玩家對玩家）
                  x-summary-en: Texas Hold'em (player vs dealer, player vs player)
                  value:
                    ok: true
                    data:
                      items:
                        - recSeq: 2048
                          slipId: 01K62H3P9R8Q7M6N5B4V3C2X1Z.9K2LM4QP
                          rev: 1
                          status: settled
                          username: alice
                          currency: TWD
                          tableId: H01
                          game: holdem
                          variant: casino
                          roundId: 01K62H3P9R8Q7M6N5B4V3C2X1Z
                          shoe: ''
                          round: 318
                          bets:
                            - { zone: ANTE, amount: '100', return: '200' }
                            - { zone: CALL, amount: '200', return: '400' }
                            - { zone: AAB, amount: '50', return: '0' }
                          stake: '350'
                          validStake: '350'
                          rolling: '350'
                          payout: '600'
                          winLoss: '250'
                          delta: '600'
                          result: { code: player_wins, cardInfo: 'AS KD 7H 7C 2S|AH 7D|QS 3C' }
                          rake: '0'
                          onsite: false
                          hand: { handId: 01K62H3P9R8Q7M6N5B4V3C2X1Z, no: 318, outcome: player_wins, cards: 'AS KD 7H 7C 2S|AH 7D|QS 3C' }
                          placedAt: '2026-09-27T08:12:03.410Z'
                          settledAt: '2026-09-27T08:13:10.020Z'
                        - recSeq: 2049
                          slipId: 01K62H9A1B2C3D4E5F6G7H8J9K.3Q7XK2PL
                          rev: 1
                          status: settled
                          username: bob
                          currency: TWD
                          tableId: H02
                          game: holdem
                          variant: nlhe
                          roundId: 01K62H9A1B2C3D4E5F6G7H8J9K
                          shoe: ''
                          round: 77
                          bets:
                            - { zone: POT, amount: '400', return: '0' }
                          stake: '400'
                          validStake: '400'
                          rolling: '400'
                          payout: '740'
                          winLoss: '340'
                          delta: '740'
                          result: { code: win, cardInfo: 'QS QH 5D 9C 2H|QD JC' }
                          rake: '40'
                          onsite: false
                          hand: { handId: 01K62H9A1B2C3D4E5F6G7H8J9K, no: 77, outcome: win, cards: 'QS QH 5D 9C 2H|QD JC' }
                          placedAt: '2026-09-27T08:20:41.000Z'
                          settledAt: '2026-09-27T08:22:05.300Z'
                      nextCursor: '202609.2049'
                      hasMore: false
                niuniu:
                  summary: 牛牛（平倍、翻倍與預扣）
                  x-summary-en: Niu Niu (equal and double bets with a hold)
                  value:
                    ok: true
                    data:
                      items:
                        - recSeq: 3072
                          slipId: 01K65N7Q2W4E6R8T0Y1V3J5K7P.7H3KQ2XA
                          rev: 1
                          status: settled
                          username: alice
                          currency: TWD
                          tableId: N01
                          game: niuniu
                          variant: standard
                          roundId: 01K65N7Q2W4E6R8T0Y1V3J5K7P
                          shoe: '260928-01'
                          round: 7
                          bets:
                            - { zone: P1E, amount: '1000', return: '1950' }
                            - { zone: P2D, amount: '500', return: '500', hold: '1000', mult: 2 }
                            - { zone: P3E, amount: '500', return: '975' }
                            - { zone: P3D, amount: '1000', return: '5850', hold: '2000', mult: 3 }
                          stake: '3000'
                          hold: '3000'
                          validStake: '5500'
                          rolling: '1000'
                          payout: '9275'
                          winLoss: '3275'
                          delta: '9275'
                          result:
                            code: 5893A
                            cardInfo: '7H|KS QD 10C 3S 5H|4C 6D JS 9C KH|2D 8S QC AH 2C|7D 3H KD 5C 5D'
                            hands: { B: 8, P1: 9, P2: 3, P3: 10 }
                            winners: { P1: P, P2: B, P3: P }
                          placedAt: '2026-09-28T06:30:12.300Z'
                          settledAt: '2026-09-28T06:30:51.640Z'
                      nextCursor: '202609.3072'
                      hasMore: false
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /bets/summary:
    get:
      tags: [bets]
      operationId: getBetSummary
      summary: 每日每玩家彙總
      x-summary-en: Daily per-player summary
      description: |
        指定日期（依**你的租戶時區**）每位玩家的注單彙總，只計每張注單的最新版本，作廢注單的本金不計入 `stake`。適合做每日對帳。

        - 最多回傳 1,000 位玩家（依輸贏排序）。
        - 德州撲克的現場座位（注單 `onsite: true`）不是你的會員，不列入彙總；要看現場座位請查注單。
        - 牛牛的注單在彙總的 `payout` 以「本金＋輸贏」計入，不含退回的預扣（與其他遊戲的算法一致）；注單本身的 `payout` 則含退回的預扣。
      x-description-en: |
        Per-player totals for one date (in **your tenant's time zone**). Only the latest version of each bet slip counts, and voided slips add nothing to `stake`. Useful for daily reconciliation.

        - Returns at most 1,000 players (ordered by win/loss).
        - Texas Hold'em on-site seats (slips with `onsite: true`) are not your members and are left out of the summary; list the bet slips to see them.
        - Niu Niu slips count towards the summary `payout` as stake + win/loss, without the returned hold (the same basis as every other game); a slip's own `payout` does include the returned hold.
      x-error-codes: [INVALID_PARAMETER]
      parameters:
        - name: date
          in: query
          required: true
          description: 日期 `YYYY-MM-DD`（租戶時區），必須是存在的日期。
          x-description-en: Date `YYYY-MM-DD` (tenant time zone); it must be a real date.
          schema: { $ref: '#/components/schemas/Date' }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/BetSummary' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      date: '2026-09-24'
                      timezone: Asia/Taipei
                      players:
                        - { username: alice, slips: 42, stake: '52000', validStake: '31500', rolling: '18000', payout: '55200', winLoss: '3200', rake: '0' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /rounds/{roundId}:
    get:
      tags: [tables]
      operationId: getRound
      summary: 查詢局結果
      x-summary-en: Get a round result
      description: |
        已結算（或作廢、爭議）的一局：結果碼、牌面、點數與修正版次。尚未結算的局回 `ROUND_NOT_FOUND`。

        - `roundId` 是 26 個字元的大寫 ULID（注單的 `roundId`）；格式不符時回 `NOT_FOUND`（找不到端點）。
      x-description-en: |
        A settled (or voided, or disputed) round: result code, cards, points and correction revision. Rounds that are not settled yet return `ROUND_NOT_FOUND`.

        - `roundId` is a 26-character upper-case ULID (the `roundId` of a bet). A malformed ID returns `NOT_FOUND` (unknown endpoint).
      x-error-codes: [ROUND_NOT_FOUND, NOT_FOUND]
      parameters:
        - name: roundId
          in: path
          required: true
          description: 局 ID（26 字元大寫 ULID）。
          x-description-en: Round ID (26-character upper-case ULID).
          schema: { $ref: '#/components/schemas/RoundId' }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/Round' }
              examples:
                baccarat:
                  summary: 百家樂：莊 6 點勝
                  x-summary-en: 'Baccarat: banker wins with 6'
                  value:
                    ok: true
                    data:
                      roundId: 01K5Y0B8Z6R2M4N7P9Q3S5T8VW
                      tableId: S01
                      shoe: '260924-03'
                      round: 12
                      status: settled
                      code: '1'
                      cardInfo: '122334424000'
                      result:
                        cards: { P1: 2C, B1: 4H, P2: 3D, B2: 2S, P3: 10S }
                        player: 5
                        banker: 6
                        winner: B
                        playerPair: false
                        bankerPair: false
                      complete: true
                      rev: 1
                      openedAt: '2026-09-24T03:00:30.012Z'
                      settledAt: '2026-09-24T03:01:20.480Z'
                niuniu:
                  summary: 牛牛：莊牛8，閒一、閒三贏
                  x-summary-en: 'Niu Niu: banker Bull 8, players 1 and 3 win'
                  value:
                    ok: true
                    data:
                      roundId: 01K65N7Q2W4E6R8T0Y1V3J5K7P
                      tableId: N01
                      shoe: '260928-01'
                      round: 7
                      status: settled
                      code: 5893A
                      cardInfo: '7H|KS QD 10C 3S 5H|4C 6D JS 9C KH|2D 8S QC AH 2C|7D 3H KD 5C 5D'
                      result:
                        cards:
                          F: 7H
                          B-1: KS
                          B-2: QD
                          B-3: 10C
                          B-4: 3S
                          B-5: 5H
                          P1-1: 4C
                          P1-2: 6D
                          P1-3: JS
                          P1-4: 9C
                          P1-5: KH
                          P2-1: 2D
                          P2-2: 8S
                          P2-3: QC
                          P2-4: AH
                          P2-5: 2C
                          P3-1: 7D
                          P3-2: 3H
                          P3-3: KD
                          P3-4: 5C
                          P3-5: 5D
                        hands: { B: 8, P1: 9, P2: 3, P3: 10 }
                        winners: { P1: P, P2: B, P3: P }
                      complete: true
                      rev: 1
                      openedAt: '2026-09-28T06:30:05.010Z'
                      settledAt: '2026-09-28T06:30:51.640Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /tables:
    get:
      tags: [tables]
      operationId: listTables
      summary: 桌檯清單
      x-summary-en: List tables
      description: 你可以選的桌檯：平台桌、你自己建的桌，以及其他商戶提供給你的桌（有 `provider`；平台開放跨商戶提供、提供者核准並把你列為對象時才會出現），以及你是否已啟用（`enabled`）。玩家只看得到你已啟用的桌。
      x-description-en: 'The tables you can choose: platform tables, tables you built yourself, and tables other merchants share with you (with `provider`; listed only while the platform allows cross-merchant sharing and the provider''s share is approved and includes you), plus whether you have enabled each one (`enabled`). Players only see tables you have enabled.'
      x-error-codes: []
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/TableList' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      tables:
                        - tableId: S01
                          game: baccarat
                          variants: [classic, nocomm]
                          name: { CHT: 模擬百家樂 1, CHS: 模拟百家乐 1, ENG: Sim Baccarat 1 }
                          status: open
                          betSeconds: 20
                          enabled: true
                        - tableId: S03
                          game: dragontiger
                          variants: [classic]
                          name: { CHT: 模擬龍虎, CHS: 模拟龙虎, ENG: Sim Dragon Tiger }
                          status: open
                          betSeconds: 20
                          enabled: false
                        - tableId: N01
                          game: niuniu
                          variants: [standard]
                          name: { CHT: 模擬牛牛 N01, CHS: 模拟牛牛 N01, ENG: Sim Niu Niu N01 }
                          status: open
                          betSeconds: 15
                          enabled: true
                        - tableId: STUDIO-B1
                          game: baccarat
                          variants: [classic, nocomm]
                          name: { CHT: 旗艦攝影棚 B1, ENG: Studio B1 }
                          status: open
                          betSeconds: 20
                          enabled: true
                          provider: { name: 旗艦娛樂 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /tables/enable:
    post:
      tags: [tables]
      operationId: enableTable
      summary: 啟用桌檯
      x-summary-en: Enable a table
      description: |
        讓玩家看得到並可以進入這張桌，規則與 Console「桌檯選擇」相同。

        - 免費方案最多啟用配額內的桌數（預設 2），超過回 `QUOTA_EXCEEDED`。
        - 付費方案沒有配額，**啟用當天起開始計費**（每桌每月 100 USD，依日按比例從預付額度扣）。
        - 帳戶停權時回 `ACCOUNT_LOCKED`。已啟用的桌再次啟用不會出錯。
      x-description-en: |
        Makes the table visible and playable for your players; the same rules as the Console "Table selection" page.

        - The free plan can enable up to its quota (2 by default); beyond that you get `QUOTA_EXCEEDED`.
        - The paid plan has no quota and **billing starts on the day you enable** (USD 100 per table per month, charged daily and pro rata from your prepaid credit).
        - A suspended account gets `ACCOUNT_LOCKED`. Enabling an already enabled table is not an error.
      x-error-codes: [INVALID_PARAMETER, NOT_FOUND, QUOTA_EXCEEDED, ACCOUNT_LOCKED]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TableToggleRequest' }
            examples:
              default:
                value: { tableId: S01 }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/TableToggleResult' }
              examples:
                default:
                  value: { ok: true, data: { tableId: S01, enabled: true, used: 2, quota: 2 } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /tables/disable:
    post:
      tags: [tables]
      operationId: disableTable
      summary: 停用桌檯
      x-summary-en: Disable a table
      description: 玩家立即看不到、也進不去這張桌；付費方案從次日起不再計這張桌的桌費（停用當天仍計一天）。
      x-description-en: Players immediately lose access to the table. On the paid plan the table stops being billed from the next day (the day you disable it still counts).
      x-error-codes: [INVALID_PARAMETER, NOT_FOUND]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TableToggleRequest' }
            examples:
              default:
                value: { tableId: S01 }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/TableToggleResult' }
              examples:
                default:
                  value: { ok: true, data: { tableId: S01, enabled: false, used: 1, quota: 2 } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /account:
    get:
      tags: [account]
      operationId: getAccount
      summary: 帳戶摘要
      x-summary-en: Account summary
      description: 方案、帳務狀態、預付額度、已啟用桌數與配額、本月串流用量、近 7 天平均日費用、預估可用天數與寬限期限。
      x-description-en: Plan, account state, prepaid credit, enabled tables and quota, streaming used this month, average daily cost over the last 7 days, estimated days left and the grace deadline.
      x-error-codes: []
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/Account' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      plan: paid
                      state: PAID
                      balanceUsd: '842.315'
                      tablesUsed: 3
                      tablesQuota: null
                      streamGbMonth: '412.906'
                      avgDailyUsd7d: '14.672'
                      daysLeft: 57
                      graceUntil: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /account/usage:
    get:
      tags: [account]
      operationId: getAccountUsage
      summary: 每日用量
      x-summary-en: Daily usage
      description: 期間內每天（UTC）的桌·日、串流 GB 與扣款金額。今天尚未扣款，以即時累計顯示。
      x-description-en: Table-days, streaming GB and charges for each UTC day in the range. Today is not charged yet and shows the running total.
      x-error-codes: [INVALID_PARAMETER]
      parameters:
        - name: from
          in: query
          required: true
          description: 起始日 `YYYY-MM-DD`（UTC，含）。
          x-description-en: First day `YYYY-MM-DD` (UTC, inclusive).
          schema: { $ref: '#/components/schemas/Date' }
        - name: to
          in: query
          required: true
          description: 結束日 `YYYY-MM-DD`（UTC，含）。
          x-description-en: Last day `YYYY-MM-DD` (UTC, inclusive).
          schema: { $ref: '#/components/schemas/Date' }
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/UsageList' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      days:
                        - { day: '2026-09-23', tableDays: 3, streamGb: '18.204', streamSource: client, tableFeeUsd: '10', streamFeeUsd: '1.638372' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

  /account/statements:
    get:
      tags: [account]
      operationId: listStatements
      summary: 月結單
      x-summary-en: Monthly statements
      description: 最近 24 期月結單（新到舊），每月 1 日產生上個月的月結單。完整明細在 Console「方案與帳單 → 月結單」。
      x-description-en: The last 24 monthly statements (newest first). A statement for the previous month is produced on the 1st of each month. Full details are in the Console under "Plan & billing → Monthly statements".
      x-error-codes: []
      responses:
        '200':
          description: 成功
          x-description-en: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, data]
                properties:
                  ok: { const: true }
                  data: { $ref: '#/components/schemas/StatementList' }
              examples:
                default:
                  value:
                    ok: true
                    data:
                      statements:
                        - { period: '2026-08', openingUsd: '120', chargesUsd: '-310.52', paymentsUsd: '1000', closingUsd: '809.48' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }

# 本平台送到租戶伺服器的 Webhook（簽章、重試與冪等處理見 /doc〈Webhook〉）
webhooks:
  bet.settled:
    post:
      summary: 局結算
      x-summary-en: Round settled
      description: 一局結算完成，而且你的玩家在這局有下注。`data.bets` 是你的玩家在這局的注單，格式與 `GET /bets` 相同。
      x-description-en: A round was settled and your players had bets in it. `data.bets` holds your players' slips for the round, in the same shape as `GET /bets`.
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: bet.settled }
                    data: { $ref: '#/components/schemas/RoundEventData' }
            examples:
              default:
                value:
                  id: 8f14e45fceea167a5a36dedd4bea2543
                  event: bet.settled
                  companyCode: ACME
                  createdAt: '2026-09-24T03:01:21.030Z'
                  data:
                    round:
                      roundId: 01K5Y0B8Z6R2M4N7P9Q3S5T8VW
                      tableId: S01
                      shoe: '260924-03'
                      round: 12
                      rev: 1
                      resultCode: '1'
                      cardInfo: '122334424000'
                      settledAt: '2026-09-24T03:01:20.480Z'
                    bets:
                      - recSeq: 1024
                        slipId: 01K5Y0B8Z6R2M4N7P9Q3S5T8VW.7H3KQ2XA
                        rev: 1
                        status: settled
                        username: alice
                        currency: TWD
                        tableId: S01
                        game: baccarat
                        variant: nocomm
                        roundId: 01K5Y0B8Z6R2M4N7P9Q3S5T8VW
                        shoe: '260924-03'
                        round: 12
                        bets:
                          - { zone: B, amount: '10000', return: '15000' }
                          - { zone: S6, amount: '1000', return: '13000' }
                        stake: '11000'
                        validStake: '6000'
                        rolling: '0'
                        payout: '28000'
                        winLoss: '17000'
                        delta: '28000'
                        result: { code: '1', cardInfo: '122334424000' }
                        placedAt: '2026-09-24T03:00:41.120Z'
                        settledAt: '2026-09-24T03:01:20.480Z'
              niuniu:
                summary: 牛牛（翻倍格帶 hold、mult）
                x-summary-en: Niu Niu (double bets carry hold and mult)
                value:
                  id: 3c59dc048e8850243be8079a5c74d079
                  event: bet.settled
                  companyCode: ACME
                  createdAt: '2026-09-28T06:30:52.210Z'
                  data:
                    round:
                      roundId: 01K65N7Q2W4E6R8T0Y1V3J5K7P
                      tableId: N01
                      shoe: '260928-01'
                      round: 7
                      rev: 1
                      resultCode: 5893A
                      cardInfo: '7H|KS QD 10C 3S 5H|4C 6D JS 9C KH|2D 8S QC AH 2C|7D 3H KD 5C 5D'
                      settledAt: '2026-09-28T06:30:51.640Z'
                    bets:
                      - recSeq: 3072
                        slipId: 01K65N7Q2W4E6R8T0Y1V3J5K7P.7H3KQ2XA
                        rev: 1
                        status: settled
                        username: alice
                        currency: TWD
                        tableId: N01
                        game: niuniu
                        variant: standard
                        roundId: 01K65N7Q2W4E6R8T0Y1V3J5K7P
                        shoe: '260928-01'
                        round: 7
                        bets:
                          - { zone: P1E, amount: '1000', return: '1950' }
                          - { zone: P2D, amount: '500', return: '500', hold: '1000', mult: 2 }
                          - { zone: P3E, amount: '500', return: '975' }
                          - { zone: P3D, amount: '1000', return: '5850', hold: '2000', mult: 3 }
                        stake: '3000'
                        hold: '3000'
                        validStake: '5500'
                        rolling: '1000'
                        payout: '9275'
                        winLoss: '3275'
                        delta: '9275'
                        result:
                          code: 5893A
                          cardInfo: '7H|KS QD 10C 3S 5H|4C 6D JS 9C KH|2D 8S QC AH 2C|7D 3H KD 5C 5D'
                          hands: { B: 8, P1: 9, P2: 3, P3: 10 }
                          winners: { P1: P, P2: B, P3: P }
                        placedAt: '2026-09-28T06:30:12.300Z'
                        settledAt: '2026-09-28T06:30:51.640Z'
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  round.corrected:
    post:
      summary: 結果修正後重算
      x-summary-en: Round recalculated after a correction
      description: 資料源修正結果後重新結算。`data.round.rev` 是新的版次，`data.bets` 只包含這個版次的注單（`status` 為 `recalculated`，`delta` 為這次的入帳差額）。
      x-description-en: The data source corrected the result and the round was settled again. `data.round.rev` is the new revision and `data.bets` only holds slips of that revision (`status` is `recalculated`; `delta` is what this revision credited).
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: round.corrected }
                    data: { $ref: '#/components/schemas/RoundEventData' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  round.voided:
    post:
      summary: 局作廢
      x-summary-en: Round voided
      description: 一局作廢、本金全額退回（牛牛連同翻倍的預扣）。`data.bets` 只包含作廢版次的注單（`status` 為 `void`）。
      x-description-en: A round was voided and stakes refunded in full (for Niu Niu, together with the hold on double bets). `data.bets` only holds slips of the void revision (`status` is `void`).
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: round.voided }
                    data: { $ref: '#/components/schemas/RoundEventData' }
            examples:
              default:
                value:
                  id: 0b7c1f2e9d3a4c5b8e6f7a8b9c0d1e2f
                  event: round.voided
                  companyCode: ACME
                  createdAt: '2026-09-24T03:10:02.000Z'
                  data:
                    round:
                      roundId: 01K5Y1C9A7B3D5E7F9G1H3J5K7
                      tableId: S02
                      shoe: '260924-07'
                      round: 31
                      rev: 1
                      resultCode: null
                      cardInfo: null
                      settledAt: '2026-09-24T03:10:01.500Z'
                    bets:
                      - recSeq: 1031
                        slipId: 01K5Y1C9A7B3D5E7F9G1H3J5K7.7H3KQ2XA
                        rev: 1
                        status: void
                        username: alice
                        currency: TWD
                        tableId: S02
                        game: baccarat
                        variant: classic
                        roundId: 01K5Y1C9A7B3D5E7F9G1H3J5K7
                        shoe: '260924-07'
                        round: 31
                        bets:
                          - { zone: P, amount: '500', return: '500' }
                        stake: '500'
                        validStake: '0'
                        rolling: '0'
                        payout: '500'
                        winLoss: '0'
                        delta: '500'
                        result: { code: '', cardInfo: '' }
                        placedAt: '2026-09-24T03:09:40.210Z'
                        settledAt: '2026-09-24T03:10:01.500Z'
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  player.kicked:
    post:
      summary: 玩家被踢出
      x-summary-en: Player kicked
      description: '你以 API 登出（`POST /player/logout`）或鎖定（`POST /player/update` 設 `status: locked`）玩家時送出。新的 launch 取代舊 session 時不會送出。'
      x-description-en: 'Sent when you log a player out (`POST /player/logout`) or lock them (`POST /player/update` with `status: locked`) through the API. Not sent when a new launch replaces an older session.'
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: player.kicked }
                    data: { $ref: '#/components/schemas/PlayerKickedData' }
            examples:
              default:
                value:
                  id: 3c59dc048e8850243be8079a5c74d079
                  event: player.kicked
                  companyCode: ACME
                  createdAt: '2026-09-24T04:00:00.120Z'
                  data: { username: alice, reason: locked, at: '2026-09-24T04:00:00.050Z' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  account.grace:
    post:
      summary: 進入寬限期
      x-summary-en: Grace period started
      description: 付費方案的預付額度用完，進入寬限期（`data.graceUntil` 為截止時間）。
      x-description-en: The paid plan's prepaid credit ran out and the grace period started (`data.graceUntil` is the deadline).
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: account.grace }
                    data: { $ref: '#/components/schemas/AccountStateData' }
            examples:
              default:
                value:
                  id: 45c48cce2e2d7fbdea1afc51c7c6ad26
                  event: account.grace
                  companyCode: ACME
                  createdAt: '2026-09-25T00:05:03.410Z'
                  data: { from: PAID, to: GRACE, balanceUsd: '-3.17', graceUntil: '2026-09-28T00:05:03.300Z', actor: system }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  account.downgraded:
    post:
      summary: 自動降級
      x-summary-en: Downgraded
      description: 寬限期結束仍未儲值，帳戶自動降級為免費展示方案；超出免費配額的桌已停用。
      x-description-en: The grace period ended without a top-up and the account was downgraded to the free demo plan; tables beyond the free quota were disabled.
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: account.downgraded }
                    data: { $ref: '#/components/schemas/AccountStateData' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  account.restored:
    post:
      summary: 恢復付費方案
      x-summary-en: Restored
      description: 寬限或降級中的帳戶儲值後恢復為付費方案（降級時停用的桌會自動重新啟用）。
      x-description-en: An account in grace or downgraded was topped up and is back on the paid plan (tables disabled by the downgrade are re-enabled).
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: account.restored }
                    data: { $ref: '#/components/schemas/AccountStateData' }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  account.topup:
    post:
      summary: 儲值入帳
      x-summary-en: Top-up credited
      description: 付款或儲值已入帳到預付額度。
      x-description-en: A payment or top-up was credited to the prepaid credit.
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: account.topup }
                    data: { $ref: '#/components/schemas/AccountTopupData' }
            examples:
              default:
                value:
                  id: d3d9446802a44259755d38e6d163e820
                  event: account.topup
                  companyCode: ACME
                  createdAt: '2026-09-25T09:12:44.001Z'
                  data: { amountUsd: '500.00', balanceUsd: '496.83', ref: 7d1f0c3b2a9e8f7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c6b5a4938271605f }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  account.low_balance:
    post:
      summary: 預付額度偏低
      x-summary-en: Low balance
      description: 付費方案扣款後，預估剩餘天數低於提醒門檻（預設 7 天）；每天最多一次。需要在 Console 設定帳單聯絡 Email。
      x-description-en: After a daily charge on the paid plan, the estimated days left dropped below the alert threshold (7 days by default); at most once a day. Requires a billing contact email in the Console.
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: account.low_balance }
                    data: { $ref: '#/components/schemas/AccountLowBalanceData' }
            examples:
              default:
                value:
                  id: 6512bd43d9caa6e02c990b0a82652dca
                  event: account.low_balance
                  companyCode: ACME
                  createdAt: '2026-09-25T00:05:02.880Z'
                  data: { balanceUsd: '61.20', avgDailyUsd: '14.67', daysLeft: 4 }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }
  test:
    post:
      summary: 測試事件
      x-summary-en: Test event
      description: 在 Console 按「測試送出」時送出，不需要訂閱。
      x-description-en: Sent when you click "Send test" in the Console; no subscription needed.
      parameters:
        - $ref: '#/components/parameters/WebhookEvent'
        - $ref: '#/components/parameters/WebhookDelivery'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/WebhookEnvelope'
                - properties:
                    event: { const: test }
                    data: { $ref: '#/components/schemas/TestEventData' }
            examples:
              default:
                value:
                  id: 8f14e45fceea167a5a36dedd4bea2543
                  event: test
                  companyCode: ACME
                  createdAt: '2026-09-24T03:00:00.000Z'
                  data: { message: elite webhook test }
      responses:
        '2XX': { $ref: '#/components/responses/WebhookAck' }

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: 金鑰 ID，格式 `ek_<s|l>_<租戶代號>_<16 碼>`（`s` 沙箱、`l` 正式），例如 `ek_s_1a_XXXXXXXXXXXXXXXX`。在 Console「上線與串接 → API 金鑰」取得。
      x-description-en: The key ID, format `ek_<s|l>_<tenant>_<16 chars>` (`s` sandbox, `l` live), for example `ek_s_1a_XXXXXXXXXXXXXXXX`. Get it in the Console under "Go-live & integration → API keys".
    Timestamp:
      type: apiKey
      in: header
      name: X-Timestamp
      description: 目前的 Unix 時間（秒，9–11 位數字）。與伺服器時間差超過 ±300 秒會被拒絕。
      x-description-en: Current Unix time in seconds (9–11 digits). Rejected when it differs from server time by more than ±300 seconds.
    Nonce:
      type: apiKey
      in: header
      name: X-Nonce
      description: 每個請求都不同的隨機字串，16–64 個英數字；10 分鐘內不可重複使用（重試也要換新的 nonce）。
      x-description-en: A random string that is different for every request, 16–64 letters and digits; it must not repeat within 10 minutes (retries need a new nonce too).
    Signature:
      type: apiKey
      in: header
      name: X-Signature
      description: '`hex(HMAC-SHA256(secret, METHOD + "\n" + PATH_AND_QUERY + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + hex(SHA256(body))))`。PATH_AND_QUERY 從 `/api/tenant/v1` 開始並包含查詢字串。'
      x-description-en: '`hex(HMAC-SHA256(secret, METHOD + "\n" + PATH_AND_QUERY + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + hex(SHA256(body))))`. PATH_AND_QUERY starts with `/api/tenant/v1` and includes the query string.'

  parameters:
    UsernameQuery:
      name: username
      in: query
      required: true
      description: 玩家帳號。
      x-description-en: Player username.
      schema: { $ref: '#/components/schemas/Username' }
    TxnIdQuery:
      name: txnId
      in: query
      required: true
      description: 轉帳時使用的 `txnId`。
      x-description-en: The `txnId` used for the transfer.
      schema: { $ref: '#/components/schemas/TxnId' }
    WebhookEvent:
      name: X-Elite-Event
      in: header
      required: true
      description: 事件名稱，與本文的 `event` 相同。
      x-description-en: Event name, same as `event` in the body.
      schema: { type: string, examples: [bet.settled] }
    WebhookDelivery:
      name: X-Elite-Delivery
      in: header
      required: true
      description: 這次投遞的 ID，與本文的 `id` 相同；自動重試時不變，可用來去重。
      x-description-en: ID of this delivery, same as `id` in the body; unchanged across automatic retries, so use it to de-duplicate.
      schema: { type: string, examples: [8f14e45fceea167a5a36dedd4bea2543] }
    WebhookSignature:
      name: X-Elite-Signature
      in: header
      required: true
      description: '`t=<Unix 秒>,v1=<hex(HMAC-SHA256(Webhook 密鑰, t + "." + 原始本文))>`；請以收到的原始位元組驗證，並拒絕 `t` 與目前時間相差超過 300 秒的請求。'
      x-description-en: '`t=<Unix seconds>,v1=<hex(HMAC-SHA256(webhook secret, t + "." + raw body))>`. Verify it over the raw bytes you received and reject requests whose `t` is more than 300 seconds from now.'
      schema: { type: string, pattern: '^t=\d{1,12},v1=[0-9a-f]{64}$', examples: ['t=1790218800,v1=fec6bdb3baaec32d5693cb97a7c7b005a27f37a98aa4d67d09e6bf3808d3f20a'] }

  schemas:
    Username:
      type: string
      pattern: '^[A-Za-z0-9_.@-]{1,32}$'
      description: 玩家帳號，1–32 個字元（英數字與 `_ . @ -`）；**不分大小寫**（`Alice` 與 `alice` 是同一位玩家）。
      x-description-en: Player username, 1–32 characters (letters, digits and `_ . @ -`). **Case-insensitive** (`Alice` and `alice` are the same player).
      examples: [alice]
    TxnId:
      type: string
      pattern: '^[A-Za-z0-9_.:-]{1,64}$'
      description: '你的轉帳單號，1–64 個字元（英數字與 `_ . : -`），作為冪等鍵。請在你的整個租戶內保持唯一（不要在不同玩家之間重複使用）。'
      x-description-en: 'Your transfer ID, 1–64 characters (letters, digits and `_ . : -`), used as the idempotency key. Keep it unique across your whole tenant (never reuse it for another player).'
      examples: [dep-20260924-000123]
    Amount:
      type: string
      pattern: '^-?\d{1,15}(\.\d{1,4})?$'
      description: 玩家金額，十進位字串，最多 4 位小數；回應會去掉小數尾端的 0（例如 `"100"`、`"0.95"`、`"1000.5"`）。
      x-description-en: A player amount as a decimal string with at most 4 decimals. Responses drop trailing zeros (for example `"100"`, `"0.95"`, `"1000.5"`).
      examples: ['1000.5']
    PositiveAmount:
      type: string
      pattern: '^\d{1,15}(\.\d{1,4})?$'
      description: 大於 0 的金額，十進位字串，最多 4 位小數，例如 `"1000"`、`"99.5"`。請用字串，不要用浮點數。
      x-description-en: An amount greater than 0 as a decimal string with at most 4 decimals, for example `"1000"` or `"99.5"`. Send a string, not a floating-point number.
      examples: ['1000']
    UsdAmount:
      type: string
      pattern: '^-?\d{1,12}(\.\d{1,6})?$'
      description: USD 金額，十進位字串，最多 6 位小數（內部以百萬分之一美元累計）。
      x-description-en: A USD amount as a decimal string with at most 6 decimals (accumulated internally in millionths of a dollar).
      examples: ['842.315']
    Currency:
      type: string
      pattern: '^[A-Za-z]{3,5}$'
      description: 幣別代碼（3–5 個英文字母，不分大小寫，例如 `TWD`、`USD`、`USDT`）。
      x-description-en: Currency code (3–5 letters, case-insensitive, for example `TWD`, `USD`, `USDT`).
      examples: [TWD]
    Lang:
      type: string
      enum: [CHT, CHS, ENG, JPN, KOR, THAI, VIET, HIND, PHP]
      description: 遊戲語系：CHT 繁中、CHS 簡中、ENG 英文、JPN 日文、KOR 韓文、THAI 泰文、VIET 越南文、HIND 印地文、PHP 菲律賓文（不分大小寫）。
      x-description-en: Game language — CHT Traditional Chinese, CHS Simplified Chinese, ENG English, JPN Japanese, KOR Korean, THAI Thai, VIET Vietnamese, HIND Hindi, PHP Filipino (case-insensitive).
    Date:
      type: string
      pattern: '^\d{4}-\d{2}-\d{2}$'
      description: 日期 `YYYY-MM-DD`。
      x-description-en: A date `YYYY-MM-DD`.
      examples: ['2026-09-24']
    DateTime:
      type: string
      format: date-time
      description: UTC 時間，ISO-8601 含毫秒。
      x-description-en: UTC time, ISO-8601 with milliseconds.
      examples: ['2026-09-24T03:00:00.000Z']
    Cursor:
      type: string
      pattern: '^\d{6}\.\d+$'
      description: 注單同步游標（`yyyymm.序號`）。請視為不透明字串，原樣保存與帶回。
      x-description-en: Bet sync cursor (`yyyymm.sequence`). Treat it as opaque; store it and send it back unchanged.
      examples: ['202609.1024']
    RoundId:
      type: string
      pattern: '^[0-9A-Z]{26}$'
      description: 局 ID（26 字元大寫 ULID）。
      x-description-en: Round ID (26-character upper-case ULID).
      examples: [01K5Y0B8Z6R2M4N7P9Q3S5T8VW]
    TableId:
      type: string
      minLength: 1
      description: 桌號，例如 `S01`（見 `GET /tables`）。
      x-description-en: Table ID, for example `S01` (see `GET /tables`).
      examples: [S01]
    Game:
      type: string
      enum: [baccarat, dragontiger, holdem, niuniu]
      description: 遊戲：`baccarat` 百家樂、`dragontiger` 龍虎、`holdem` 德州撲克、`niuniu` 牛牛。
      x-description-en: 'Game: `baccarat`, `dragontiger`, `holdem` (Texas Hold''em) or `niuniu` (Niu Niu).'
    Variant:
      type: string
      description: 玩法：百家樂 `classic`（傳統，莊贏抽 5%）、`nocomm`（免佣，莊 6 點贏賠一半、有超級六）；龍虎的玩法以桌檯的 `variants` 為準；德州撲克 `casino`（玩家對荷官，Casino Hold'em）、`thbp`（玩家對荷官，Texas Hold'em Bonus Poker）、`nlhe`（玩家對玩家，無限注）；牛牛 `standard`。
      x-description-en: 'Variant: baccarat `classic` (5% commission on banker wins) or `nocomm` (no commission; banker winning with 6 pays half, with Super Six). For dragon tiger use the table''s `variants`. Texas Hold''em: `casino` (player vs dealer, Casino Hold''em), `thbp` (player vs dealer, Texas Hold''em Bonus Poker) or `nlhe` (player vs player, no-limit). Niu Niu: `standard`.'
      examples: [nocomm]
    LimitProfileId:
      type: [integer, 'null']
      minimum: 1
      description: 限紅方案 ID：你在 Console「限紅方案」建立的方案，或平台範本；幣別必須與玩家相同，否則回 `INVALID_PARAMETER`。`null` 改回預設。限紅方案依遊戲區分，只套用在同一種遊戲的桌，其他遊戲的桌使用預設方案。
      x-description-en: 'Bet-limit profile ID: one of your own profiles (Console "Bet-limit profiles") or a platform template, in the player''s currency, otherwise `INVALID_PARAMETER`. `null` goes back to the default. Profiles are per game: a profile only applies to tables of its game, and other games use the default.'
      examples: [3]
    PlayerStatus:
      type: string
      enum: [active, locked, no_bet]
      description: '`active` 正常；`locked` 鎖定（不能進入遊戲）；`no_bet` 禁止下注（可進入觀看）。'
      x-description-en: '`active` normal; `locked` cannot enter the game; `no_bet` can enter and watch but not bet.'

    Error:
      type: object
      required: [ok, error]
      properties:
        ok: { const: false }
        error:
          type: object
          required: [code, message]
          properties:
            code:
              $ref: '#/components/schemas/ErrorCode'
            message:
              type: string
              description: 給開發者看的說明（英文為主），可能調整，請不要用它做程式判斷；請以 `code` 判斷。
              x-description-en: A developer-facing explanation (mostly English). It may change, so do not branch on it; branch on `code`.
    ErrorCode:
      type: string
      description: 錯誤碼，完整說明見〈錯誤碼〉。
      x-description-en: Error code. See "Error codes" for the full list.
      enum:
        - UNAUTHORIZED
        - IP_NOT_ALLOWED
        - TENANT_SUSPENDED
        - RATE_LIMITED
        - PAYLOAD_TOO_LARGE
        - INVALID_JSON
        - NOT_FOUND
        - INTERNAL_ERROR
        - INVALID_PARAMETER
        - INVALID_USERNAME
        - INVALID_CURRENCY
        - CURRENCY_NOT_ENABLED
        - INVALID_AMOUNT
        - INVALID_CURSOR
        - CURRENCY_MISMATCH
        - PLAYER_NOT_FOUND
        - PLAYER_LOCKED
        - PLAYER_LIMIT
        - TXN_CONFLICT
        - INSUFFICIENT_BALANCE
        - TXN_NOT_FOUND
        - ROUND_NOT_FOUND
        - QUOTA_EXCEEDED
        - ACCOUNT_LOCKED

    LaunchRequest:
      type: object
      required: [username]
      properties:
        username: { $ref: '#/components/schemas/Username' }
        nickname:
          type: string
          description: 暱稱（選填）。只在建立玩家時使用。
          x-description-en: Display name (optional). Only used when the player is created.
        currency:
          allOf: [{ $ref: '#/components/schemas/Currency' }]
          description: 幣別（選填），只在建立玩家時使用；預設為你的租戶幣別。
          x-description-en: Currency (optional), only used when the player is created. Defaults to your tenant currency.
        lang:
          allOf: [{ $ref: '#/components/schemas/Lang' }]
          description: 遊戲語系（選填）；預設為你在 Console 設定的第一個語系，未設定時為 `CHT`。
          x-description-en: Game language (optional). Defaults to the first language set in your Console, or `CHT`.
        device:
          type: string
          enum: [pc, mobile]
          default: pc
          description: '`mobile` 開啟手機版，其他值一律為桌機版 `pc`。'
          x-description-en: '`mobile` opens the mobile layout; any other value means the desktop layout `pc`.'
        table:
          allOf: [{ $ref: '#/components/schemas/TableId' }]
          description: 直接進入這張桌（選填）；不帶時進入大廳。桌必須是你已啟用的桌。
          x-description-en: Go straight into this table (optional); without it the player lands in the lobby. It must be a table you have enabled.
        variant:
          type: string
          description: 偏好的百家樂玩法（選填，`classic`／`nocomm`）。目前版本只保存，玩家仍在桌內自行選擇。
          x-description-en: Preferred baccarat variant (optional, `classic` / `nocomm`). Currently stored only; the player still picks the variant at the table.
        lobbyUrl:
          type: string
          format: uri
          pattern: '^https?://'
          description: 你的網站網址（選填，http 或 https）。目前版本的「回到網站」按鈕使用 Console「品牌與登入 → 返回大廳網址」的設定；這個參數會保存，供之後的版本逐次覆寫。
          x-description-en: Your site's URL (optional, http or https). In the current version the "Back to site" button uses the Console setting "Branding & login → Return-to-lobby URL"; this parameter is stored for per-launch overrides in a later version.
        limitProfileId:
          $ref: '#/components/schemas/LimitProfileId'
    LaunchResult:
      type: object
      required: [url, expiresIn]
      properties:
        url:
          type: string
          format: uri
          description: 一次性遊戲網址（`https://<主機>/Launch?t=…`），60 秒內有效、只能開啟一次。
          x-description-en: One-time game URL (`https://<host>/Launch?t=…`), valid for 60 seconds and usable once.
        expiresIn:
          type: integer
          const: 60
          description: 網址有效秒數。
          x-description-en: Seconds until the URL expires.
    UsernameRequest:
      type: object
      required: [username]
      properties:
        username: { $ref: '#/components/schemas/Username' }
    LogoutResult:
      type: object
      required: [kicked]
      properties:
        kicked:
          const: true
          description: 一律為 `true`。
          x-description-en: Always `true`.
    Player:
      type: object
      required: [username, nickname, status, currency, balance, online, createdAt, lastLoginAt]
      properties:
        username:
          type: string
          description: 玩家帳號（保留第一次建立時的大小寫）。
          x-description-en: Username (with the letter case used when it was created).
        nickname:
          type: [string, 'null']
          description: 暱稱。
          x-description-en: Display name.
        status: { $ref: '#/components/schemas/PlayerStatus' }
        currency:
          type: string
          description: 幣別。
          x-description-en: Currency.
        balance:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 目前可用餘額。
          x-description-en: Current available balance.
        online:
          type: boolean
          description: 目前是否在遊戲中（大廳或桌內）。
          x-description-en: Whether the player is in the game right now (lobby or a table).
        createdAt: { $ref: '#/components/schemas/DateTime' }
        lastLoginAt:
          type: [string, 'null']
          format: date-time
          description: 最近一次進入遊戲的時間。
          x-description-en: When the player last entered the game.
    PlayerUpdateRequest:
      type: object
      required: [username]
      properties:
        username: { $ref: '#/components/schemas/Username' }
        status: { $ref: '#/components/schemas/PlayerStatus' }
        nickname:
          type: [string, 'null']
          description: 新暱稱；`null` 或空字串清除。
          x-description-en: New display name; `null` or an empty string clears it.
        limitProfileId:
          $ref: '#/components/schemas/LimitProfileId'
        password:
          type: string
          minLength: 6
          maxLength: 64
          description: 公用登入（`/Login`）用的密碼，6–64 個字元。
          x-description-en: Password for the public login page (`/Login`), 6–64 characters.
    TransferRequest:
      type: object
      required: [username, txnId, amount, currency]
      properties:
        username: { $ref: '#/components/schemas/Username' }
        txnId: { $ref: '#/components/schemas/TxnId' }
        amount: { $ref: '#/components/schemas/PositiveAmount' }
        currency:
          allOf: [{ $ref: '#/components/schemas/Currency' }]
          description: 必須等於玩家的幣別。
          x-description-en: Must equal the player's currency.
    TransferResult:
      type: object
      required: [txnId, status, balance, duplicate]
      properties:
        txnId:
          type: string
          description: 請求的 `txnId`。
          x-description-en: The `txnId` from the request.
        status:
          const: done
          description: 一律為 `done`（單階段，沒有處理中狀態）。
          x-description-en: Always `done` (single step; there is no pending state).
        balance:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 這筆轉帳完成當下的餘額；重送同一個 `txnId` 時回傳第一次的值。
          x-description-en: Balance right after this transfer; a resent `txnId` returns the original value.
        duplicate:
          type: boolean
          description: '`true` 表示這個 `txnId` 先前已處理過：這次沒有再動到餘額，回應是第一次的結果。'
          x-description-en: '`true` means this `txnId` had already been processed: the balance was not changed again and this is the original result.'
    TransferRecord:
      type: object
      required: [txnId, username, dir, amount, status, balance, at]
      properties:
        txnId:
          type: string
          description: 轉帳單號。
          x-description-en: Transfer ID.
        username:
          type: string
          description: 玩家帳號。
          x-description-en: Username.
        dir:
          type: string
          enum: [in, out, adjust]
          description: '`in` 轉入、`out` 轉出、`adjust` Console 人工上下分。'
          x-description-en: '`in` deposit, `out` withdrawal, `adjust` manual adjustment made in the Console.'
        amount:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 金額（正數）。
          x-description-en: Amount (positive).
        status:
          const: done
          description: 一律為 `done`。
          x-description-en: Always `done`.
        balance:
          type: [string, 'null']
          description: 這筆轉帳完成後的餘額。
          x-description-en: Balance right after this transfer.
        at: { $ref: '#/components/schemas/DateTime' }
    Balance:
      type: object
      required: [balance, currency]
      properties:
        balance: { $ref: '#/components/schemas/Amount' }
        currency:
          type: string
          description: 幣別。
          x-description-en: Currency.

    Zone:
      type: string
      enum: [B, P, T, BP, PP, S6, D, TG, ST, DB, DS, DO, DE, TB, TS, TO, TE, ANTE, CALL, AAB, FLOP, TURN, RIVER, BONUS, POT, P1E, P1D, P2E, P2D, P3E, P3D]
      description: 投注區。百家樂：`B` 莊、`P` 閒、`T` 和、`BP` 莊對、`PP` 閒對、`S6` 超級六（免佣）。龍虎：`D` 龍、`TG` 虎、`T` 和、`ST` 同花和；大小單雙（您在 Console 桌檯設定開啟的桌才有）：`DB` 龍大、`DS` 龍小、`DO` 龍單、`DE` 龍雙、`TB` 虎大、`TS` 虎小、`TO` 虎單、`TE` 虎雙。德州撲克：玩家對荷官 Casino Hold'em `ANTE` 底注、`CALL` 跟注（2 倍底注）、`AAB` AA 邊注；玩家對荷官 Texas Hold'em Bonus `ANTE` 底注、`FLOP` 翻牌注（2 倍底注）、`TURN` 轉牌注（1 倍底注）、`RIVER` 河牌注（1 倍底注）、`BONUS` 紅利注；玩家對玩家 `POT`（這手投入底池的合計，`return` 為沒被跟注退回的部分）。牛牛（都是押那一家閒贏莊）：`P1E` 閒一平倍、`P1D` 閒一翻倍、`P2E` 閒二平倍、`P2D` 閒二翻倍、`P3E` 閒三平倍、`P3D` 閒三翻倍。
      x-description-en: 'Bet zone. Baccarat: `B` banker, `P` player, `T` tie, `BP` banker pair, `PP` player pair, `S6` Super Six (no-commission). Dragon tiger: `D` dragon, `TG` tiger, `T` tie, `ST` suited tie; big/small/odd/even (only on tables where you turn it on in Console Table settings): `DB` dragon big, `DS` dragon small, `DO` dragon odd, `DE` dragon even, `TB` tiger big, `TS` tiger small, `TO` tiger odd, `TE` tiger even. Texas Hold''em: player vs dealer Casino Hold''em `ANTE` ante, `CALL` call (2× the ante), `AAB` AA bonus; player vs dealer Texas Hold''em Bonus `ANTE` ante, `FLOP` flop bet (2× the ante), `TURN` turn bet (1× the ante), `RIVER` river bet (1× the ante), `BONUS` bonus bet; player vs player `POT` (everything put into the pot in the hand; `return` is the uncalled part given back). Niu Niu (each one bets that this player hand beats the banker): `P1E` player 1 equal, `P1D` player 1 double, `P2E` player 2 equal, `P2D` player 2 double, `P3E` player 3 equal, `P3D` player 3 double.'
    BetLeg:
      type: object
      required: [zone, amount, return]
      properties:
        zone: { $ref: '#/components/schemas/Zone' }
        amount:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 本金。
          x-description-en: Stake.
        return:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 退回金額（本金＋淨贏；和局退回本金；輸為 0）。牛牛翻倍格另含退回的預扣。
          x-description-en: Amount returned (stake + net win; a push returns the stake; 0 when lost). For a Niu Niu double bet it also includes the returned hold.
        hold:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 只有牛牛翻倍格（`P1D`、`P2D`、`P3D`）：這一格下注時另外預扣的金額（預設為本金 × 2），沒用到的部分已含在 `return`。
          x-description-en: 'Niu Niu double bets only (`P1D`, `P2D`, `P3D`): the extra amount held from the balance when the bet was placed (stake × 2 by default); the unused part is included in `return`.'
        mult:
          type: integer
          minimum: 0
          maximum: 10
          description: 只有牛牛翻倍格：實際套用的倍數。閒贏為閒家牌型的倍數、閒輸為莊家牌型的倍數（預設 無牛～牛6 ×1、牛7～牛9 ×2、牛牛 ×3）；退款為 `0`。
          x-description-en: 'Niu Niu double bets only: the multiplier actually applied — the player hand''s multiplier when the player wins, the banker hand''s when the player loses (by default ×1 for No Bull to Bull 6, ×2 for Bull 7–9, ×3 for Niu Niu); `0` when refunded.'
    NiuniuHands:
      type: object
      description: 牛牛各家的牌型代碼，鍵 `B` 莊、`P1`～`P3` 閒一～閒三；值 `0` 無牛、`1`–`9` 牛幾、`10` 牛牛、`11` 五花牛、`12` 炸彈、`13` 五小牛（11–13 只在開啟特殊牌型的桌出現）。結果不完整時只有已知的家。
      x-description-en: 'Niu Niu hand rank per hand, keyed `B` banker and `P1`–`P3` players 1–3: `0` No Bull, `1`–`9` Bull 1–9, `10` Niu Niu, `11` Five Face Bull, `12` Bomb, `13` Five Small Bull (11–13 only at tables with special hands turned on). An incomplete result only has the hands that are known.'
      propertyNames: { enum: [B, P1, P2, P3] }
      additionalProperties: { type: integer, minimum: 0, maximum: 13 }
    NiuniuWinners:
      type: object
      required: [P1, P2, P3]
      description: 牛牛每一家閒對莊的輸贏：`P` 閒贏、`B` 莊贏（沒有和局）。
      x-description-en: 'Niu Niu outcome of each player hand against the banker: `P` the player wins, `B` the banker wins (there are no ties).'
      propertyNames: { enum: [P1, P2, P3] }
      additionalProperties: { type: string, enum: [P, B] }
    ResultCode:
      type: object
      required: [code, cardInfo]
      properties:
        code:
          type: string
          description: 結果碼（一個十六進位字元，格式見〈桌檯與局結果〉）；作廢且沒有結果時為空字串。德州撲克為這位玩家這手的結果：玩家對荷官 `player_wins`、`dealer_wins`、`tie`、`dealer_not_qualified`（只有 Casino Hold'em）、`fold`；玩家對玩家 `win`、`split`、`lose`、`fold`；作廢 `void`。牛牛為 5 個字元 `[勝負][莊][閒一][閒二][閒三]`：第一個字元是閒家贏的位元（閒一 1、閒二 2、閒三 4 相加，0–7），之後是各家的牌型（`0`–`9`、`A` 牛牛、`B` 五花牛、`C` 炸彈、`D` 五小牛、`-` 不知道），例如 `5893A`。
          x-description-en: 'Result code (one hexadecimal character, see "Tables and round results"); an empty string for a void round without a result. For Texas Hold''em it is this player''s outcome of the hand: player vs dealer `player_wins`, `dealer_wins`, `tie`, `dealer_not_qualified` (Casino Hold''em only), `fold`; player vs player `win`, `split`, `lose`, `fold`; `void` when the hand was voided. For Niu Niu it is 5 characters `[outcome][banker][player 1][player 2][player 3]`: the first is a bit mask of the winning player hands (player 1 = 1, player 2 = 2, player 3 = 4, added up: 0–7), followed by each hand''s rank (`0`–`9`, `A` Niu Niu, `B` Five Face Bull, `C` Bomb, `D` Five Small Bull, `-` unknown), for example `5893A`.'
        cardInfo:
          type: string
          description: 牌面字串（百家樂 12 字元、龍虎 4 字元，格式見〈桌檯與局結果〉）；沒有結果時為空字串。德州撲克為 `公牌|這位玩家的兩張底牌|荷官的兩張`（空白分隔的牌面代碼，荷官只有玩家對荷官桌才有），例如 `AS KD 7H 7C 2S|AH 7D|QS 3C`。牛牛為 `頭牌|莊|閒一|閒二|閒三`（每家 5 張依 1–5 的順序，空白分隔），例如 `7H|KS QD 10C 3S 5H|4C 6D JS 9C KH|2D 8S QC AH 2C|7D 3H KD 5C 5D`。
          x-description-en: 'Card string (12 characters for baccarat, 4 for dragon tiger, see "Tables and round results"); an empty string when there is no result. For Texas Hold''em it is `board|this player''s two hole cards|the dealer''s two cards` (space-separated card codes; the dealer part only at player-vs-dealer tables), for example `AS KD 7H 7C 2S|AH 7D|QS 3C`. For Niu Niu it is `first card|banker|player 1|player 2|player 3` (each hand''s 5 cards in slot order 1–5, space-separated), for example `7H|KS QD 10C 3S 5H|4C 6D JS 9C KH|2D 8S QC AH 2C|7D 3H KD 5C 5D`.'
        hands:
          allOf: [{ $ref: '#/components/schemas/NiuniuHands' }]
          description: '只有牛牛：各家的牌型代碼（`B`、`P1`、`P2`、`P3` → 0–13），不必自己解結果碼。作廢（`status: void`）的注單沒有這個欄位。'
          x-description-en: 'Niu Niu only: each hand''s rank (`B`, `P1`, `P2`, `P3` → 0–13), so you do not need to decode the result code. Absent on voided slips (`status: void`).'
        winners:
          allOf: [{ $ref: '#/components/schemas/NiuniuWinners' }]
          description: '只有牛牛：每一家閒對莊的輸贏（`P1`、`P2`、`P3` → `P` 閒贏、`B` 莊贏）。作廢（`status: void`）的注單沒有這個欄位。'
          x-description-en: 'Niu Niu only: the outcome of each player hand against the banker (`P1`, `P2`, `P3` → `P` player wins, `B` banker wins). Absent on voided slips (`status: void`).'
    BetRecord:
      type: object
      required: [recSeq, slipId, rev, status, username, currency, tableId, game, variant, roundId, shoe, round, bets, stake, validStake, rolling, payout, winLoss, delta, result, placedAt, settledAt]
      properties:
        recSeq:
          type: integer
          description: 寫入序號（同一個 UTC 月份內遞增）。
          x-description-en: Write sequence (increases within a UTC month).
        slipId:
          type: string
          description: 注單 ID：一位玩家在一局的所有下注為一張注單。重算與作廢沿用同一個 `slipId`。
          x-description-en: Slip ID. All of a player's bets in one round form one slip. Recalculations and voids keep the same `slipId`.
        rev:
          type: integer
          minimum: 1
          description: 版次。同一 `slipId` 以最大的 `rev` 為準。
          x-description-en: Revision. The highest `rev` of a `slipId` is final.
        status:
          type: string
          enum: [settled, recalculated, void]
          description: '`settled` 首次結算；`recalculated` 結果修正後重算；`void` 作廢（全額退回本金；牛牛連同預扣）。'
          x-description-en: '`settled` first settlement; `recalculated` recalculated after a result correction; `void` voided (stakes refunded in full; for Niu Niu together with the hold).'
        username:
          type: string
          description: 玩家帳號。
          x-description-en: Username.
        currency:
          type: string
          description: 幣別。
          x-description-en: Currency.
        tableId: { $ref: '#/components/schemas/TableId' }
        game: { $ref: '#/components/schemas/Game' }
        variant: { $ref: '#/components/schemas/Variant' }
        roundId:
          allOf: [{ $ref: '#/components/schemas/RoundId' }]
          description: 局 ID；德州撲克為這一手的 ID（handId）。
          x-description-en: Round ID; for Texas Hold'em the hand ID.
        shoe:
          type: string
          description: 靴號；德州撲克沒有靴，為空字串；牛牛每局一副新牌，這裡是場次（預設每 60 局換一場）。
          x-description-en: Shoe number; an empty string for Texas Hold'em (no shoe). Niu Niu uses a fresh deck every round, so this is the session (a new one every 60 rounds by default).
        round:
          type: integer
          description: 本靴第幾局；德州撲克為這張桌的第幾手；牛牛為本場次第幾局。
          x-description-en: Round number within the shoe; for Texas Hold'em the hand number at this table; for Niu Niu the round number within the session.
        bets:
          type: array
          items: { $ref: '#/components/schemas/BetLeg' }
          description: 各投注區的本金與退回。
          x-description-en: Stake and return per bet zone.
        stake:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 本金合計（牛牛不含預扣）。
          x-description-en: Total stake (for Niu Niu, without the hold).
        hold:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 只有牛牛：翻倍的預扣合計（各翻倍格的 `bets[].hold` 相加；沒有押翻倍時為 `0`）。翻倍可能輸超過本金（預設最多本金的 3 倍），所以下注時除了本金，另外從餘額預扣這筆金額，結算時把沒用到的部分連同派彩一起退回（含在 `payout`）。
          x-description-en: 'Niu Niu only: the total hold on double bets (the sum of `bets[].hold`; `0` without double bets). A double bet can lose more than its stake (up to 3× by default), so this amount is held from the balance on top of the stake when the bet is placed, and the unused part is returned with the payout at settlement (included in `payout`).'
        validStake:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 有效投注（莊閒對押、和局等依規則扣除）。牛牛：平倍為本金、翻倍為本金 × 實際倍數（`bets[].mult`），退款的格子不算。
          x-description-en: 'Valid (effective) stake, with banker/player hedges and pushes removed by the rules. Niu Niu: the stake for equal bets and stake × the multiplier actually applied (`bets[].mult`) for double bets; refunded bets do not count.'
        rolling:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 洗碼量（輸掉的注，超級六不計）。牛牛為輸掉的金額（翻倍輸的是本金 × 莊家牌型的倍數）。
          x-description-en: 'Rolling (lost stakes; Super Six excluded). Niu Niu: the amount lost (a lost double bet loses stake × the banker hand''s multiplier).'
        payout:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 派彩合計（含本金）；牛牛另含退回的預扣。
          x-description-en: Total payout (stake included); for Niu Niu it also includes the returned hold.
        winLoss:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 玩家輸贏 = `payout − stake`（正數玩家贏）；牛牛為 `payout − stake − hold`。對帳請一律用這個欄位。
          x-description-en: Player win/loss = `payout − stake` (positive means the player won); for Niu Niu `payout − stake − hold`. Always reconcile with this field.
        delta:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 這一版對玩家餘額的入帳差額：首次結算為 `payout`；重算為新舊派彩差（可為負）；作廢為本金（牛牛再加預扣）減去先前的派彩。
          x-description-en: 'What this revision changed on the player''s balance: `payout` for the first settlement; the payout difference for a recalculation (may be negative); stake (plus the hold for Niu Niu) minus the earlier payout for a void.'
        result: { $ref: '#/components/schemas/ResultCode' }
        rake:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 只有德州撲克：這手歸這位玩家的抽水（玩家對玩家桌；玩家對荷官桌為 `0`）。
          x-description-en: 'Texas Hold''em only: the rake taken from this player in the hand (player-vs-player tables; `0` at player-vs-dealer tables).'
        onsite:
          type: boolean
          description: 只有德州撲克：現場座位（開桌商戶的現場客人，荷官在平板登記、不走錢包；`username` 為「現場 #座位」）。
          x-description-en: 'Texas Hold''em only: an on-site seat (a guest at the table-owner''s studio, registered by the dealer on the tablet, outside the wallet; `username` is "現場 #seat").'
        hand:
          type: object
          required: [handId, no, outcome, cards]
          description: 只有德州撲克：這一手的結果摘要（完整手牌紀錄在 Console「德州撲克紀錄」）。
          x-description-en: 'Texas Hold''em only: a summary of the hand (the full hand history is in Console "Texas Hold''em records").'
          properties:
            handId:
              type: string
              description: 這一手的 ID（同 `roundId`）。
              x-description-en: Hand ID (same as `roundId`).
            no:
              type: integer
              description: 這張桌的第幾手（同 `round`）。
              x-description-en: Hand number at this table (same as `round`).
            outcome:
              type: string
              description: 這位玩家的結果（同 `result.code`）。
              x-description-en: This player's outcome (same as `result.code`).
            cards:
              type: string
              description: 公牌與這位玩家亮出的牌（同 `result.cardInfo`）。
              x-description-en: Board and the cards this player showed (same as `result.cardInfo`).
        placedAt: { $ref: '#/components/schemas/DateTime' }
        settledAt:
          allOf: [{ $ref: '#/components/schemas/DateTime' }]
          description: 這一版的結算（或重算、作廢）時間。
          x-description-en: When this revision was settled (or recalculated, or voided).
    BetPage:
      type: object
      required: [items, nextCursor, hasMore]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/BetRecord' }
        nextCursor: { $ref: '#/components/schemas/Cursor' }
        hasMore:
          type: boolean
          description: '`true` 表示可能還有下一頁，請立即再讀。'
          x-description-en: '`true` means there may be another page; read it right away.'
    PlayerDaySummary:
      type: object
      required: [username, slips, stake, validStake, rolling, payout, winLoss]
      properties:
        username:
          type: string
          description: 玩家帳號。
          x-description-en: Username.
        slips:
          type: integer
          description: 注單數（含作廢）。
          x-description-en: Number of slips (voided ones included).
        stake:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 本金合計（不含作廢）。
          x-description-en: Total stake (voided slips excluded).
        validStake: { $ref: '#/components/schemas/Amount' }
        rolling: { $ref: '#/components/schemas/Amount' }
        payout:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 派彩合計（含本金）。牛牛的注單以「本金＋輸贏」計入，不含退回的預扣（注單本身的 `payout` 含預扣）。
          x-description-en: Total payout (stake included). Niu Niu slips count as stake + win/loss, without the returned hold (a slip's own `payout` includes the hold).
        winLoss: { $ref: '#/components/schemas/Amount' }
        rake:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: 德州撲克玩家對玩家桌的抽水合計（其他遊戲為 `0`）。
          x-description-en: Total rake from Texas Hold'em player-vs-player tables (`0` for other games).
    BetSummary:
      type: object
      required: [date, timezone, players]
      properties:
        date: { $ref: '#/components/schemas/Date' }
        timezone:
          type: string
          description: 租戶時區（IANA，例如 `Asia/Taipei`）。
          x-description-en: Tenant time zone (IANA, for example `Asia/Taipei`).
        players:
          type: array
          items: { $ref: '#/components/schemas/PlayerDaySummary' }

    BaccaratResult:
      type: object
      required: [player, banker, winner, playerPair, bankerPair]
      description: 百家樂結果（與資料源介面 GFI 相同）。
      x-description-en: Baccarat result (the same shape as in the GFI data-source interface).
      properties:
        cards:
          type: object
          description: 牌面，位置 `P1 B1 P2 B2 P3 B3`，未發的牌省略；牌面格式如 `AS`、`10H`、`QD`。
          x-description-en: Cards by position `P1 B1 P2 B2 P3 B3` (undealt cards omitted); cards look like `AS`, `10H`, `QD`.
          propertyNames: { enum: [P1, B1, P2, B2, P3, B3] }
          additionalProperties: { type: string, pattern: '^(A|[2-9]|10|J|Q|K)[SHDC]$' }
        player: { type: integer, minimum: 0, maximum: 9 }
        banker: { type: integer, minimum: 0, maximum: 9 }
        winner: { type: string, enum: [P, B, T] }
        playerPair: { type: boolean }
        bankerPair: { type: boolean }
        cardCount: { type: integer, minimum: 4, maximum: 6 }
    DragonTigerResult:
      type: object
      required: [winner]
      description: 龍虎結果（與資料源介面 GFI 相同）。
      x-description-en: Dragon tiger result (the same shape as in the GFI data-source interface).
      properties:
        cards:
          type: object
          required: [D, T]
          properties:
            D: { type: string, pattern: '^(A|[2-9]|10|J|Q|K)[SHDC]$' }
            T: { type: string, pattern: '^(A|[2-9]|10|J|Q|K)[SHDC]$' }
        winner: { type: string, enum: [D, T, TIE] }
    NiuniuResult:
      type: object
      required: [winners]
      description: 牛牛結果（與資料源介面 GFI 相同）。各家輸贏以平台依牌面重算的結果碼 `code` 為準。
      x-description-en: Niu Niu result (the same shape as in the GFI data-source interface). The authoritative outcome is the result code `code`, which the platform recomputes from the cards.
      properties:
        cards:
          type: object
          description: 牌面，牌位 `F` 頭牌、`B-1`～`B-5` 莊、`P1-1`～`P1-5` 閒一、`P2-1`～`P2-5` 閒二、`P3-1`～`P3-5` 閒三（完整結果 21 張）；牌面格式如 `AS`、`10H`、`QD`。
          x-description-en: Cards by position — `F` first card, `B-1` to `B-5` banker, `P1-1` to `P1-5` player 1, `P2-1` to `P2-5` player 2, `P3-1` to `P3-5` player 3 (21 cards in a complete result); cards look like `AS`, `10H`, `QD`.
          maxProperties: 21
          propertyNames: { enum: [F, B-1, B-2, B-3, B-4, B-5, P1-1, P1-2, P1-3, P1-4, P1-5, P2-1, P2-2, P2-3, P2-4, P2-5, P3-1, P3-2, P3-3, P3-4, P3-5] }
          additionalProperties: { type: string, pattern: '^(A|[2-9]|10|J|Q|K)[SHDC]$' }
        hands: { $ref: '#/components/schemas/NiuniuHands' }
        winners: { $ref: '#/components/schemas/NiuniuWinners' }
    Round:
      type: object
      required: [roundId, tableId, shoe, round, status, code, cardInfo, result, complete, rev, openedAt, settledAt]
      properties:
        roundId: { $ref: '#/components/schemas/RoundId' }
        tableId: { $ref: '#/components/schemas/TableId' }
        shoe:
          type: string
          description: 靴號；牛牛為場次。
          x-description-en: Shoe number; for Niu Niu the session.
        round:
          type: integer
          description: 本靴第幾局；牛牛為本場次第幾局。
          x-description-en: Round number within the shoe; for Niu Niu within the session.
        status:
          type: string
          enum: [settled, void, disputed]
          description: '`settled` 已結算；`void` 作廢；`disputed` 爭議局（結算後收到超出護欄的修正或作廢，維持原結果、不動金額）。'
          x-description-en: '`settled`; `void`; `disputed` (a correction or void arrived outside the guardrails after settlement, so the original result and amounts stand).'
        code:
          type: [string, 'null']
          description: 結果碼（百家樂、龍虎為一個十六進位字元；牛牛為 5 個字元，例如 `5893A`）。
          x-description-en: Result code (one hexadecimal character for baccarat and dragon tiger; 5 characters for Niu Niu, for example `5893A`).
        cardInfo:
          type: [string, 'null']
          description: 牌面字串（牛牛為 `頭牌|莊|閒一|閒二|閒三`）。
          x-description-en: Card string (for Niu Niu `first card|banker|player 1|player 2|player 3`).
        result:
          description: 結構化結果（百家樂、龍虎或牛牛）；沒有結果時為 `null`。
          x-description-en: Structured result (baccarat, dragon tiger or Niu Niu); `null` when there is none.
          anyOf:
            - $ref: '#/components/schemas/BaccaratResult'
            - $ref: '#/components/schemas/DragonTigerResult'
            - $ref: '#/components/schemas/NiuniuResult'
            - type: 'null'
        complete:
          type: [boolean, 'null']
          description: '`true` 表示有完整牌面；`false` 表示資料源只提供了輸贏與點數（牛牛為各家輸贏，可能附牌型）。'
          x-description-en: '`true` when all cards are known; `false` when the data source only gave the outcome and points (for Niu Niu, each hand''s outcome, possibly with its rank).'
        rev:
          type: integer
          description: 結果版次（第一次結果為 1，每次修正 +1；作廢也會 +1）。
          x-description-en: Result revision (1 for the first result, +1 per correction; a void also adds 1).
        openedAt:
          type: [string, 'null']
          format: date-time
          description: 開放下注時間。
          x-description-en: When betting opened.
        settledAt:
          type: [string, 'null']
          format: date-time
          description: 首次結算時間。
          x-description-en: When the round was first settled.

    Table:
      type: object
      required: [tableId, game, variants, name, status, betSeconds, enabled]
      properties:
        tableId: { $ref: '#/components/schemas/TableId' }
        game: { $ref: '#/components/schemas/Game' }
        variants:
          type: array
          items: { type: string }
          description: 這張桌提供的玩法。
          x-description-en: Variants offered at this table.
        name:
          type: object
          additionalProperties: { type: string }
          description: 各語系的桌名，鍵為語系代碼（`CHT`、`CHS`、`ENG`…）。
          x-description-en: Table name per language, keyed by language code (`CHT`, `CHS`, `ENG`…).
        status:
          type: string
          enum: [open, maintenance]
          description: '`open` 開放；`maintenance` 維護中（資料源中斷等）。'
          x-description-en: '`open`; `maintenance` (for example the data source is down).'
        betSeconds:
          type: integer
          description: 預設下注秒數。
          x-description-en: Default betting time in seconds.
        enabled:
          type: boolean
          description: 你是否已啟用這張桌。
          x-description-en: Whether you have enabled this table.
        provider:
          type: object
          required: [name]
          description: 只有其他商戶提供給你的桌（跨商戶提供）才有：提供者。玩家用你的限紅、錢照舊由你結算，使用這張桌也算進你的桌數。
          x-description-en: 'Only on tables another merchant shares with you (cross-merchant sharing): the provider. Your players use your bet limits and you settle with them as usual; the table counts towards your table count.'
          properties:
            name:
              type: string
              description: 提供者名稱。
              x-description-en: Provider name.
    TableList:
      type: object
      required: [tables]
      properties:
        tables:
          type: array
          items: { $ref: '#/components/schemas/Table' }
    TableToggleRequest:
      type: object
      required: [tableId]
      properties:
        tableId: { $ref: '#/components/schemas/TableId' }
    TableToggleResult:
      type: object
      required: [tableId, enabled, used, quota]
      properties:
        tableId: { $ref: '#/components/schemas/TableId' }
        enabled:
          type: boolean
          description: 操作後是否啟用。
          x-description-en: Enabled after the call.
        used:
          type: integer
          description: 目前已啟用的桌數。
          x-description-en: Tables enabled now.
        quota:
          type: [integer, 'null']
          description: 方案可啟用的桌數；付費方案為 `null`（不限）。
          x-description-en: Table quota of your plan; `null` on the paid plan (no limit).

    Account:
      type: object
      required: [plan, state, balanceUsd, tablesUsed, tablesQuota, streamGbMonth, avgDailyUsd7d, daysLeft, graceUntil]
      properties:
        plan:
          type: string
          enum: [free, paid]
          description: 方案：`free` 免費展示方案、`paid` 付費方案。
          x-description-en: 'Plan: `free` (demo) or `paid`.'
        state:
          type: string
          enum: [FREE, PAID, GRACE, DOWNGRADED, SUSPENDED, CLOSED]
          description: 帳務狀態，說明見〈計費說明〉。
          x-description-en: Account state, see "Billing".
        balanceUsd:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 預付額度餘額（可為負數）。
          x-description-en: Prepaid credit balance (can be negative).
        tablesUsed:
          type: integer
          description: 已啟用桌數。
          x-description-en: Tables enabled.
        tablesQuota:
          type: [integer, 'null']
          description: 可啟用桌數；付費方案為 `null`。
          x-description-en: Table quota; `null` on the paid plan.
        streamGbMonth:
          type: string
          description: 本月（UTC）串流用量（GB，3 位小數）。
          x-description-en: Streaming used this month (UTC), in GB with 3 decimals.
        avgDailyUsd7d:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 近 7 天平均每日費用。
          x-description-en: Average daily cost over the last 7 days.
        daysLeft:
          type: [integer, 'null']
          description: 以目前費用估計，預付額度約可再用幾天；沒有費用時為 `null`。
          x-description-en: Estimated days the credit will last at the current cost; `null` when there is no cost.
        graceUntil:
          type: [string, 'null']
          format: date-time
          description: 寬限期截止時間（只在 `GRACE` 狀態）。
          x-description-en: End of the grace period (only in `GRACE`).
    UsageDay:
      type: object
      required: [day, tableDays, streamGb, streamSource, tableFeeUsd, streamFeeUsd]
      properties:
        day: { $ref: '#/components/schemas/Date' }
        tableDays:
          type: number
          description: 當天計費的桌·日。
          x-description-en: Billable table-days that day.
        streamGb:
          type: string
          description: 串流用量（GB，3 位小數）。
          x-description-en: Streaming used (GB, 3 decimals).
        streamSource:
          type: string
          enum: [cdn, client, estimate]
          description: 串流用量的依據：`cdn` 串流服務依標籤彙總、`client` 播放器回報、`estimate` 估算。
          x-description-en: 'Where the streaming number comes from: `cdn` (CDN totals by tag), `client` (player reports) or `estimate`.'
        tableFeeUsd:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 桌費。
          x-description-en: Table fee.
        streamFeeUsd:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 串流費。
          x-description-en: Streaming fee.
    UsageList:
      type: object
      required: [days]
      properties:
        days:
          type: array
          items: { $ref: '#/components/schemas/UsageDay' }
    Statement:
      type: object
      required: [period, openingUsd, chargesUsd, paymentsUsd, closingUsd]
      properties:
        period:
          type: string
          pattern: '^\d{4}-\d{2}$'
          description: 期間 `YYYY-MM`。
          x-description-en: Period `YYYY-MM`.
        openingUsd:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 期初餘額。
          x-description-en: Opening balance.
        chargesUsd:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 扣款合計（負數）。
          x-description-en: Total charges (negative).
        paymentsUsd:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 付款與加值合計。
          x-description-en: Total payments and credits.
        closingUsd:
          allOf: [{ $ref: '#/components/schemas/UsdAmount' }]
          description: 期末餘額。
          x-description-en: Closing balance.
    StatementList:
      type: object
      required: [statements]
      properties:
        statements:
          type: array
          items: { $ref: '#/components/schemas/Statement' }

    WebhookEnvelope:
      type: object
      required: [id, event, companyCode, createdAt, data]
      description: 所有 Webhook 共用的本文格式；`data` 依事件而定。
      x-description-en: The body shared by every webhook; `data` depends on the event.
      properties:
        id:
          type: string
          description: 投遞 ID（同 `X-Elite-Delivery`）；自動重試時不變，平台手動重送時會換新的。
          x-description-en: Delivery ID (same as `X-Elite-Delivery`); unchanged across automatic retries, new when the platform resends manually.
        event:
          type: string
          enum: [bet.settled, round.corrected, round.voided, player.kicked, account.grace, account.downgraded, account.restored, account.topup, account.low_balance, test]
          description: 事件名稱。
          x-description-en: Event name.
        companyCode:
          type: string
          description: 收到事件的租戶公司代碼（沙箱租戶為 `…-SBX`）。
          x-description-en: Company code of the tenant the event belongs to (`…-SBX` for a sandbox tenant).
        createdAt:
          allOf: [{ $ref: '#/components/schemas/DateTime' }]
          description: 這次投遞產生的時間（每次重試都會更新）。
          x-description-en: When this delivery was built (updated on every retry).
        data:
          type: object
          description: 事件內容，格式見各事件。
          x-description-en: Event content; see each event.
    WebhookRound:
      type: object
      required: [roundId, tableId, shoe, round, rev, resultCode, cardInfo, settledAt]
      description: 局事件的局資訊。
      x-description-en: Round information in round events.
      properties:
        roundId: { $ref: '#/components/schemas/RoundId' }
        tableId: { $ref: '#/components/schemas/TableId' }
        shoe:
          type: [string, 'null']
          description: 靴號（字串）；牛牛為場次。
          x-description-en: Shoe number (a string); for Niu Niu the session.
        round:
          type: [integer, 'null']
          description: 本靴第幾局；牛牛為本場次第幾局。
          x-description-en: Round number within the shoe; for Niu Niu within the session.
        rev:
          type: integer
          minimum: 1
          description: 這個事件對應的結果版次；`data.bets` 只包含這個版次的注單。
          x-description-en: The result revision of this event; `data.bets` only holds slips of this revision.
        resultCode:
          type: [string, 'null']
          description: 結果碼（百家樂、龍虎為一個十六進位字元；牛牛為 5 個字元）；沒有結果的作廢局為 `null`。
          x-description-en: Result code (one hexadecimal character for baccarat and dragon tiger; 5 characters for Niu Niu); `null` for a void round without a result.
        cardInfo:
          type: [string, 'null']
          description: 牌面字串（牛牛為 `頭牌|莊|閒一|閒二|閒三`）；沒有結果的作廢局為 `null`。
          x-description-en: Card string (for Niu Niu `first card|banker|player 1|player 2|player 3`); `null` for a void round without a result.
        settledAt:
          type: [string, 'null']
          format: date-time
          description: 這一局首次結算（或作廢）的時間。
          x-description-en: When the round was first settled (or voided).
    RoundEventData:
      type: object
      required: [round, bets]
      description: '`bet.settled`、`round.corrected`、`round.voided` 的 `data`。'
      x-description-en: '`data` of `bet.settled`, `round.corrected` and `round.voided`.'
      properties:
        round: { $ref: '#/components/schemas/WebhookRound' }
        bets:
          type: array
          items: { $ref: '#/components/schemas/BetRecord' }
          description: 你的玩家在這一局、這個版次的注單，格式與 `GET /bets` 的 `items` 完全相同。
          x-description-en: Your players' slips for this round and revision, exactly the same shape as `GET /bets` items.
    PlayerKickedData:
      type: object
      required: [username, reason, at]
      description: '`player.kicked` 的 `data`。'
      x-description-en: '`data` of `player.kicked`.'
      properties:
        username:
          type: string
          description: 玩家帳號。
          x-description-en: Username.
        reason:
          type: string
          enum: [logged_out, locked]
          description: '`logged_out`：`POST /player/logout`；`locked`：`POST /player/update` 設為 `locked`。'
          x-description-en: '`logged_out`: `POST /player/logout`; `locked`: `POST /player/update` set `locked`.'
        at: { $ref: '#/components/schemas/DateTime' }
    UsdCents:
      type: string
      pattern: '^-?\d+\.\d{2}$'
      description: USD 金額，四捨五入到分，固定 2 位小數（例如 `"496.83"`）。
      x-description-en: A USD amount rounded to cents, always 2 decimals (for example `"496.83"`).
      examples: ['496.83']
    AccountStateData:
      type: object
      required: [from, to, balanceUsd, graceUntil, actor]
      description: '`account.grace`、`account.downgraded`、`account.restored` 的 `data`。'
      x-description-en: '`data` of `account.grace`, `account.downgraded` and `account.restored`.'
      properties:
        from:
          type: string
          enum: [FREE, PAID, GRACE, DOWNGRADED, SUSPENDED, CLOSED]
          description: 原本的帳務狀態。
          x-description-en: Previous account state.
        to:
          type: string
          enum: [PAID, GRACE, DOWNGRADED]
          description: 新的帳務狀態。
          x-description-en: New account state.
        balanceUsd: { $ref: '#/components/schemas/UsdCents' }
        graceUntil:
          type: [string, 'null']
          format: date-time
          description: 寬限期截止時間（進入寬限期時）。
          x-description-en: End of the grace period (when entering it).
        actor:
          type: string
          description: 觸發者：`system`（自動）或操作者。
          x-description-en: 'Who triggered it: `system` (automatic) or an operator.'
    AccountTopupData:
      type: object
      required: [amountUsd, balanceUsd, ref]
      description: '`account.topup` 的 `data`。'
      x-description-en: '`data` of `account.topup`.'
      properties:
        amountUsd: { $ref: '#/components/schemas/UsdCents' }
        balanceUsd: { $ref: '#/components/schemas/UsdCents' }
        ref:
          type: string
          description: 付款參考編號（例如金流交易編號或 USDT 交易雜湊）。
          x-description-en: Payment reference (for example the gateway transaction ID or the USDT transaction hash).
    AccountLowBalanceData:
      type: object
      required: [balanceUsd, avgDailyUsd, daysLeft]
      description: '`account.low_balance` 的 `data`。'
      x-description-en: '`data` of `account.low_balance`.'
      properties:
        balanceUsd: { $ref: '#/components/schemas/UsdCents' }
        avgDailyUsd:
          allOf: [{ $ref: '#/components/schemas/UsdCents' }]
          description: 近 7 天平均每日費用。
          x-description-en: Average daily cost over the last 7 days.
        daysLeft:
          type: integer
          description: 以目前費用估計，預付額度約可再用的天數。
          x-description-en: Estimated days the credit will last at the current cost.
    TestEventData:
      type: object
      required: [message]
      description: '`test` 的 `data`。'
      x-description-en: '`data` of `test`.'
      properties:
        message: { const: elite webhook test }

  responses:
    BadRequest:
      description: 參數或本文錯誤
      x-description-en: Invalid parameters or body
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            INVALID_PARAMETER:
              value: { ok: false, error: { code: INVALID_PARAMETER, message: username is required } }
            INVALID_JSON:
              value: { ok: false, error: { code: INVALID_JSON, message: request body is not valid JSON } }
    Unauthorized:
      description: 簽章驗證失敗（金鑰、時間窗、nonce 或簽章）
      x-description-en: Authentication failed (key, time window, nonce or signature)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalid:
              value: { ok: false, error: { code: UNAUTHORIZED, message: invalid credentials } }
            skew:
              value: { ok: false, error: { code: UNAUTHORIZED, message: timestamp outside the ±300 s window } }
            replay:
              value: { ok: false, error: { code: UNAUTHORIZED, message: nonce already used } }
    Forbidden:
      description: 來源 IP 不在白名單、租戶已停權，或玩家被鎖定、超過玩家數上限
      x-description-en: Source IP not allowed, tenant suspended, or the player is locked or over the player limit
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            IP_NOT_ALLOWED:
              value: { ok: false, error: { code: IP_NOT_ALLOWED, message: source IP is not in the allowlist } }
            TENANT_SUSPENDED:
              value: { ok: false, error: { code: TENANT_SUSPENDED, message: tenant is suspended } }
    NotFound:
      description: 找不到（端點、玩家、轉帳、局或桌）
      x-description-en: Not found (endpoint, player, transfer, round or table)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            PLAYER_NOT_FOUND:
              value: { ok: false, error: { code: PLAYER_NOT_FOUND, message: player not found } }
            NOT_FOUND:
              value: { ok: false, error: { code: NOT_FOUND, message: unknown endpoint } }
    Conflict:
      description: 衝突（交易單號重複但內容不同、餘額不足、超過配額、帳戶停權）
      x-description-en: Conflict (txnId reused with different content, insufficient balance, quota exceeded, account locked)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            TXN_CONFLICT:
              value: { ok: false, error: { code: TXN_CONFLICT, message: txnId was already used with different content } }
            INSUFFICIENT_BALANCE:
              value: { ok: false, error: { code: INSUFFICIENT_BALANCE, message: insufficient available balance } }
    PayloadTooLarge:
      description: 請求本文超過 64 KB
      x-description-en: Request body larger than 64 KB
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            PAYLOAD_TOO_LARGE:
              value: { ok: false, error: { code: PAYLOAD_TOO_LARGE, message: request body too large } }
    TooManyRequests:
      description: 超過限流
      x-description-en: Rate limit exceeded
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            RATE_LIMITED:
              value: { ok: false, error: { code: RATE_LIMITED, message: too many requests } }
    InternalError:
      description: 伺服器錯誤，可重試（本文可能不是 JSON）
      x-description-en: Server error, safe to retry (the body may not be JSON)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            INTERNAL_ERROR:
              value: { ok: false, error: { code: INTERNAL_ERROR, message: 'internal error, please retry' } }
    WebhookAck:
      description: 任何 2xx 都表示已收到；請在 10 秒內回應。其他狀態碼、逾時或重新導向都視為失敗並重試。
      x-description-en: Any 2xx means received; answer within 10 seconds. Any other status, a timeout or a redirect counts as a failure and is retried.

  # 錯誤碼目錄：/doc〈錯誤碼〉由此產生。status 為 HTTP 狀態碼；common 表示任何端點都可能回傳；retry 表示可以原樣（換新的 nonce 與時間）重試。
  x-error-catalog:
    - code: UNAUTHORIZED
      status: 401
      common: true
      retry: false
      messages: [invalid credentials, timestamp outside the ±300 s window, nonce already used]
      zh-TW:
        meaning: 簽章驗證失敗。`invalid credentials`：金鑰 ID 格式錯誤、金鑰不存在或已停用（輪替中的舊金鑰超過 24 小時也算）、nonce 格式錯誤，或簽章不符。`timestamp outside the ±300 s window`：`X-Timestamp` 格式錯誤或與伺服器時間差超過 300 秒。`nonce already used`：同一個 nonce 在 10 分鐘內重複使用（這次請求沒有被處理）。
        action: 用〈簽章除錯器〉比對簽章字串；確認路徑從 `/api/tenant/v1` 開始並含查詢字串、以實際送出的本文位元組計算雜湊；伺服器校時（NTP）；每個請求（含重試）都產生新的 nonce 與時間戳。
      en:
        meaning: 'Authentication failed. `invalid credentials`: the key ID is malformed, unknown or revoked (a rotated key more than 24 hours old counts), the nonce is malformed, or the signature does not match. `timestamp outside the ±300 s window`: `X-Timestamp` is malformed or more than 300 seconds away from server time. `nonce already used`: the nonce was used again within 10 minutes (this request was not processed).'
        action: Compare your string to sign with the Signature debugger. Check that the path starts with `/api/tenant/v1` and includes the query string, and that you hash the exact body bytes you send. Sync your server clock (NTP). Use a new nonce and timestamp for every request, including retries.
    - code: IP_NOT_ALLOWED
      status: 403
      common: true
      retry: false
      messages: [source IP is not in the allowlist]
      zh-TW:
        meaning: 你設定了 IP 白名單，而這個請求的來源 IP 不在名單內。
        action: 在 Console「上線與串接 → IP 白名單與 Webhook」加入伺服器的對外 IP（IPv4／IPv6 或 CIDR）。
      en:
        meaning: You have an IP allowlist and the request came from an IP that is not on it.
        action: Add your server's outbound IP (IPv4/IPv6 or CIDR) in the Console under "Go-live & integration → IP allowlist and Webhook".
    - code: TENANT_SUSPENDED
      status: 403
      common: true
      retry: false
      messages: [tenant is suspended]
      zh-TW:
        meaning: 租戶已停用、停權或已關閉，所有 API 都暫停服務。
        action: 登入 Console 查看帳戶狀態，或聯絡支援。
      en:
        meaning: The tenant is disabled, suspended or closed; the whole API is unavailable.
        action: Check the account state in the Console or contact support.
    - code: RATE_LIMITED
      status: 429
      common: true
      retry: true
      messages: [too many requests]
      zh-TW:
        meaning: 超過每秒請求上限（付費方案 100 次／秒、沙箱 20 次／秒、免費方案 5 次／秒，可短暫突發到 2 倍）。
        action: 退避後重試（例如 200 ms、400 ms、800 ms…，加隨機延遲），並換新的 nonce 與時間戳；平時以批次與游標減少呼叫次數。
      en:
        meaning: Too many requests per second (100/s on the paid plan, 20/s in the sandbox, 5/s on the free plan; short bursts up to 2×).
        action: Back off and retry (for example 200 ms, 400 ms, 800 ms… with jitter) with a new nonce and timestamp. Reduce calls by paging with cursors.
    - code: PAYLOAD_TOO_LARGE
      status: 413
      common: true
      retry: false
      messages: [request body too large]
      zh-TW:
        meaning: 請求本文超過 64 KB。
        action: 縮小本文；本 API 的正常請求都遠小於這個上限。
      en:
        meaning: The request body is larger than 64 KB.
        action: Send a smaller body; normal requests to this API are far below the limit.
    - code: INVALID_JSON
      status: 400
      common: true
      retry: false
      messages: [request body is not valid JSON, request body must be an object]
      zh-TW:
        meaning: 本文不是合法的 JSON，或不是 JSON 物件。
        action: '送出 `Content-Type: application/json` 與 JSON 物件（`{…}`）。'
      en:
        meaning: The body is not valid JSON, or not a JSON object.
        action: 'Send `Content-Type: application/json` and a JSON object (`{…}`).'
    - code: NOT_FOUND
      status: 404
      common: true
      retry: false
      messages: [unknown endpoint, table not found]
      zh-TW:
        meaning: '`unknown endpoint`：方法與路徑不存在（例如用 GET 呼叫 POST 端點，或局 ID 不是 26 字元大寫 ULID）。`table not found`：啟用或停用的桌號不存在。'
        action: 對照〈API 參考〉的方法與路徑；桌號以 `GET /tables` 為準。
      en:
        meaning: '`unknown endpoint`: no such method and path (for example GET on a POST endpoint, or a round ID that is not a 26-character upper-case ULID). `table not found`: the table to enable or disable does not exist.'
        action: Check the method and path in the API reference; take table IDs from `GET /tables`.
    - code: INTERNAL_ERROR
      status: 500
      common: true
      retry: true
      messages: ['internal error, please retry']
      zh-TW:
        meaning: 伺服器暫時錯誤。任何 5xx（包含本文不是 JSON 的情況）都視為這一類。
        action: 退避後重試。轉帳請以**相同的 `txnId`** 重試，或先以 `GET /wallet/transfer` 查詢；持續發生請查看狀態頁或聯絡支援。
      en:
        meaning: A temporary server error. Treat any 5xx (including ones whose body is not JSON) the same way.
        action: Back off and retry. Retry transfers with the **same `txnId`**, or look them up with `GET /wallet/transfer` first. If it persists, check the status page or contact support.
    - code: INVALID_PARAMETER
      status: 400
      retry: false
      messages: ['<field> is required', '<field> must be a string', unsupported lang, lobbyUrl must be an http(s) URL, 'status must be active, locked or no_bet', password must be 6–64 characters, limitProfileId must be a positive integer or null, limitProfileId not found, 'limitProfileId currency is <CUR>, player currency is <CUR>', 'txnId must be 1–64 characters of A-Z a-z 0-9 _ . : -', txnId is required, from must be an ISO time, date must be a valid YYYY-MM-DD, from and to must be YYYY-MM-DD]
      zh-TW:
        meaning: 缺少必要欄位，或欄位格式、值不正確；`message` 會指出是哪個欄位。
        action: 依〈API 參考〉修正欄位後重送。
      en:
        meaning: A required field is missing, or a field has the wrong format or value; `message` names the field.
        action: Fix the field according to the API reference and send again.
    - code: INVALID_USERNAME
      status: 400
      retry: false
      messages: ['username must be 1–32 characters of A-Z a-z 0-9 _ . @ -', invalid username]
      zh-TW:
        meaning: 玩家帳號不符合規則（1–32 個字元，英數字與 `_ . @ -`），或查詢時沒有帶 `username`。
        action: 在你的系統把玩家帳號對應成符合規則的字串（例如以你的會員 ID 產生）。
      en:
        meaning: The username breaks the rules (1–32 characters of letters, digits and `_ . @ -`), or `username` is missing from a query.
        action: Map your players to usernames that follow the rules (for example derived from your member ID).
    - code: INVALID_CURRENCY
      status: 400
      retry: false
      messages: [invalid currency]
      zh-TW:
        meaning: 建立新玩家時指定的幣別代碼不合法（需要 3–5 個英文字母）。
        action: 使用正確的幣別代碼，或不帶 `currency` 以使用租戶預設幣別。
      en:
        meaning: The currency code given for a new player is invalid (3–5 letters required).
        action: Use a valid currency code, or leave `currency` out to use the tenant default.
    - code: CURRENCY_NOT_ENABLED
      status: 400
      retry: false
      messages: ['currency <CUR> is not enabled for this operator (enabled: <CUR>, <CUR>)']
      zh-TW:
        meaning: 建立新玩家時指定的幣別，租戶還沒開啟（多幣別：Console「限紅方案 → 幣別」）。已存在的玩家不受影響。
        action: 在 Console 開啟這個幣別並設定該幣別的限紅方案後再建立玩家，或不帶 `currency` 以使用主要幣別。
      en:
        meaning: The currency given for a new player is not enabled for the operator (Console → Bet limits → Currencies). Existing players are not affected.
        action: Enable the currency in Console and set up its bet-limit profiles first, or leave `currency` out to use the primary currency.
    - code: INVALID_AMOUNT
      status: 400
      retry: false
      messages: [amount must be a positive decimal string with at most 4 decimals, invalid amount]
      zh-TW:
        meaning: 金額不是大於 0、最多 4 位小數的十進位字串。
        action: 以字串傳送金額（例如 `"100.5"`），不要用浮點數或科學記號。
      en:
        meaning: The amount is not a positive decimal string with at most 4 decimals.
        action: Send the amount as a string (for example `"100.5"`), never as a float or in scientific notation.
    - code: INVALID_CURSOR
      status: 400
      retry: false
      messages: [cursor must come from a previous response]
      zh-TW:
        meaning: '`cursor` 格式錯誤。'
        action: 原樣帶回上一頁的 `nextCursor`；遺失時改用 `from` 從某個時間重新同步，並以 `(slipId, rev)` 去重。
      en:
        meaning: '`cursor` is malformed.'
        action: Send back the previous page's `nextCursor` unchanged. If you lost it, resync from a time with `from` and de-duplicate on `(slipId, rev)`.
    - code: CURRENCY_MISMATCH
      status: 400
      retry: false
      messages: [player currency is <CUR>]
      zh-TW:
        meaning: 轉帳的 `currency` 與玩家的幣別不同。玩家的幣別在建立時決定，之後不能變更。
        action: 以玩家的幣別轉帳（`GET /player` 可查）；不同幣別請使用不同的玩家帳號。
      en:
        meaning: The transfer `currency` differs from the player's currency, which is fixed when the player is created.
        action: Transfer in the player's currency (see `GET /player`). Use separate usernames for different currencies.
    - code: PLAYER_NOT_FOUND
      status: 404
      retry: false
      messages: [player not found]
      zh-TW:
        meaning: 這個帳號的玩家不存在。
        action: 玩家會在第一次 launch 或第一次轉入時自動建立；請先完成其中一個步驟。
      en:
        meaning: No player with this username.
        action: Players are created on their first launch or first deposit; do one of those first.
    - code: PLAYER_LOCKED
      status: 403
      retry: false
      messages: [player is locked]
      zh-TW:
        meaning: 玩家已被鎖定，不能啟動遊戲。
        action: 需要時以 `POST /player/update` 把 `status` 改回 `active`。
      en:
        meaning: The player is locked and cannot be launched.
        action: If appropriate, set `status` back to `active` with `POST /player/update`.
    - code: PLAYER_LIMIT
      status: 403
      retry: false
      messages: [demo plan allows at most <N> players]
      zh-TW:
        meaning: 玩家帳號數已達方案上限（免費展示方案預設 50、沙箱 500）。
        action: 沿用既有的測試玩家，或升級付費方案。
      en:
        meaning: The plan's player limit is reached (50 by default on the free demo plan, 500 in the sandbox).
        action: Reuse existing test players, or upgrade to the paid plan.
    - code: TXN_CONFLICT
      status: 409
      retry: false
      messages: [txnId was already used with different content, txnId was already used for another player]
      zh-TW:
        meaning: 這個 `txnId` 已經用在另一筆金額或方向不同的轉帳，或最近兩個月內已用在另一位玩家。
        action: 這是你的系統的單號錯誤：每筆新的轉帳都要用新的 `txnId`（整個租戶內唯一）；重試時則必須和第一次內容完全相同。
      en:
        meaning: This `txnId` was already used for a transfer with a different amount or direction, or for another player within the last two months.
        action: This is an ID bug on your side. Every new transfer needs a new `txnId`, unique across your tenant, and a retry must be identical to the first attempt.
    - code: INSUFFICIENT_BALANCE
      status: 409
      retry: false
      messages: [insufficient available balance]
      zh-TW:
        meaning: 轉出金額大於可用餘額（未結算注單的本金不可轉出）。
        action: 以 `GET /wallet/balance` 取得可用餘額後再轉出。
      en:
        meaning: The withdrawal is larger than the available balance (stakes of unsettled bets cannot be withdrawn).
        action: Read the available balance with `GET /wallet/balance` and withdraw at most that.
    - code: TXN_NOT_FOUND
      status: 404
      retry: false
      messages: [transfer not found]
      zh-TW:
        meaning: 查不到這個 `txnId` 的轉帳：查詢時有帶 `username` 表示這筆轉帳沒有執行（或仍在處理中）；沒帶時只查租戶流水（當月與前 3 個月）。
        action: 以**相同的 `txnId` 與內容**重送轉帳，直到得到明確的成功或 4xx 結果。不要當成失敗而改用新的單號。
      en:
        meaning: 'No transfer with this `txnId` was found. With `username` in the query this means the transfer was not executed (or is still being processed); without it only the tenant ledger (current and previous 3 months) is searched.'
        action: Resend the transfer with the **same `txnId` and content** until you get a definite success or 4xx. Do not treat it as failed and switch to a new ID.
    - code: ROUND_NOT_FOUND
      status: 404
      retry: false
      messages: [round not found]
      zh-TW:
        meaning: 這一局不存在，或尚未結算。
        action: 局在結算後才查得到；注單上的 `roundId` 一定查得到。
      en:
        meaning: The round does not exist or is not settled yet.
        action: Rounds become available once settled; any `roundId` from a bet record can be looked up.
    - code: QUOTA_EXCEEDED
      status: 409
      retry: false
      messages: ['超過方案配額（<N> 桌）']
      zh-TW:
        meaning: 已啟用的桌數達到免費方案配額（預設 2 桌）。
        action: 先停用其他桌，或在 Console「方案與帳單 → 方案」升級付費方案。
      en:
        meaning: You have reached the free plan's table quota (2 tables by default).
        action: Disable another table first, or upgrade in the Console under "Plan & billing → Plan".
    - code: ACCOUNT_LOCKED
      status: 409
      retry: false
      messages: [account is suspended]
      zh-TW:
        meaning: 帳戶已停權或關閉，不能啟用桌檯。
        action: 聯絡支援。
      en:
        meaning: The account is suspended or closed, so tables cannot be enabled.
        action: Contact support.
