{"openapi":"3.1.0","info":{"title":"2048 Cloud API","version":"2.1.0","summary":"Accounts, cross-device saves, leaderboards, achievements, and statistics for the 2048 game.","description":"This API is the optional cloud half of [2048](https://hoangsonww.github.io/2048-Game/). The game itself is still entirely local: every client plays, scores, undoes, and saves without ever calling this service, and a player who never signs in never touches it.\n\nWhat an account adds is continuity — the same round and the same best score on a laptop, a phone, and a tablet — plus leaderboards, achievements, and a daily challenge.\n\nThe service root (`GET /`) redirects to this documentation at `/docs`. Machine-readable OpenAPI lives at `/openapi.json`; Redoc and Scalar are at `/redoc` and `/reference`.\n\n### Principles\n\n- **The game never depends on the network.** Every endpoint here is additive. If this service is down, all three clients keep working exactly as they did before it existed.\n- **Rules stay in the clients.** The server validates boards and sanity-checks scores; it never simulates a move. A single-player puzzle has no authoritative server simulation to offer.\n- **Nothing is silently discarded.** When two devices disagree about a round, the further one wins and the other is preserved in a conflict slot the player can recover.\n- **Deletion means deletion.** `DELETE /auth/me` removes the account and every row that references it, in one request, with no tombstone.\n\n### Authentication\n\nSend `Authorization: Bearer <accessToken>`. Access tokens are short-lived and stateless; refresh tokens are long-lived, stored only as a hash, and rotated on every use. Replaying a rotated refresh token revokes the session.\n\n### Errors\n\nEvery failure is `{ \"error\": { \"code\", \"message\", \"details\"? }, \"requestId\" }`. Branch on `code`, never on `message`. Quote `requestId` in a bug report; it is also returned in the `X-Request-Id` header.","contact":{"name":"Son Nguyen","url":"https://github.com/hoangsonww/2048-Game"},"license":{"name":"MIT","identifier":"MIT"}},"servers":[{"url":"https://game-2048-cloud-api.vercel.app","description":"This deployment"},{"url":"http://localhost:4000","description":"Local development"}],"externalDocs":{"description":"Repository and architecture notes","url":"https://github.com/hoangsonww/2048-Game/blob/main/docs/backend.md"},"tags":[{"name":"Meta","description":"Health, readiness, version, configuration, and counters."},{"name":"Authentication","description":"Accounts, tokens, sessions, and deletion."},{"name":"Players","description":"Profiles, preferences, search, and data export."},{"name":"Cloud saves","description":"Cross-device round synchronisation with conflict preservation."},{"name":"Scores","description":"Submitting and reading finished rounds."},{"name":"Leaderboard","description":"Ranked boards over UTC windows."},{"name":"Achievements","description":"The catalog and a player's progress through it."},{"name":"Statistics","description":"Aggregates, distributions, and activity series."},{"name":"Social","description":"Follows, used only to scope a leaderboard."},{"name":"Daily challenge","description":"A seeded board everyone plays on the same UTC day."},{"name":"Telemetry","description":"Coarse, opt-in, expiring gameplay events."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"An access token from `/auth/login`, `/auth/register`, or `/auth/refresh`."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","example":"not_found","description":"Stable machine-readable identifier. Branch on this, never on the message."},"message":{"type":"string","example":"Score was not found."},"details":{"type":"object","additionalProperties":true}}},"requestId":{"type":"string","format":"uuid","description":"Echoed in the `X-Request-Id` response header. Quote it in a bug report."}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","example":412},"limit":{"type":"integer","example":25},"offset":{"type":"integer","example":0},"count":{"type":"integer","example":25},"hasMore":{"type":"boolean","example":true}}},"Board":{"description":"A board, either as 16 flat cells (row-major) or as a 4x4 grid. Every value is zero or a power of two.","oneOf":[{"type":"array","items":{"type":"integer","minimum":0},"minItems":16,"maxItems":16},{"type":"array","items":{"type":"array","items":{"type":"integer","minimum":0},"minItems":4,"maxItems":4},"minItems":4,"maxItems":4}],"example":[2,4,0,0,0,8,0,0,0,0,16,0,0,0,0,2]},"Preferences":{"type":"object","properties":{"theme":{"type":"string","enum":["system","light","dark"]},"reducedMotion":{"type":"boolean"},"soundEnabled":{"type":"boolean"},"hapticsEnabled":{"type":"boolean"},"autoSync":{"type":"boolean","description":"Whether the client should sync automatically after each valid move."},"publicProfile":{"type":"boolean"},"showOnLeaderboard":{"type":"boolean","description":"Turning this off hides the player's rows without deleting them."}}},"Statistics":{"type":"object","properties":{"bestScore":{"type":"integer","example":24680},"totalScore":{"type":"integer","example":812400},"gamesPlayed":{"type":"integer","example":137},"gamesWon":{"type":"integer","example":6},"totalMoves":{"type":"integer","example":41205},"highestTile":{"type":"integer","example":4096},"totalPlaytimeSeconds":{"type":"integer","example":92400},"lastPlayedAt":{"type":"string","format":"date-time","nullable":true}}},"PublicUser":{"type":"object","properties":{"id":{"type":"string","example":"66b2f0c0a1c4de00126ab9f1"},"username":{"type":"string","example":"ada"},"displayName":{"type":"string","example":"Ada L."},"country":{"type":"string","nullable":true,"example":"GB"},"avatarColor":{"type":"string","example":"#e96345"},"bio":{"type":"string"},"statistics":{"$ref":"#/components/schemas/Statistics"},"followerCount":{"type":"integer"},"followingCount":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"}}},"PrivateUser":{"allOf":[{"$ref":"#/components/schemas/PublicUser"},{"type":"object","properties":{"email":{"type":"string","format":"email"},"roles":{"type":"array","items":{"type":"string","enum":["player","moderator","admin"]}},"preferences":{"$ref":"#/components/schemas/Preferences"},"emailVerified":{"type":"boolean"},"lastSeenAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}}]},"TokenPair":{"type":"object","properties":{"accessToken":{"type":"string","description":"Short-lived bearer token. Send as `Authorization: Bearer <token>`."},"refreshToken":{"type":"string","description":"Long-lived, single-use. Every refresh rotates it; replaying an old one revokes the session."},"tokenType":{"type":"string","example":"Bearer"},"expiresIn":{"type":"integer","example":3600,"description":"Access token lifetime in seconds."},"refreshExpiresIn":{"type":"integer","example":5184000},"sessionId":{"type":"string","format":"uuid"}}},"AuthResponse":{"allOf":[{"type":"object","properties":{"user":{"$ref":"#/components/schemas/PrivateUser"}}},{"$ref":"#/components/schemas/TokenPair"}]},"Session":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"client":{"type":"string","enum":["web","ios","android","cli","unknown"]},"userAgent":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"lastUsedAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time"},"revoked":{"type":"boolean"},"current":{"type":"boolean","description":"True for the session that made this request."}}},"GameSave":{"type":"object","properties":{"slot":{"type":"string","example":"current"},"label":{"type":"string"},"board":{"type":"array","items":{"type":"integer"},"minItems":16,"maxItems":16},"grid":{"type":"array","items":{"type":"array","items":{"type":"integer"}},"description":"The same board as 4x4 rows, for clients that store it that way."},"score":{"type":"integer"},"bestScore":{"type":"integer"},"won":{"type":"boolean"},"gameOver":{"type":"boolean"},"moves":{"type":"integer"},"highestTile":{"type":"integer"},"elapsedSeconds":{"type":"integer"},"undo":{"type":"object","nullable":true,"description":"The one-step undo snapshot, carried so a device handoff mid-round does not consume the player's undo.","properties":{"board":{"type":"array","items":{"type":"integer"}},"score":{"type":"integer"},"won":{"type":"boolean"}}},"revision":{"type":"integer","description":"Increments on every write. Send it back as `baseRevision` to prove a save is a continuation rather than a divergence."},"client":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"SavePayload":{"type":"object","required":["score"],"properties":{"board":{"$ref":"#/components/schemas/Board"},"grid":{"$ref":"#/components/schemas/Board"},"score":{"type":"integer","minimum":0},"bestScore":{"type":"integer","minimum":0},"won":{"type":"boolean"},"gameOver":{"type":"boolean"},"moves":{"type":"integer","minimum":0},"elapsedSeconds":{"type":"integer","minimum":0},"label":{"type":"string","maxLength":60},"deviceId":{"type":"string","maxLength":64},"client":{"type":"string","enum":["web","ios","android","cli","unknown"]},"baseRevision":{"type":"integer","minimum":0,"description":"The `revision` this device last saw."},"undo":{"type":"object","nullable":true,"properties":{"board":{"$ref":"#/components/schemas/Board"},"score":{"type":"integer"},"won":{"type":"boolean"}}}}},"SyncResult":{"type":"object","properties":{"resolution":{"type":"string","enum":["uploaded","downloaded","in_sync","conflicted"],"description":"`conflicted` means both sides had real progress. The further round wins and the other is preserved in `conflictSlot` — nothing is ever discarded."},"winner":{"type":"string","nullable":true,"enum":["local","remote",null]},"conflictSlot":{"type":"string","nullable":true,"example":"conflict-m8s2k1"},"save":{"$ref":"#/components/schemas/GameSave"}}},"Score":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"country":{"type":"string","nullable":true},"avatarColor":{"type":"string"},"score":{"type":"integer"},"highestTile":{"type":"integer"},"moves":{"type":"integer"},"durationSeconds":{"type":"integer"},"won":{"type":"boolean"},"gameOver":{"type":"boolean"},"mode":{"type":"string","enum":["classic","daily"]},"challengeDate":{"type":"string","nullable":true},"client":{"type":"string"},"verified":{"type":"boolean","description":"False when the score exceeds what its final board could have produced. Unverified rounds are kept but never ranked."},"createdAt":{"type":"string","format":"date-time"}}},"LeaderboardEntry":{"type":"object","properties":{"rank":{"type":"integer","example":1},"userId":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"country":{"type":"string","nullable":true},"avatarColor":{"type":"string"},"score":{"type":"integer"},"highestTile":{"type":"integer"},"moves":{"type":"integer"},"durationSeconds":{"type":"integer"},"won":{"type":"boolean"},"client":{"type":"string"},"entries":{"type":"integer","description":"How many qualifying rounds this player has in the window."},"achievedAt":{"type":"string","format":"date-time"},"isViewer":{"type":"boolean","description":"Present when the request is authenticated."}}},"LeaderboardPage":{"type":"object","properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/LeaderboardEntry"}},"summary":{"type":"object","properties":{"players":{"type":"integer"},"topScore":{"type":"integer"},"averageScore":{"type":"integer"},"winners":{"type":"integer"}}},"window":{"type":"object","properties":{"period":{"type":"string","enum":["daily","weekly","monthly","yearly","all"]},"since":{"type":"string","format":"date-time","nullable":true},"until":{"type":"string","format":"date-time","nullable":true}}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"Rank":{"type":"object","properties":{"ranked":{"type":"boolean","description":"False when the player has no qualifying round in the window."},"rank":{"type":"integer","nullable":true},"players":{"type":"integer"},"percentile":{"type":"number","nullable":true,"example":97.4},"entry":{"$ref":"#/components/schemas/LeaderboardEntry"},"window":{"type":"object","additionalProperties":true}}},"Achievement":{"type":"object","properties":{"key":{"type":"string","example":"tile_2048"},"name":{"type":"string","example":"2048"},"description":{"type":"string"},"category":{"type":"string","enum":["milestone","tiles","score","dedication","skill","daily"]},"points":{"type":"integer"},"progress":{"type":"integer"},"target":{"type":"integer"},"unlocked":{"type":"boolean"},"unlockedAt":{"type":"string","format":"date-time","nullable":true}}},"DailyChallenge":{"type":"object","properties":{"date":{"type":"string","example":"2026-09-18"},"dayNumber":{"type":"integer","example":992},"seed":{"type":"integer","description":"FNV-1a of the date. A client can reproduce the board offline from this alone."},"board":{"type":"array","items":{"type":"integer"},"minItems":16,"maxItems":16},"modifier":{"type":"object","properties":{"key":{"type":"string","enum":["classic","tile_hunt","efficiency","endurance"]},"name":{"type":"string"},"description":{"type":"string"},"targetScore":{"type":"integer"}}},"expiresAt":{"type":"string","format":"date-time"}}},"GlobalStats":{"type":"object","properties":{"players":{"type":"integer"},"activeToday":{"type":"integer"},"games":{"type":"integer"},"gamesToday":{"type":"integer"},"savedRounds":{"type":"integer"},"wins":{"type":"integer"},"winRate":{"type":"number"},"topScore":{"type":"integer"},"averageScore":{"type":"integer"},"totalScore":{"type":"integer"},"totalMoves":{"type":"integer"},"highestTile":{"type":"integer"},"generatedAt":{"type":"string","format":"date-time"}}},"PersonalStats":{"type":"object","properties":{"games":{"type":"integer"},"wins":{"type":"integer"},"winRate":{"type":"number"},"bestScore":{"type":"integer"},"averageScore":{"type":"integer"},"totalScore":{"type":"integer"},"totalMoves":{"type":"integer"},"totalPlaytimeSeconds":{"type":"integer"},"highestTile":{"type":"integer"},"averageMovesPerGame":{"type":"integer"},"pointsPerMove":{"type":"number"},"firstGameAt":{"type":"string","format":"date-time","nullable":true},"lastGameAt":{"type":"string","format":"date-time","nullable":true},"tiles":{"type":"object","properties":{"games":{"type":"integer"},"buckets":{"type":"array","items":{"type":"object","properties":{"tile":{"type":"integer"},"games":{"type":"integer"},"bestScore":{"type":"integer"},"share":{"type":"number"}}}}}},"activity":{"type":"object","properties":{"days":{"type":"integer"},"since":{"type":"string","format":"date-time"},"series":{"type":"array","description":"Dense: days with no games appear as zeroes rather than being omitted.","items":{"type":"object","properties":{"date":{"type":"string"},"games":{"type":"integer"},"bestScore":{"type":"integer"},"totalScore":{"type":"integer"},"wins":{"type":"integer"}}}}}},"recentGames":{"type":"array","items":{"$ref":"#/components/schemas/Score"}}}}}},"security":[],"paths":{"/api/v1/health":{"get":{"tags":["Meta"],"operationId":"getHealth","summary":"Liveness","description":"Answers whether the process is running. It deliberately does **not** touch the database: a health check that fails because a dependency is slow invites a platform to restart a service that was fine. Use `/ready` for the dependency question.","security":[],"responses":{"200":{"description":"The service is running.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"ok"},"version":{"type":"string"},"uptimeSeconds":{"type":"integer"}}}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/ready":{"get":{"tags":["Meta"],"operationId":"getReadiness","summary":"Readiness, including a database ping","security":[],"responses":{"200":{"description":"The database answered.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"database":{"type":"object","additionalProperties":true}}}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The database is unreachable.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"/api/v1/version":{"get":{"tags":["Meta"],"operationId":"getVersion","summary":"Build and runtime versions","security":[],"responses":{"200":{"description":"Version information.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/config":{"get":{"tags":["Meta"],"operationId":"getClientConfig","summary":"Public client configuration","description":"Feature flags and limits, read once at start-up so clients do not hard-code values the server also enforces.","security":[],"responses":{"200":{"description":"Client configuration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/metrics":{"get":{"tags":["Meta"],"operationId":"getMetrics","summary":"Request counters for this instance","description":"Counters belong to one warm serverless instance and reset when it recycles. They are not fleet-wide totals.","security":[],"responses":{"200":{"description":"Counters.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/metrics.prom":{"get":{"tags":["Meta"],"operationId":"getPrometheusMetrics","summary":"The same counters in Prometheus exposition format","security":[],"responses":{"200":{"description":"Prometheus text.","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/register":{"post":{"tags":["Authentication"],"operationId":"register","summary":"Create an account","description":"Returns a token pair immediately, so a player who signs up mid-round can sync without a second request.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["username","email","password"],"properties":{"username":{"type":"string","minLength":3,"maxLength":24,"example":"ada"},"email":{"type":"string","format":"email","example":"ada@example.com"},"password":{"type":"string","minLength":8,"description":"At least 8 characters, including one letter and one number."},"displayName":{"type":"string","maxLength":40},"country":{"type":"string","minLength":2,"maxLength":2,"example":"GB"},"client":{"type":"string","enum":["web","ios","android","cli","unknown"]}}}}}},"responses":{"201":{"description":"Account created and signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"403":{"description":"Registration is closed on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The username or email address is already taken. `error.details.field` says which.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["Authentication"],"operationId":"login","summary":"Sign in","description":"An unknown account and a wrong password return the same error, so this endpoint cannot be used to discover who has an account.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["identifier","password"],"properties":{"identifier":{"type":"string","description":"Username or email address.","example":"ada"},"password":{"type":"string"},"client":{"type":"string","enum":["web","ios","android","cli","unknown"]}}}}}},"responses":{"200":{"description":"Signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"401":{"description":"Invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/refresh":{"post":{"tags":["Authentication"],"operationId":"refresh","summary":"Rotate a token pair","description":"Refresh tokens are single-use. Replaying a rotated token revokes the whole session, which is what turns a stolen token into a detectable event rather than a silent one.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"A fresh pair.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"401":{"description":"The session is gone, expired, or the token was already rotated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/logout":{"post":{"tags":["Authentication"],"operationId":"logout","summary":"Revoke one session","security":[],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"Signed out.","content":{"application/json":{"schema":{"type":"object","properties":{"signedOut":{"type":"boolean"}}}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/logout-all":{"post":{"tags":["Authentication"],"operationId":"logoutAll","summary":"Revoke every session on the account","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"All sessions revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"signedOut":{"type":"boolean"},"sessionsRevoked":{"type":"integer"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["Authentication"],"operationId":"getCurrentUser","summary":"The signed-in account","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The account.","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/PrivateUser"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"tags":["Authentication"],"operationId":"updateCurrentUser","summary":"Update profile fields","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"displayName":{"type":"string","maxLength":40},"country":{"type":"string","minLength":2,"maxLength":2,"nullable":true},"avatarColor":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"bio":{"type":"string","maxLength":280}}}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/PrivateUser"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Authentication"],"operationId":"deleteAccount","summary":"Delete the account and every trace of it","description":"Removes the account, its sessions, saves, scores, achievements, follows, and telemetry in one request. There is no tombstone and no recovery window.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password","confirm"],"properties":{"password":{"type":"string"},"confirm":{"type":"string","enum":["DELETE"]}}}}}},"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/change-password":{"post":{"tags":["Authentication"],"operationId":"changePassword","summary":"Change the password","description":"By default every other session is revoked, because a password change that leaves other devices signed in is not a password change.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["currentPassword","newPassword"],"properties":{"currentPassword":{"type":"string"},"newPassword":{"type":"string","minLength":8},"signOutOtherSessions":{"type":"boolean","default":true}}}}}},"responses":{"200":{"description":"Changed.","content":{"application/json":{"schema":{"type":"object","properties":{"changed":{"type":"boolean"},"sessionsRevoked":{"type":"integer"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/reset-password":{"post":{"tags":["Authentication"],"operationId":"resetPassword","summary":"Reset a forgotten password","description":"Interim recovery path with no email round trip: a matching username and email address are enough to set a new password, and every session on the account is revoked. Knowing both is therefore account takeover, so this is gated behind `FEATURE_PASSWORD_RESET` and the auth rate limiter, and answers identically whichever half is wrong. Replace with a mailed single-use token before relying on it.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["username","email","newPassword"],"properties":{"username":{"type":"string","minLength":3,"maxLength":24},"email":{"type":"string","format":"email"},"newPassword":{"type":"string","minLength":8}}}}}},"responses":{"200":{"description":"Reset.","content":{"application/json":{"schema":{"type":"object","properties":{"reset":{"type":"boolean"},"sessionsRevoked":{"type":"integer"}}}}}},"401":{"description":"The username and email do not match an account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Password reset is disabled on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/sessions":{"get":{"tags":["Authentication"],"operationId":"listSessions","summary":"List signed-in devices","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Sessions, newest first.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Session"}}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/sessions/{sessionId}":{"delete":{"tags":["Authentication"],"operationId":"revokeSession","summary":"Revoke one device","security":[{"bearerAuth":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Session identifier from `GET /auth/sessions`."}],"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"revoked":{"type":"boolean"},"id":{"type":"string"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such session on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/auth/available":{"get":{"tags":["Authentication"],"operationId":"checkAvailability","summary":"Check whether a username or email is free","description":"Intended for inline validation on a sign-up form. The unique indexes remain the real guarantee; this is a better error message, not the enforcement.","security":[],"parameters":[{"name":"username","in":"query","schema":{"type":"string"},"description":"Username to test."},{"name":"email","in":"query","schema":{"type":"string","format":"email"},"description":"Email address to test."}],"responses":{"200":{"description":"Availability.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/users":{"get":{"tags":["Players"],"operationId":"listPlayers","summary":"Search players","description":"Matching is an anchored prefix on the username so the query uses an index rather than scanning the collection on every keystroke.","security":[],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."},{"name":"q","in":"query","schema":{"type":"string"},"description":"Username prefix."},{"name":"sort","in":"query","schema":{"type":"string","enum":["best","recent","name"],"default":"best"},"description":"Ordering."}],"responses":{"200":{"description":"Matching players.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PublicUser"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/users/me/preferences":{"get":{"tags":["Players"],"operationId":"getPreferences","summary":"Read preferences","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Preferences.","content":{"application/json":{"schema":{"type":"object","properties":{"preferences":{"$ref":"#/components/schemas/Preferences"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"tags":["Players"],"operationId":"updatePreferences","summary":"Update preferences","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Preferences"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"preferences":{"$ref":"#/components/schemas/Preferences"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/users/me/export":{"get":{"tags":["Players"],"operationId":"exportAccountData","summary":"Download everything stored about the account","description":"The counterpart to account deletion: data about a player should be theirs to read as well as theirs to remove.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"A JSON export.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/users/{username}":{"get":{"tags":["Players"],"operationId":"getPlayer","summary":"A public profile","security":[],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."}],"responses":{"200":{"description":"The profile.","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/PublicUser"},"isSelf":{"type":"boolean"}}}}}},"404":{"description":"No such player.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/users/{username}/scores":{"get":{"tags":["Players"],"operationId":"getPlayerScores","summary":"A player's best rounds","security":[],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."}],"responses":{"200":{"description":"Scores, highest first.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Score"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"403":{"description":"That profile is private.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such player.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/users/{username}/achievements":{"get":{"tags":["Players"],"operationId":"getPlayerAchievements","summary":"A player's achievements","security":[],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."}],"responses":{"200":{"description":"Achievements and a completion summary.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Achievement"}},"summary":{"type":"object","additionalProperties":true}}}}}},"403":{"description":"That profile is private.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such player.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/users/{username}/stats":{"get":{"tags":["Players"],"operationId":"getPlayerStats","summary":"A player's statistics","security":[],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."}],"responses":{"200":{"description":"Statistics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalStats"}}}},"403":{"description":"That profile is private.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such player.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/saves":{"get":{"tags":["Cloud saves"],"operationId":"listSaves","summary":"List every slot","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Slots, most recently updated first.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/GameSave"}},"limits":{"type":"object","additionalProperties":true}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Cloud saves"],"operationId":"deleteAllSaves","summary":"Delete every slot","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["confirm"],"properties":{"confirm":{"type":"string","enum":["DELETE"]}}}}}},"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"count":{"type":"integer"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/saves/sync":{"post":{"tags":["Cloud saves"],"operationId":"syncSave","summary":"Two-way synchronisation","description":"The endpoint to call on launch, on sign-in, and on reconnect — the only one that can reconcile two devices without losing a round.\n\nResolution order: no remote save uploads the local one; no local save downloads the remote one; identical rounds report `in_sync`; a local save carrying a `baseRevision` at or above the stored revision is a continuation and uploads; otherwise the further round (score, then moves) wins and **the other is preserved in a `conflict-…` slot**. Nothing is discarded.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"slot":{"type":"string","default":"current"},"strategy":{"type":"string","enum":["auto","prefer-local","prefer-remote"],"default":"auto","description":"`auto` applies the resolution order above. The other two are for an explicit user choice after a conflict."},"save":{"oneOf":[{"$ref":"#/components/schemas/SavePayload"},{"type":"null"}],"description":"The device's local save, or null when it has none."}}}}}},"responses":{"200":{"description":"Resolved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SyncResult"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/saves/current":{"get":{"tags":["Cloud saves"],"operationId":"getCurrentSave","summary":"Read the active round","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The save.","content":{"application/json":{"schema":{"type":"object","properties":{"save":{"$ref":"#/components/schemas/GameSave"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No cloud save yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"tags":["Cloud saves"],"operationId":"putCurrentSave","summary":"Overwrite the active round","description":"Unconditional: the caller's save wins. Use `POST /saves/sync` unless the player has explicitly chosen this device's round.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SavePayload"}}}},"responses":{"200":{"description":"Stored.","content":{"application/json":{"schema":{"type":"object","properties":{"save":{"$ref":"#/components/schemas/GameSave"},"resolution":{"type":"string"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/saves/{slot}":{"get":{"tags":["Cloud saves"],"operationId":"getSave","summary":"Read a slot","security":[{"bearerAuth":[]}],"parameters":[{"name":"slot","in":"path","required":true,"schema":{"type":"string"},"description":"Slot name."}],"responses":{"200":{"description":"The save.","content":{"application/json":{"schema":{"type":"object","properties":{"save":{"$ref":"#/components/schemas/GameSave"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such slot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"tags":["Cloud saves"],"operationId":"putSave","summary":"Create or overwrite a slot","security":[{"bearerAuth":[]}],"parameters":[{"name":"slot","in":"path","required":true,"schema":{"type":"string"},"description":"Slot name."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SavePayload"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"save":{"$ref":"#/components/schemas/GameSave"}}}}}},"201":{"description":"Created.","content":{"application/json":{"schema":{"type":"object","properties":{"save":{"$ref":"#/components/schemas/GameSave"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The slot limit for this account is already reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Cloud saves"],"operationId":"deleteSave","summary":"Delete a slot","security":[{"bearerAuth":[]}],"parameters":[{"name":"slot","in":"path","required":true,"schema":{"type":"string"},"description":"Slot name."}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"slot":{"type":"string"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such slot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scores":{"post":{"tags":["Scores"],"operationId":"submitScore","summary":"Submit a finished round","description":"Idempotent: the same board and score from the same player returns the original row with `duplicate: true` and a 200 rather than creating a second leaderboard entry, so a retry after a flaky network is safe.\n\nA score that exceeds what its final board could have produced is stored with `verified: false` and kept out of every leaderboard. It is flagged rather than refused, because refusing would lose a genuine round to a client bug.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["score"],"properties":{"board":{"$ref":"#/components/schemas/Board"},"grid":{"$ref":"#/components/schemas/Board"},"score":{"type":"integer","minimum":0},"moves":{"type":"integer","minimum":0},"durationSeconds":{"type":"integer","minimum":0},"mode":{"type":"string","enum":["classic","daily"],"default":"classic"},"challengeDate":{"type":"string","nullable":true},"client":{"type":"string","enum":["web","ios","android","cli","unknown"]}}}}}},"responses":{"200":{"description":"Already recorded; the original row is returned.","content":{"application/json":{"schema":{"type":"object","properties":{"score":{"$ref":"#/components/schemas/Score"},"duplicate":{"type":"boolean"}}}}}},"201":{"description":"Recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"score":{"$ref":"#/components/schemas/Score"},"duplicate":{"type":"boolean"},"unlockedAchievements":{"type":"array","items":{"$ref":"#/components/schemas/Achievement"}},"statistics":{"$ref":"#/components/schemas/Statistics"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Scores"],"operationId":"listMyScores","summary":"The signed-in player's rounds","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."},{"name":"mode","in":"query","schema":{"type":"string","enum":["classic","daily","all"],"default":"all"},"description":"Filter by mode."},{"name":"sort","in":"query","schema":{"type":"string","enum":["recent","score"],"default":"recent"},"description":"Ordering."},{"name":"wonOnly","in":"query","schema":{"type":"boolean","default":false},"description":"Only rounds that reached 2048."}],"responses":{"200":{"description":"Rounds.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Score"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scores/best":{"get":{"tags":["Scores"],"operationId":"getPersonalBest","summary":"Personal best","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The best verified round, or null.","content":{"application/json":{"schema":{"type":"object","properties":{"best":{"oneOf":[{"$ref":"#/components/schemas/Score"},{"type":"null"}]}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scores/stats":{"get":{"tags":["Scores"],"operationId":"getMyScoreStats","summary":"Career statistics","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Statistics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalStats"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/scores/{id}":{"get":{"tags":["Scores"],"operationId":"getScore","summary":"One round, with its final board","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Score identifier."}],"responses":{"200":{"description":"The round.","content":{"application/json":{"schema":{"type":"object","properties":{"score":{"$ref":"#/components/schemas/Score"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such round on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Scores"],"operationId":"deleteScore","summary":"Delete a round","description":"Career totals are intentionally left alone: they record rounds played, and making `gamesPlayed` go down because a row was removed is not what anyone expects.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Score identifier."}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such round on this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/leaderboard":{"get":{"tags":["Leaderboard"],"operationId":"getLeaderboard","summary":"A ranked page","description":"One row per player — their best qualifying round in the window, never three of the same player's games in the top ten. Ties break by the earlier submission, so a player's rank does not drift between two identical requests.\n\nAuthenticated requests mark the viewer's own row with `isViewer` and are never shared-cached.","security":[],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."},{"name":"period","in":"query","schema":{"type":"string","enum":["daily","weekly","monthly","yearly","all"],"default":"all"},"description":"Window to rank within. All windows are UTC; `GET /leaderboard/periods` returns the exact boundaries."},{"name":"mode","in":"query","schema":{"type":"string","enum":["classic","daily"],"default":"classic"},"description":"Which board to read."},{"name":"challengeDate","in":"query","schema":{"type":"string"},"description":"For `mode=daily`, the challenge date."}],"responses":{"200":{"description":"A page of the board.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeaderboardPage"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/leaderboard/periods":{"get":{"tags":["Leaderboard"],"operationId":"getLeaderboardPeriods","summary":"The exact UTC boundaries of every window","security":[],"responses":{"200":{"description":"Window boundaries.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/leaderboard/me":{"get":{"tags":["Leaderboard"],"operationId":"getMyRank","summary":"The signed-in player's rank","security":[{"bearerAuth":[]}],"parameters":[{"name":"period","in":"query","schema":{"type":"string","enum":["daily","weekly","monthly","yearly","all"],"default":"all"},"description":"Window to rank within. All windows are UTC; `GET /leaderboard/periods` returns the exact boundaries."},{"name":"mode","in":"query","schema":{"type":"string","enum":["classic","daily"],"default":"classic"},"description":"Which board to read."}],"responses":{"200":{"description":"Rank and percentile.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Rank"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/leaderboard/around-me":{"get":{"tags":["Leaderboard"],"operationId":"getRanksAroundMe","summary":"The slice of the board around the player","security":[{"bearerAuth":[]}],"parameters":[{"name":"period","in":"query","schema":{"type":"string","enum":["daily","weekly","monthly","yearly","all"],"default":"all"},"description":"Window to rank within. All windows are UTC; `GET /leaderboard/periods` returns the exact boundaries."},{"name":"mode","in":"query","schema":{"type":"string","enum":["classic","daily"],"default":"classic"},"description":"Which board to read."},{"name":"radius","in":"query","schema":{"type":"integer","minimum":1,"maximum":25,"default":4},"description":"Rows either side."}],"responses":{"200":{"description":"Neighbouring ranks.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/leaderboard/friends":{"get":{"tags":["Leaderboard"],"operationId":"getFriendsLeaderboard","summary":"A board of the people the player follows","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."},{"name":"period","in":"query","schema":{"type":"string","enum":["daily","weekly","monthly","yearly","all"],"default":"all"},"description":"Window to rank within. All windows are UTC; `GET /leaderboard/periods` returns the exact boundaries."}],"responses":{"200":{"description":"A page of the board.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeaderboardPage"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/leaderboard/users/{username}":{"get":{"tags":["Leaderboard"],"operationId":"getPlayerRank","summary":"Any player's rank","security":[],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."},{"name":"period","in":"query","schema":{"type":"string","enum":["daily","weekly","monthly","yearly","all"],"default":"all"},"description":"Window to rank within. All windows are UTC; `GET /leaderboard/periods` returns the exact boundaries."},{"name":"mode","in":"query","schema":{"type":"string","enum":["classic","daily"],"default":"classic"},"description":"Which board to read."}],"responses":{"200":{"description":"Rank and percentile.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Rank"}}}},"404":{"description":"No such player.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/achievements":{"get":{"tags":["Achievements"],"operationId":"listAchievements","summary":"The catalog","description":"Definitions are compiled into the build, so this changes only on deploy and is cached for an hour.","security":[],"responses":{"200":{"description":"Every achievement.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Achievement"}}}}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/achievements/me":{"get":{"tags":["Achievements"],"operationId":"getMyAchievements","summary":"Progress for the signed-in player","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Every achievement, earned or not, plus a completion summary.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Achievement"}},"summary":{"type":"object","additionalProperties":true}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/achievements/{key}":{"get":{"tags":["Achievements"],"operationId":"getAchievement","summary":"One definition","security":[],"parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"},"description":"Achievement key, for example `tile_2048`."}],"responses":{"200":{"description":"The definition.","content":{"application/json":{"schema":{"type":"object","properties":{"achievement":{"$ref":"#/components/schemas/Achievement"}}}}}},"404":{"description":"No such achievement.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/stats/global":{"get":{"tags":["Statistics"],"operationId":"getGlobalStats","summary":"Service-wide totals","security":[],"responses":{"200":{"description":"Totals.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalStats"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/stats/tiles":{"get":{"tags":["Statistics"],"operationId":"getTileDistribution","summary":"How far rounds get","description":"The share of rounds ending at each highest tile — the clearest single picture of the difficulty curve.","security":[],"responses":{"200":{"description":"Distribution.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/stats/activity":{"get":{"tags":["Statistics"],"operationId":"getActivitySeries","summary":"A dense daily series","description":"Days with no games appear as zeroes rather than being omitted, so the series can be charted without silently rewriting the x-axis.","security":[],"parameters":[{"name":"days","in":"query","schema":{"type":"integer","minimum":1,"maximum":365,"default":30},"description":"Length of the window."}],"responses":{"200":{"description":"The series.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/stats/me":{"get":{"tags":["Statistics"],"operationId":"getMyStats","summary":"The signed-in player's statistics","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Statistics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonalStats"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/stats/me/activity":{"get":{"tags":["Statistics"],"operationId":"getMyActivity","summary":"The signed-in player's daily series","security":[{"bearerAuth":[]}],"parameters":[{"name":"days","in":"query","schema":{"type":"integer","minimum":1,"maximum":365,"default":30},"description":"Length of the window."}],"responses":{"200":{"description":"The series.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social/follow/{username}":{"post":{"tags":["Social"],"operationId":"followPlayer","summary":"Follow a player","description":"Idempotent: following twice succeeds rather than returning a duplicate-key error the client has to special-case.","security":[{"bearerAuth":[]}],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."}],"responses":{"200":{"description":"Already following.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"201":{"description":"Now following.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"description":"You cannot follow yourself.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Social"],"operationId":"unfollowPlayer","summary":"Unfollow a player","security":[{"bearerAuth":[]}],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."}],"responses":{"200":{"description":"No longer following.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social/following":{"get":{"tags":["Social"],"operationId":"listFollowing","summary":"Who the player follows","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."}],"responses":{"200":{"description":"Players.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PublicUser"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social/followers":{"get":{"tags":["Social"],"operationId":"listFollowers","summary":"Who follows the player","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."}],"responses":{"200":{"description":"Players.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PublicUser"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social/relationship/{username}":{"get":{"tags":["Social"],"operationId":"getRelationship","summary":"The edge between two players","security":[{"bearerAuth":[]}],"parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string"},"description":"Case-insensitive username."}],"responses":{"200":{"description":"Both directions.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/social/suggestions":{"get":{"tags":["Social"],"operationId":"getFollowSuggestions","summary":"Players worth following","description":"Strong players the viewer does not already follow. The heuristic is deliberately that simple.","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":25,"default":10},"description":"How many."}],"responses":{"200":{"description":"Suggestions.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PublicUser"}}}}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/challenges/daily":{"get":{"tags":["Daily challenge"],"operationId":"getDailyChallenge","summary":"Today's challenge","description":"The board is *derived* from the date with a seeded generator, not stored, so every client gets the same opening position and can reproduce it offline from the seed alone.","security":[],"responses":{"200":{"description":"The challenge.","content":{"application/json":{"schema":{"type":"object","properties":{"challenge":{"$ref":"#/components/schemas/DailyChallenge"},"attempted":{"type":"boolean"}}}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/challenges/daily/leaderboard":{"get":{"tags":["Daily challenge"],"operationId":"getDailyLeaderboard","summary":"The board for one day","security":[],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Page size."},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."},{"name":"date","in":"query","schema":{"type":"string","example":"2026-09-18"},"description":"Defaults to today (UTC)."}],"responses":{"200":{"description":"A page of the board.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/challenges/daily/me":{"get":{"tags":["Daily challenge"],"operationId":"getMyDailyRank","summary":"The player's rank in a day's challenge","security":[{"bearerAuth":[]}],"parameters":[{"name":"date","in":"query","schema":{"type":"string"},"description":"Defaults to today (UTC)."}],"responses":{"200":{"description":"Rank and percentile.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Rank"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/challenges/daily/submit":{"post":{"tags":["Daily challenge"],"operationId":"submitDailyScore","summary":"Submit today's attempt","description":"Only today's challenge accepts submissions. Without that rule the daily board becomes a backlog anyone can grind through.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["score"],"properties":{"date":{"type":"string"},"board":{"$ref":"#/components/schemas/Board"},"grid":{"$ref":"#/components/schemas/Board"},"score":{"type":"integer","minimum":0},"moves":{"type":"integer","minimum":0},"durationSeconds":{"type":"integer","minimum":0},"client":{"type":"string","enum":["web","ios","android","cli","unknown"]}}}}}},"responses":{"200":{"description":"Already recorded.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"201":{"description":"Recorded.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"description":"The date is invalid or is not today.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, expired, or malformed access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/challenges/daily/{date}":{"get":{"tags":["Daily challenge"],"operationId":"getChallengeForDate","summary":"Any day's challenge","security":[],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","example":"2026-09-18"},"description":"ISO date."}],"responses":{"200":{"description":"The challenge.","content":{"application/json":{"schema":{"type":"object","properties":{"challenge":{"$ref":"#/components/schemas/DailyChallenge"}}}}}},"400":{"description":"Not a valid calendar date.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/events":{"post":{"tags":["Telemetry"],"operationId":"recordEvents","summary":"Record gameplay events","description":"Coarse, opt-in, pseudonymous, and closed-vocabulary: both the event types and the metadata keys are enumerated, so this cannot become a general sink or accidentally carry a board. Rows expire after 90 days. See `docs/privacy.md`.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["events"],"properties":{"client":{"type":"string","enum":["web","ios","android","cli","unknown"]},"anonymousId":{"type":"string","maxLength":64,"description":"Ignored for signed-in callers, so the two identities are never linked."},"events":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"object","additionalProperties":true}}}}}}},"responses":{"202":{"description":"Accepted.","content":{"application/json":{"schema":{"type":"object","properties":{"accepted":{"type":"integer"}}}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/events/summary":{"get":{"tags":["Telemetry"],"operationId":"getEventSummary","summary":"Event counts by type and client","security":[],"parameters":[{"name":"days","in":"query","schema":{"type":"integer","minimum":1,"maximum":90,"default":7},"description":"Window length."}],"responses":{"200":{"description":"Counts.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"422":{"description":"The request failed validation. `error.details.issues` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}