# PUZZLE CRASH — Server API (v1) Server-authoritative sessions, **demo credits only**. Base path `/v1`, JSON. Implementation: `apps/server` (Fastify 5 · PostgreSQL · Redis). ## Conventions | | | |---|---| | Auth | `Authorization: Bearer ` from `POST /v1/players` (HMAC-signed player id — demo identity, see SECURITY.md) | | Idempotency | Every state-changing call needs `Idempotency-Key: [A-Za-z0-9_-]{8,128}`. Retrying with the same key returns the original result and never applies twice. Reusing a key for a *different* action → `422 IDEMPOTENCY_MISMATCH`. | | Concurrency | Actions carry `expectedVersion` (= `session.version`). A stale version → `409 VERSION_CONFLICT` with the current session in `error.details.session`. | | Validation | Bodies are strict JSON-schema: unknown fields, wrong types, non-integers and out-of-range values → `400 VALIDATION`. No type coercion. | | Errors | `{ "error": { "code": "…", "message": "…", "details"?: … } }` | | Rate limits | per player (else per IP): 600 req/min global · 25 actions/s · 30 session starts/min · 10 new players/min/IP → `429 RATE_LIMITED` | ## Endpoints ### `POST /v1/players` → 201 Creates a demo player with the starting balance. ```json { "playerId": "uuid", "token": "uuid.sig", "balance": 10000 } ``` ### `GET /v1/me` → `{ playerId, balance, notice }` `notice` is `null` or an `ExpiryNotice` — a session that was resolved while the player was away and has not been acknowledged yet (see *Session presence* below). ### `POST /v1/notices/:sessionId/ack` → `{ acknowledged: boolean }` The player has seen the notice; it is not returned again. ### `POST /v1/sessions` → 201 `SessionResponse` ```json { "difficulty": "basic" | "medium" | "hard", "stake": 1..100000, "clientSeed"?: "1–64 printable ASCII" } ``` Debits the stake, draws a CSPRNG server seed, publishes its commitment and deals hand 0. Errors: `409 ACTIVE_SESSION_EXISTS` (one live session per player; `details.sessionId`), `422 INSUFFICIENT_FUNDS`. ### `GET /v1/sessions/active` → `{ session: PublicSession | null, balance }` Recovery after reloads, crashes or device switches. ### `GET /v1/sessions?limit=1..100` → `{ sessions: HistoryItem[] }` ### `GET /v1/sessions/:id` → `{ session: PublicSession }` ### `POST /v1/sessions/:id/actions` → 200 `SessionResponse` ```json { "type": "PLACE", "slot": 0..2, "x": 0..7, "y": 0..7, "expectedVersion": 12 } { "type": "CONTINUE", "expectedVersion": 13 } { "type": "CASHOUT", "expectedVersion": 13 } ``` The server validates and applies the intent with the shared engine; the client never sends board, level, multiplier or payout. CASHOUT credits the wallet in the same transaction. | Status | Code | Meaning | |---|---|---| | 409 | `VERSION_CONFLICT` | stale `expectedVersion` (current session in details) | | 409 | `INVALID_STATUS` | e.g. PLACE during a decision, second CASHOUT | | 409 | `CASHOUT_LOCKED` | level below `minCashoutLevel` | | 409 | `FINAL_LEVEL` | CONTINUE after level 100 | | 409 | `BUSY` | session lock not acquired within 3 s | | 409 | `SESSION_EXPIRED` | the grace period ran out; the session was resolved (see presence) | | 422 | `ILLEGAL_PLACEMENT`, `INVALID_SLOT`, `SLOT_EMPTY` | rejected move (audited) | | 422 | `IDEMPOTENCY_MISMATCH` | key reused for another action | ### `POST /v1/sessions/:id/leave` → `{ session: PublicSession }` Deliberate exit (the player pressed *Menüye dön* / closed the game on purpose). Starts the **leave grace** (default 5 min). Calling it again does not extend the deadline. ### `POST /v1/sessions/:id/heartbeat` → `{ session: PublicSession }` "I'm here." Sent every 60 s while the game is on screen and immediately when the tab becomes visible again. Extends the **disconnect grace** (default 30 min) and cancels a pending leave. Any accepted action has the same effect. ### Session presence & expiry *Continue* is meant for interrupted sessions (connection lost, app killed, phone died), not for parking a session indefinitely. | Situation | Deadline | Configured by | |---|---|---| | Player deliberately left (`/leave`) | 5 min after leaving | `LEAVE_GRACE_SEC` | | No heartbeat or action (disconnect, crash) | 30 min after the last sign of life | `DISCONNECT_GRACE_SEC` | When the deadline passes (sweeper every `SWEEP_INTERVAL_MS`, default 15 s, and lazily on any access): - **Cashout unlocked** (`canCashout` at that moment) → the server performs the CASHOUT on the player's behalf: `status CASHED_OUT`, `endReason EXPIRED_AUTO_CASHOUT`, payout credited. It is a normal action in the hash chain, so `/verification` covers it. - **Cashout locked** → `status EXPIRED`, `endReason EXPIRED_FORFEIT`, the stake is lost. Either way an `ExpiryNotice` is queued for the player (`/v1/me`), and a late action gets `409 SESSION_EXPIRED` (current session in `details.session`). Audit events: `SESSION_LEFT`, `SESSION_RETURNED`, `SESSION_EXPIRED`. Metric: `pc_sessions_expired_total{outcome,cause}`. A player racing the sweeper with their own CASHOUT is paid exactly once (whoever commits first). ### `GET /v1/sessions/:id/verification` (finished sessions only) ```json { "serverSeed": "64 hex", "clientSeed": "…", "commitment": "64 hex", "generator": "gen-v2-hmac-sha256", "config": "difficulty-v2-cal-2026-10-13", "actions": [{ "seq": 1, "action": {…}, "stateHash": "…", "chainHash": "…" }], "checks": { "commitment": true, "dealtHands": true, "replay": true, "chain": true } } ``` `409 SESSION_ACTIVE` while live. The web client re-checks commitment and every dealt hand in the browser (`verifyDealtHands`). ### `GET /v1/config` Published rules: per difficulty `version, levelCount, handsPerLevel, minCashoutLevel, multiplierTable, maxMultiplier, sizeClassWeights, pieceProbabilities`, plus the fairness scheme. ### Ops `GET /healthz` (liveness) · `GET /readyz` (DB + Redis; 503 if either is down) · `GET /metrics` (Prometheus; not exposed through the public nginx). ## Types ```ts PublicSession = { id, difficulty, configVersion, version, commitment, clientSeed, createdAt, endedAt, state: GameState without `seed`, // board, hand (current only), level, multiplier, status, payout… canCashout: boolean, playableSlots: number[], status: 'PLAYING' | 'LEVEL_COMPLETE' | 'CASHED_OUT' | 'GAME_OVER' | 'EXPIRED', endReason: 'CASHOUT' | 'GAME_OVER' | 'EXPIRED_AUTO_CASHOUT' | 'EXPIRED_FORFEIT' | null, leftAt: string | null, // set while the player has deliberately left expiresInSec: number | null // live sessions: seconds until resolution (server clock) } ExpiryNotice = { sessionId, outcome: 'EXPIRED_AUTO_CASHOUT' | 'EXPIRED_FORFEIT', cause: 'LEFT' | 'DISCONNECTED', difficulty, stake, payout, level, endedAt } SessionResponse = { session: PublicSession, events: GameEvent[], balance: number } HistoryItem = { id, difficulty, stake, status, endReason, level, multiplier, payout, pieces, createdAt, endedAt } ``` `events` are the engine's semantic events (`PIECE_PLACED`, `LINES_CLEARED`, `COMBO_TRIGGERED`, `HAND_DEALT`, `LEVEL_COMPLETED`, `CASHOUT`, `GAME_OVER`, …) that drive the client's animations. ## Provably fair scheme 1. Start: `serverSeed ← CSPRNG(32 bytes)`, `commitment = SHA-256(serverSeed)` returned to the player. 2. Hand *n*: `HMAC-SHA256(serverSeed, clientSeed + ":" + n)` → sfc32 state → class-weighted draw (published weights; the generator never sees balance, winnings or decisions). 3. Hands are generated only when the previous hand completes; the seed stays server-side (encrypted at rest) until the session ends. 4. End: seed revealed via `/verification`; anyone can recompute every hand and the action replay. ## Example ```bash API=http://localhost:8088 TOKEN=$(curl -s -X POST $API/v1/players | jq -r .token) S=$(curl -s -X POST $API/v1/sessions -H "authorization: Bearer $TOKEN" -H "idempotency-key: start-$(date +%s)" \ -H 'content-type: application/json' -d '{"difficulty":"medium","stake":100}') ID=$(echo $S | jq -r .session.id) curl -s -X POST $API/v1/sessions/$ID/actions -H "authorization: Bearer $TOKEN" -H "idempotency-key: a1-$(date +%s)" \ -H 'content-type: application/json' -d '{"type":"PLACE","slot":0,"x":0,"y":0,"expectedVersion":0}' ```