@idosgames/mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "authentication",
3
3
  "description": "Log players into a game on the iDosGames TypeScript SDK (@idosgames/core) via client.auth (AuthenticationService): guest/device-id login, email register & login, Google/Telegram/platform-token login, password reset, auto login on relaunch, session refresh, logout, and client-side email/password validation. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and asks about logging a player in, sessions, registration, guest accounts, device-id login, Telegram login, Google login, platform-token login, forgot/reset password, auto-login, isLoggedIn, or otherwise touches client.auth, AuthenticationService, or AuthContext — even if they don't name the module explicitly.",
4
- "content": "---\nname: authentication\ndescription: >-\n Log players into a game on the iDosGames TypeScript SDK (@idosgames/core)\n via client.auth (AuthenticationService): guest/device-id login, email\n register & login, Google/Telegram/platform-token login, password reset, auto\n login on relaunch, session refresh, logout, and client-side email/password\n validation. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and asks about logging a player\n in, sessions, registration, guest accounts, device-id login, Telegram login,\n Google login, platform-token login, forgot/reset password, auto-login,\n isLoggedIn, or otherwise touches client.auth, AuthenticationService, or\n AuthContext — even if they don't name the module explicitly.\n---\n\n# Authentication (iDosGames TS SDK)\n\nThe Authentication module is how a player gets a session. It's the one module\nevery other feature depends on: nothing else on `client` works until a login\nmethod has succeeded. Unlike feature modules, most of its methods don't return\na narrow per-feature payload — they return the **entire post-login bootstrap\nstate** (`ClientState`: title config + the player's full `User` state),\nbecause a successful login is also \"give me everything the client needs to\nrender.\"\n\nThis skill is for **using** production login flows, not for porting or\nextending the service. If a login is rejected, that's the backend enforcing a\nrule (bad credentials, duplicate email, banned account) — surface the error,\ndon't try to reproduce the check client-side.\n\n## Mental model\n\n- **Guest login** (`loginWithDeviceID`) — anonymous account keyed off a\n per-device ID the platform adapter generates/stores. Zero-friction first\n launch; no credentials to lose, but also nothing to recover if the device ID\n is gone (reinstall, new device).\n- **Linked login methods** — `loginWithEmail` / `registerWithEmail`,\n `loginWithGoogle`, `loginWithTelegram`, `loginWithPlatformToken`. These tie\n the account to a real identity so the player can resume it elsewhere.\n- **`autoLogin()`** picks up where the player left off, but only for methods\n that don't need a fresh externally-issued token: it replays `loginWithEmail`\n if the last successful login was Email (saved password), `loginWithDeviceID`\n if it was Device/None, and `loginWithTelegram` if it was Telegram (its\n `initData` is re-read live from the Telegram WebApp bridge each time, not\n stored). For Google/Facebook/GooglePlay/platform-token, there's no stored\n credential to replay — `autoLogin()` returns `reason: \"client\"` instead of\n guessing, because DeviceID and email/platform logins hash to **different\n backend UserIDs** (see Gotchas), so silently falling back to a guest login\n would strand the player on an unrelated empty account. Call this on app\n start instead of hand-rolling \"which method did they use last,\" but be ready\n to handle its failure by re-running the platform's sign-in flow.\n- **Session refresh is automatic.** The HTTP transport calls\n `refreshSession()` itself when a request comes back 401 — it re-runs\n `autoLogin()` under the hood and retries once. You almost never call\n `refreshSession()` directly; it's documented here for completeness, not as a\n method you wire up in app code.\n- **Static validators** (`AuthenticationService.isValidEmail`,\n `AuthenticationService.isValidPasswordLength`) are plain synchronous\n functions, not calls to the backend — use them to validate a form before\n spending a network round-trip on a login/register call that will fail\n server-side anyway. They mirror, but do not replace, the server's own\n checks — the server always re-validates.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any other auth.* login method\n\nclient.auth.isLoggedIn; // true once a login call has succeeded\n```\n\nEvery other module's methods require this to have succeeded first — without a\nsession they return `{ ok: false, reason: \"unauthorized\" }` rather than\nthrowing. There is one `client` per player; don't share it across sessions.\nAuthentication requests carry no Bearer ticket at all — login/register/reset\nare the one family of calls that work before you have a session.\n\n## Methods\n\nAll login/register/reset methods return `Promise<OperationResult<T>>`: either\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing Telegram initData or empty platform token),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe reason code the backend returned, e.g. `\"INCORRECT_EMAIL_OR_PASSWORD\"`,\n`\"EMAIL_ALREADY_EXISTS\"` — see Gotchas for the verbatim set).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |\n| `loginWithDeviceID()` | Anonymous/guest login keyed off the device ID. | `ClientState` |\n| `loginWithTelegram()` | Login using the Telegram Mini App's init data. | `ClientState` |\n| `loginWithEmail(email, password)` | Login with email + password. | `ClientState` |\n| `registerWithEmail(email, password)` | Create an account with email + password, then log in. | `ClientState` |\n| `loginWithGoogle(googleIDToken)` | Login with a Google ID token. | `ClientState` |\n| `loginWithPlatformToken(authToken)` | Login with an iDosGames platform auth token. | `ClientState` |\n| `forgotPassword(email)` | Trigger a password-reset email (a 6-digit code). | `SuccessResponse` |\n| `resetPassword(email, resetToken, password)` | Complete a password reset using the emailed code. | `SuccessResponse` |\n| `autoLogin()` | Replay the last-used auth method (Email/Device/None/Telegram only — fails with `reason: \"client\"` for Google/Facebook/GooglePlay/platform-token). | `ClientState` |\n| `refreshSession()` | Re-authenticate and return a fresh session ticket; used internally by the transport on 401. | `string \\| null` (not an `OperationResult`) |\n| `logout()` | Clear the session and reset the local cache. Synchronous, no network call. | `void` |\n| `AuthenticationService.isValidEmail(email)` | Static, synchronous client-side format check. | `boolean` |\n| `AuthenticationService.isValidPasswordLength(pw)` | Static, synchronous length check (8–100 chars). | `boolean` |\n\nNon-Promise instance getters (read directly, no `await`):\n\n| Getter | Returns |\n| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `client.auth.context` | `AuthContext \\| null` — `{ userID, clientSessionTicket, clientSessionTicketExpiration, platformUserID?, platformAuthToken?, platformAuthTokenExpiration? }`, or `null` if never logged in. |\n| `client.auth.isLoggedIn` | `true` once `context` has a non-empty `userID` and `clientSessionTicket`. |\n| `client.auth.lastAuthType` | The persisted `AuthType` (`\"None\" \\| \"Device\" \\| \"Email\" \\| \"iDosGames\" \\| \"Facebook\" \\| \"Google\" \\| \"GooglePlay\" \\| \"Telegram\"`) from the previous successful login, read from local storage. |\n\nOn a successful login/register call, the SDK mirrors the full `ClientState`\ninto `client.data` (title config + user state) — same cache every other\nmodule reads from — and emits events. `email`/`password` are persisted to\nlocal storage **only** after a successful `loginWithEmail` or\n`registerWithEmail` call, so `autoLogin()` has something to replay. A leftover\nusername you pass in is only a fallback — Google logins use the Google\nprofile name, platform-token logins use the platform profile name, and if\nnone is available anywhere the backend assigns a generated one (see Gotchas).\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n- `auth:requestSent` → `void` — fired at the start of every login/reset call (before the network round-trip). One exception: `loginWithPlatformToken` validates its token argument first, so an empty-token `reason: \"client\"` failure doesn't fire it.\n- `auth:loggedIn` → `void` — fired after a login/register call succeeds and `ClientState` has been applied to the cache.\n- `auth:loggedOut` → `void` — fired synchronously by `logout()`.\n- `auth:unauthorized` → `void` — transport-level, fired by the HTTP layer only when a 401 could **not** be transparently recovered (the automatic refresh was unavailable, already attempted, or failed). A 401 that the refresh-and-retry rescues never emits it. Not emitted by `AuthenticationService` itself.\n- `user:clientStateReceived` → `ClientState` — fired whenever a full client state is applied (login, or any other flow that re-fetches it).\n- `user:stateUpdated` → `void` — fired whenever the cached `User` half of `ClientState` is replaced, which includes every successful login.\n- `user:anyUpdated` → `void` — coarsest \"something in the user cache changed\" signal; also fires on login.\n\n```ts\nconst off = client.on(\"auth:loggedIn\", () => {\n console.log(\"logged in as\", client.auth.context?.userID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Guest login on first launch\n\n```ts\nconst result = await client.auth.loginWithDeviceID();\nif (!result.ok) return showError(result.error ?? result.reason);\n// client.data now has the full ClientState; render the game.\n```\n\n### Try auto-login first, fall back appropriately\n\n```ts\nasync function bootstrap() {\n const result = await client.auth.autoLogin();\n if (result.ok) return; // resumed the last session\n if (result.reason === \"client\" && client.auth.lastAuthType !== \"None\") {\n // Last session used Google/Facebook/GooglePlay/a platform token — autoLogin\n // can't replay that silently. Re-run that platform's sign-in flow and call\n // the matching login method (e.g. loginWithGoogle) with a fresh token.\n return reauthenticateViaPlatformSDK(client.auth.lastAuthType);\n }\n showError(result.error ?? result.reason);\n}\n```\n\n`autoLogin()` safely self-replays Email, Device/None, and Telegram — call it\nonce on app start for those. For anything else it deliberately does **not**\nfall back to a guest login (see Gotchas) — treat its `reason: \"client\"`\nfailure as \"go get a fresh token,\" not as a generic error to toast.\n\n### Register a new account with email\n\n```ts\nif (!AuthenticationService.isValidEmail(email)) {\n return showFieldError(\"email\", \"Enter a valid email address.\");\n}\nif (!AuthenticationService.isValidPasswordLength(password)) {\n return showFieldError(\"password\", \"Password must be 8–100 characters.\");\n}\n\nconst result = await client.auth.registerWithEmail(email, password);\nif (!result.ok) return showError(result.error ?? result.reason); // e.g. \"EMAIL_ALREADY_EXISTS\"\n// logged in immediately on success; email/password saved for autoLogin().\n```\n\nEmail is matched case-insensitively — the backend trims and lowercases it\nbefore every lookup and write, so `Player@Mail.com` and `player@mail.com`\ncollide on the same account.\n\n### Upgrade a guest account to an email account\n\nThe SDK has no dedicated \"link email to this guest\" call — `registerWithEmail`\nalways mints its **own** account (a fresh UserID derived from the email, via a\ndifferent hash than the device-id account uses). To move a guest forward\nwithout losing their progress, do it through your own game-side flow (e.g. a\ncloud script that copies/merges state) rather than assuming registration\ncarries the guest's inventory along; don't build a UI that implies it will.\n\n### Forgot / reset password\n\n```ts\nconst sent = await client.auth.forgotPassword(email);\nif (!sent.ok) return showError(sent.error ?? sent.reason);\n// If an account exists for this email, a 6-digit reset code was emailed,\n// valid for 5 minutes. The response looks identical whether or not the\n// email is registered (see Gotchas) — don't tell the player \"no such account.\"\n\nconst reset = await client.auth.resetPassword(email, resetToken, newPassword);\nif (!reset.ok) return showError(reset.error ?? reset.reason);\n// password changed; call loginWithEmail with the new password next\n```\n\n### Handle a rejected login\n\n```ts\nconst result = await client.auth.loginWithEmail(email, password);\nif (!result.ok) {\n switch (result.reason) {\n case \"connection\":\n return offerRetry();\n case \"throttled\":\n return; // ignore — same call already in flight/just ran\n case \"client\":\n case \"server\":\n default:\n return showError(result.error ?? \"Login failed.\");\n }\n}\n```\n\n### Log out\n\n```ts\nclient.auth.logout(); // synchronous — clears context + resets client.data cache\n// route to login screen; no network call is made\n```\n\n## Gotchas\n\n- **`autoLogin()` deliberately refuses to guess for federated/platform\n logins.** The backend derives DeviceID UserIDs from\n `SHA256(platform + device + deviceID)` and email/platform-linked UserIDs\n from `SHA256(email + titleID)` — two different hashes with no server-side\n link between them. Falling back to `loginWithDeviceID()` for a\n Google/Facebook/GooglePlay/platform-token session would silently resolve to\n a different, empty account, not \"safely resume as a guest.\" If\n `lastAuthType` is one of those, `autoLogin()` returns\n `{ ok: false, reason: \"client\" }` instead; the app must obtain a fresh token\n from that platform's SDK and call the matching `login*` method itself.\n- **The exact `error` strings on `reason: \"server\"` are backend-defined\n codes/messages**, not prose meant for direct display — surface them through\n your own copy/localization layer rather than showing them raw. Verified\n values from the backend for the flows above: `\"INVALID_INPUT_DATA\"`\n (missing/blank required field) and `\"INCORRECT_EMAIL_OR_PASSWORD\"` (bad\n credentials **or** unknown email — the backend intentionally doesn't\n distinguish the two, so don't tell the player \"no such account\") and\n `\"EMAIL_ALREADY_EXISTS\"` (register with a taken email) are shared\n `MessageCode` enum values also used elsewhere in the backend;\n `\"RATE_LIMIT_EXCEEDED\"` / `\"OPERATION_IN_PROGRESS\"` (per-account login lock\n — see below) are the same enum family. Google/platform-token logins instead\n return ad-hoc string literals specific to that flow, e.g. `\"BANNED_GLOBAL\"`\n (platform account is banned), `\"GOOGLE_ACCOUNT_CONFLICT\"` (email already\n linked to a _different_ Google account), `\"INVALID_GOOGLE_TOKEN\"`\n (bad/expired Google ID token) — see\n [references/data-model.md](references/data-model.md) for the full list.\n Telegram login instead surfaces plain sentences like `\"Invalid Telegram\ndata\"` / an internal `\"Telegram auth_date is stale\"` condition (initData\n older than 24h or timestamped implausibly in the future) — treat any\n non-uppercase-code string as an opaque message, not something to\n pattern-match on.\n- **Per-account login is rate-limited server-side, independent of the SDK's\n own 600 ms throttle.** Repeated `loginWithEmail` attempts for the same\n account inside a ~1-second window come back `\"RATE_LIMIT_EXCEEDED\"`; a login\n already being processed for that account comes back\n `\"OPERATION_IN_PROGRESS\"`. This is a brute-force guard, not a bug — don't\n retry-loop past it.\n- **Telegram login needs the platform adapter's init data.** `loginWithTelegram()`\n calls `platform.getTelegramInitDataRaw()` first; if that returns falsy (not\n running inside Telegram, or the adapter doesn't support it), the call fails\n client-side with `reason: \"client\"` before any network request — no point\n retrying without fixing the environment. Server-side, the init data is\n rejected if its `auth_date` is missing, more than 24 hours old, or more than\n 5 minutes in the future — a Telegram Mini App that's been idle a long time\n may need a fresh WebApp launch, not just a retry. On success it's recorded\n under its own `AuthType.Telegram` (not `Device`) precisely so `autoLogin()`\n replays `loginWithTelegram()` again rather than a device-id login.\n- **`loginWithPlatformToken` requires a non-empty token.** An empty/missing\n `authToken` fails immediately with `reason: \"client\"`. On success it's\n recorded under `AuthType.iDosGames` (not a generic \"platform\" label) — that's\n what `lastAuthType` reports afterward.\n- **A username you pass in is a last resort, not a guarantee.** For Google and\n platform-token logins, the backend prefers the identity provider's own\n profile name over any client-supplied username on every login (not just the\n first) — so a locally cached display name can be silently overwritten by the\n linked account's name. If no name is available from any source at\n registration time, the backend assigns a generated one\n (`\"BraveTiger482\"`-style: adjective + noun + 2–4 digit number) rather than\n leaving it blank.\n- **`forgotPassword` never reveals whether an email is registered.** Unknown\n emails and known emails both return `{ ok: true }` with no email actually\n sent for the unknown case — this is a deliberate anti-enumeration measure,\n not a bug to work around. The emailed reset code is a 6-digit number valid\n for 5 minutes; requesting again inside a 60-second cooldown silently\n no-ops (still returns `ok: true`, no second email). `resetPassword` allows\n at most 5 wrong-code attempts before the code is invalidated server-side and\n a fresh `forgotPassword` call is required — the `error` on a wrong-but-not-final\n attempt reports the remaining count (e.g. `\"Invalid reset token. 3 attempts\nremaining\"`).\n- **Email credentials are only saved on success.** `loginWithEmail` and\n `registerWithEmail` persist email+password to local storage (scoped by\n `titleID`) only after `fetchAndApplyClientState` succeeds — a failed login\n doesn't overwrite previously saved credentials, and a fresh install has\n nothing saved until the first successful email login.\n- **`refreshSession()` has a re-entrancy guard.** If it's already running\n (e.g. two requests 401 at nearly the same time), a second call returns `null`\n immediately instead of recursing — this exists to avoid deadlocking the\n retry, so don't assume a `null` result means the session is actually dead.\n- **`logout()` clears the session, not the saved login method.** It nulls the\n auth context, wipes the cached user state and title-config bundle, and emits\n `auth:loggedOut` — but `lastAuthType` and any saved email/password stay in\n local storage, so a later `autoLogin()` resumes the same account. There is no\n public \"forget me\" API; logout is a session clear, not a credential wipe.\n- **`refreshSession()` and `logout()` don't return `OperationResult`.**\n `refreshSession()` resolves to `string | null` (the fresh session ticket, or\n `null` on failure/no saved method); `logout()` is synchronous `void`. Don't\n branch on `.ok` for these two.\n- **`GameLoop` is excluded from the login `ClientState`.** Board/stage state\n and its definitions are fetched separately per-stage by the GameLoop\n feature, not bundled into every login response.\n- **Guard against double-submit.** Each call fires a real request; a\n double-tapped \"Log in\" button can fire twice. Firing the same auth endpoint\n again inside the SDK's own throttle window (default 600 ms) comes back\n `reason: \"throttled\"` rather than duplicating the call, but disable the\n control while a call is in flight rather than relying on that — and note the\n server has its own, stricter per-account lock on top (see above).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — request/response wire\nshapes, the exact backend validation and error-code rules for each login/reset\npath, UserID derivation, and the default-username generator. Read it when you\nneed to reason about _why_ a specific `error` string came back, or when\nbuilding a password-reset or registration UI that needs to match the backend's\ntiming/attempt rules exactly.\n",
4
+ "content": "---\nname: authentication\ndescription: >-\n Log players into a game on the iDosGames TypeScript SDK (@idosgames/core)\n via client.auth (AuthenticationService): guest/device-id login, email\n register & login, Google/Telegram/platform-token login, password reset, auto\n login on relaunch, session refresh, logout, and client-side email/password\n validation. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and asks about logging a player\n in, sessions, registration, guest accounts, device-id login, Telegram login,\n Google login, platform-token login, forgot/reset password, auto-login,\n isLoggedIn, or otherwise touches client.auth, AuthenticationService, or\n AuthContext — even if they don't name the module explicitly.\n---\n\n# Authentication (iDosGames TS SDK)\n\nThe Authentication module is how a player gets a session. It's the one module\nevery other feature depends on: nothing else on `client` works until a login\nmethod has succeeded. Unlike feature modules, most of its methods don't return\na narrow per-feature payload — they return the **entire post-login bootstrap\nstate** (`ClientState`: title config + the player's full `User` state),\nbecause a successful login is also \"give me everything the client needs to\nrender.\"\n\nThis skill is for **using** production login flows, not for porting or\nextending the service. If a login is rejected, that's the backend enforcing a\nrule (bad credentials, duplicate email, banned account) — surface the error,\ndon't try to reproduce the check client-side.\n\n## Remember me\n\n`setRememberSession(remember)` decides whether a successful login is written to storage. Call it\n**before** a `login*` method — it is read when that login completes, and it applies to whichever\nprovider runs next.\n\n```ts\nclient.auth.setRememberSession(rememberCheckbox); // default: true\nconst result = await client.auth.loginWithDeviceID();\n```\n\n- **On** (the default, and how every release before core 0.1.3 behaved) — the session is persisted,\n so `autoLogin()` signs the player back in on the next launch.\n- **Off** — nothing is persisted and any previously remembered session is dropped, so the next\n launch opens on the login screen. The current session is NOT weakened: the credentials stay in\n memory for this tab, so the transport's automatic 401 re-login still works.\n\n`logout()` ends the session and clears the persisted one, so a reload does not walk back into the\naccount the player just left. (Before core 0.1.3 it left storage untouched and a reload silently\nsigned them back in.)\n\nA wallet session is never remembered either way — a fresh signature is required on every launch.\n\n## Mental model\n\n- **Guest login** (`loginWithDeviceID`) — anonymous account keyed off a\n per-device ID the platform adapter generates/stores. Zero-friction first\n launch; no credentials to lose, but also nothing to recover if the device ID\n is gone (reinstall, new device).\n- **Linked login methods** — `loginWithEmail` / `registerWithEmail`,\n `loginWithGoogle`, `loginWithTelegram`, `loginWithPlatformToken`. These tie\n the account to a real identity so the player can resume it elsewhere.\n- **`autoLogin()`** picks up where the player left off, but only for methods\n that don't need a fresh externally-issued token: it replays `loginWithEmail`\n if the last successful login was Email (saved password), `loginWithDeviceID`\n if it was Device/None, and `loginWithTelegram` if it was Telegram (its\n `initData` is re-read live from the Telegram WebApp bridge each time, not\n stored). For Google/Facebook/GooglePlay/platform-token, there's no stored\n credential to replay — `autoLogin()` returns `reason: \"client\"` instead of\n guessing, because DeviceID and email/platform logins hash to **different\n backend UserIDs** (see Gotchas), so silently falling back to a guest login\n would strand the player on an unrelated empty account. What `autoLogin()`\n has to replay is controlled by **\"remember me\"** — see below. Call this on app\n start instead of hand-rolling \"which method did they use last,\" but be ready\n to handle its failure by re-running the platform's sign-in flow.\n- **Session refresh is automatic.** The HTTP transport calls\n `refreshSession()` itself when a request comes back 401 — it re-runs\n `autoLogin()` under the hood and retries once. You almost never call\n `refreshSession()` directly; it's documented here for completeness, not as a\n method you wire up in app code.\n- **Static validators** (`AuthenticationService.isValidEmail`,\n `AuthenticationService.isValidPasswordLength`) are plain synchronous\n functions, not calls to the backend — use them to validate a form before\n spending a network round-trip on a login/register call that will fail\n server-side anyway. They mirror, but do not replace, the server's own\n checks — the server always re-validates.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any other auth.* login method\n\nclient.auth.isLoggedIn; // true once a login call has succeeded\n```\n\nEvery other module's methods require this to have succeeded first — without a\nsession they return `{ ok: false, reason: \"unauthorized\" }` rather than\nthrowing. There is one `client` per player; don't share it across sessions.\nAuthentication requests carry no Bearer ticket at all — login/register/reset\nare the one family of calls that work before you have a session.\n\n## Methods\n\nAll login/register/reset methods return `Promise<OperationResult<T>>`: either\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing Telegram initData or empty platform token),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe reason code the backend returned, e.g. `\"INCORRECT_EMAIL_OR_PASSWORD\"`,\n`\"EMAIL_ALREADY_EXISTS\"` — see Gotchas for the verbatim set).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |\n| `loginWithDeviceID()` | Anonymous/guest login keyed off the device ID. | `ClientState` |\n| `loginWithTelegram()` | Login using the Telegram Mini App's init data. | `ClientState` |\n| `loginWithEmail(email, password)` | Login with email + password. | `ClientState` |\n| `registerWithEmail(email, password)` | Create an account with email + password, then log in. | `ClientState` |\n| `loginWithGoogle(googleIDToken)` | Login with a Google ID token. | `ClientState` |\n| `loginWithPlatformToken(authToken)` | Login with an iDosGames platform auth token. | `ClientState` |\n| `forgotPassword(email)` | Trigger a password-reset email (a 6-digit code). | `SuccessResponse` |\n| `resetPassword(email, resetToken, password)` | Complete a password reset using the emailed code. | `SuccessResponse` |\n| `autoLogin()` | Replay the last-used auth method (Email/Device/None/Telegram only — fails with `reason: \"client\"` for Google/Facebook/GooglePlay/platform-token). | `ClientState` |\n| `refreshSession()` | Re-authenticate and return a fresh session ticket; used internally by the transport on 401. | `string \\| null` (not an `OperationResult`) |\n| `logout()` | Clear the session, forget the saved login method, and reset the local cache. Synchronous, no network call. | `void` |\n| `setRememberSession(remember)` | \"Remember me\" — whether the NEXT login is persisted for `autoLogin()`. Default `true`. Call before a `login*` method. | `void` |\n| `AuthenticationService.isValidEmail(email)` | Static, synchronous client-side format check. | `boolean` |\n| `AuthenticationService.isValidPasswordLength(pw)` | Static, synchronous length check (8–100 chars). | `boolean` |\n\nNon-Promise instance getters (read directly, no `await`):\n\n| Getter | Returns |\n| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `client.auth.context` | `AuthContext \\| null` — `{ userID, clientSessionTicket, clientSessionTicketExpiration, platformUserID?, platformAuthToken?, platformAuthTokenExpiration? }`, or `null` if never logged in. |\n| `client.auth.isLoggedIn` | `true` once `context` has a non-empty `userID` and `clientSessionTicket`. |\n| `client.auth.lastAuthType` | The persisted `AuthType` (`\"None\" \\| \"Device\" \\| \"Email\" \\| \"iDosGames\" \\| \"Facebook\" \\| \"Google\" \\| \"GooglePlay\" \\| \"Telegram\"`) from the previous successful login, read from local storage. |\n\nOn a successful login/register call, the SDK mirrors the full `ClientState`\ninto `client.data` (title config + user state) — same cache every other\nmodule reads from — and emits events. `email`/`password` are persisted to\nlocal storage **only** after a successful `loginWithEmail` or\n`registerWithEmail` call, so `autoLogin()` has something to replay. A leftover\nusername you pass in is only a fallback — Google logins use the Google\nprofile name, platform-token logins use the platform profile name, and if\nnone is available anywhere the backend assigns a generated one (see Gotchas).\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n- `auth:requestSent` → `void` — fired at the start of every login/reset call (before the network round-trip). One exception: `loginWithPlatformToken` validates its token argument first, so an empty-token `reason: \"client\"` failure doesn't fire it.\n- `auth:loggedIn` → `void` — fired after a login/register call succeeds and `ClientState` has been applied to the cache.\n- `auth:loggedOut` → `void` — fired synchronously by `logout()`.\n- `auth:unauthorized` → `void` — transport-level, fired by the HTTP layer only when a 401 could **not** be transparently recovered (the automatic refresh was unavailable, already attempted, or failed). A 401 that the refresh-and-retry rescues never emits it. Not emitted by `AuthenticationService` itself.\n- `user:clientStateReceived` → `ClientState` — fired whenever a full client state is applied (login, or any other flow that re-fetches it).\n- `user:stateUpdated` → `void` — fired whenever the cached `User` half of `ClientState` is replaced, which includes every successful login.\n- `user:anyUpdated` → `void` — coarsest \"something in the user cache changed\" signal; also fires on login.\n\n```ts\nconst off = client.on(\"auth:loggedIn\", () => {\n console.log(\"logged in as\", client.auth.context?.userID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Guest login on first launch\n\n```ts\nconst result = await client.auth.loginWithDeviceID();\nif (!result.ok) return showError(result.error ?? result.reason);\n// client.data now has the full ClientState; render the game.\n```\n\n### Try auto-login first, fall back appropriately\n\n```ts\nasync function bootstrap() {\n const result = await client.auth.autoLogin();\n if (result.ok) return; // resumed the last session\n if (result.reason === \"client\" && client.auth.lastAuthType !== \"None\") {\n // Last session used Google/Facebook/GooglePlay/a platform token — autoLogin\n // can't replay that silently. Re-run that platform's sign-in flow and call\n // the matching login method (e.g. loginWithGoogle) with a fresh token.\n return reauthenticateViaPlatformSDK(client.auth.lastAuthType);\n }\n showError(result.error ?? result.reason);\n}\n```\n\n`autoLogin()` safely self-replays Email, Device/None, and Telegram — call it\nonce on app start for those. For anything else it deliberately does **not**\nfall back to a guest login (see Gotchas) — treat its `reason: \"client\"`\nfailure as \"go get a fresh token,\" not as a generic error to toast.\n\n### Register a new account with email\n\n```ts\nif (!AuthenticationService.isValidEmail(email)) {\n return showFieldError(\"email\", \"Enter a valid email address.\");\n}\nif (!AuthenticationService.isValidPasswordLength(password)) {\n return showFieldError(\"password\", \"Password must be 8–100 characters.\");\n}\n\nconst result = await client.auth.registerWithEmail(email, password);\nif (!result.ok) return showError(result.error ?? result.reason); // e.g. \"EMAIL_ALREADY_EXISTS\"\n// logged in immediately on success; email/password saved for autoLogin().\n```\n\nEmail is matched case-insensitively — the backend trims and lowercases it\nbefore every lookup and write, so `Player@Mail.com` and `player@mail.com`\ncollide on the same account.\n\n### Upgrade a guest account to an email account\n\nThe SDK has no dedicated \"link email to this guest\" call — `registerWithEmail`\nalways mints its **own** account (a fresh UserID derived from the email, via a\ndifferent hash than the device-id account uses). To move a guest forward\nwithout losing their progress, do it through your own game-side flow (e.g. a\ncloud script that copies/merges state) rather than assuming registration\ncarries the guest's inventory along; don't build a UI that implies it will.\n\n### Forgot / reset password\n\n```ts\nconst sent = await client.auth.forgotPassword(email);\nif (!sent.ok) return showError(sent.error ?? sent.reason);\n// If an account exists for this email, a 6-digit reset code was emailed,\n// valid for 5 minutes. The response looks identical whether or not the\n// email is registered (see Gotchas) — don't tell the player \"no such account.\"\n\nconst reset = await client.auth.resetPassword(email, resetToken, newPassword);\nif (!reset.ok) return showError(reset.error ?? reset.reason);\n// password changed; call loginWithEmail with the new password next\n```\n\n### Handle a rejected login\n\n```ts\nconst result = await client.auth.loginWithEmail(email, password);\nif (!result.ok) {\n switch (result.reason) {\n case \"connection\":\n return offerRetry();\n case \"throttled\":\n return; // ignore — same call already in flight/just ran\n case \"client\":\n case \"server\":\n default:\n return showError(result.error ?? \"Login failed.\");\n }\n}\n```\n\n### Log out\n\n```ts\nclient.auth.logout(); // synchronous — clears context + resets client.data cache\n// route to login screen; no network call is made\n```\n\n## Gotchas\n\n- **`autoLogin()` deliberately refuses to guess for federated/platform\n logins.** The backend derives DeviceID UserIDs from\n `SHA256(platform + device + deviceID)` and email/platform-linked UserIDs\n from `SHA256(email + titleID)` — two different hashes with no server-side\n link between them. Falling back to `loginWithDeviceID()` for a\n Google/Facebook/GooglePlay/platform-token session would silently resolve to\n a different, empty account, not \"safely resume as a guest.\" If\n `lastAuthType` is one of those, `autoLogin()` returns\n `{ ok: false, reason: \"client\" }` instead; the app must obtain a fresh token\n from that platform's SDK and call the matching `login*` method itself.\n- **The exact `error` strings on `reason: \"server\"` are backend-defined\n codes/messages**, not prose meant for direct display — surface them through\n your own copy/localization layer rather than showing them raw. Verified\n values from the backend for the flows above: `\"INVALID_INPUT_DATA\"`\n (missing/blank required field) and `\"INCORRECT_EMAIL_OR_PASSWORD\"` (bad\n credentials **or** unknown email — the backend intentionally doesn't\n distinguish the two, so don't tell the player \"no such account\") and\n `\"EMAIL_ALREADY_EXISTS\"` (register with a taken email) are shared\n `MessageCode` enum values also used elsewhere in the backend;\n `\"RATE_LIMIT_EXCEEDED\"` / `\"OPERATION_IN_PROGRESS\"` (per-account login lock\n — see below) are the same enum family. Google/platform-token logins instead\n return ad-hoc string literals specific to that flow, e.g. `\"BANNED_GLOBAL\"`\n (platform account is banned), `\"GOOGLE_ACCOUNT_CONFLICT\"` (email already\n linked to a _different_ Google account), `\"INVALID_GOOGLE_TOKEN\"`\n (bad/expired Google ID token) — see\n [references/data-model.md](references/data-model.md) for the full list.\n Telegram login instead surfaces plain sentences like `\"Invalid Telegram\ndata\"` / an internal `\"Telegram auth_date is stale\"` condition (initData\n older than 24h or timestamped implausibly in the future) — treat any\n non-uppercase-code string as an opaque message, not something to\n pattern-match on.\n- **Per-account login is rate-limited server-side, independent of the SDK's\n own 600 ms throttle.** Repeated `loginWithEmail` attempts for the same\n account inside a ~1-second window come back `\"RATE_LIMIT_EXCEEDED\"`; a login\n already being processed for that account comes back\n `\"OPERATION_IN_PROGRESS\"`. This is a brute-force guard, not a bug — don't\n retry-loop past it.\n- **Telegram login needs the platform adapter's init data.** `loginWithTelegram()`\n calls `platform.getTelegramInitDataRaw()` first; if that returns falsy (not\n running inside Telegram, or the adapter doesn't support it), the call fails\n client-side with `reason: \"client\"` before any network request — no point\n retrying without fixing the environment. Server-side, the init data is\n rejected if its `auth_date` is missing, more than 24 hours old, or more than\n 5 minutes in the future — a Telegram Mini App that's been idle a long time\n may need a fresh WebApp launch, not just a retry. On success it's recorded\n under its own `AuthType.Telegram` (not `Device`) precisely so `autoLogin()`\n replays `loginWithTelegram()` again rather than a device-id login.\n- **`loginWithPlatformToken` requires a non-empty token.** An empty/missing\n `authToken` fails immediately with `reason: \"client\"`. On success it's\n recorded under `AuthType.iDosGames` (not a generic \"platform\" label) — that's\n what `lastAuthType` reports afterward.\n- **A username you pass in is a last resort, not a guarantee.** For Google and\n platform-token logins, the backend prefers the identity provider's own\n profile name over any client-supplied username on every login (not just the\n first) — so a locally cached display name can be silently overwritten by the\n linked account's name. If no name is available from any source at\n registration time, the backend assigns a generated one\n (`\"BraveTiger482\"`-style: adjective + noun + 2–4 digit number) rather than\n leaving it blank.\n- **`forgotPassword` never reveals whether an email is registered.** Unknown\n emails and known emails both return `{ ok: true }` with no email actually\n sent for the unknown case — this is a deliberate anti-enumeration measure,\n not a bug to work around. The emailed reset code is a 6-digit number valid\n for 5 minutes; requesting again inside a 60-second cooldown silently\n no-ops (still returns `ok: true`, no second email). `resetPassword` allows\n at most 5 wrong-code attempts before the code is invalidated server-side and\n a fresh `forgotPassword` call is required — the `error` on a wrong-but-not-final\n attempt reports the remaining count (e.g. `\"Invalid reset token. 3 attempts\nremaining\"`).\n- **Email credentials are only saved on success.** `loginWithEmail` and\n `registerWithEmail` persist email+password to local storage (scoped by\n `titleID`) only after `fetchAndApplyClientState` succeeds — a failed login\n doesn't overwrite previously saved credentials, and a fresh install has\n nothing saved until the first successful email login.\n- **`refreshSession()` has a re-entrancy guard.** If it's already running\n (e.g. two requests 401 at nearly the same time), a second call returns `null`\n immediately instead of recursing — this exists to avoid deadlocking the\n retry, so don't assume a `null` result means the session is actually dead.\n- **`logout()` forgets the saved login method too (changed in core 0.1.3).** It\n nulls the auth context, wipes the cached user state and title-config bundle,\n clears `lastAuthType` and any saved email/password, and emits\n `auth:loggedOut` — so a later `autoLogin()` has nothing to resume. Before\n 0.1.3 storage survived a logout and the next launch silently signed the\n player back into the account they had just left. If you want a sign-out the\n player is remembered through, don't call `logout()`.\n- **`refreshSession()` and `logout()` don't return `OperationResult`.**\n `refreshSession()` resolves to `string | null` (the fresh session ticket, or\n `null` on failure/no saved method); `logout()` is synchronous `void`. Don't\n branch on `.ok` for these two.\n- **`GameLoop` is excluded from the login `ClientState`.** Board/stage state\n and its definitions are fetched separately per-stage by the GameLoop\n feature, not bundled into every login response.\n- **Guard against double-submit.** Each call fires a real request; a\n double-tapped \"Log in\" button can fire twice. Firing the same auth endpoint\n again inside the SDK's own throttle window (default 600 ms) comes back\n `reason: \"throttled\"` rather than duplicating the call, but disable the\n control while a call is in flight rather than relying on that — and note the\n server has its own, stricter per-account lock on top (see above).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — request/response wire\nshapes, the exact backend validation and error-code rules for each login/reset\npath, UserID derivation, and the default-username generator. Read it when you\nneed to reason about _why_ a specific `error` string came back, or when\nbuilding a password-reset or registration UI that needs to match the backend's\ntiming/attempt rules exactly.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Blockchain data model — reference\n\nFull shape of the config (`BlockchainDefinitions`), player state\n(`UserBlockchainState`), transaction documents, and the withdrawal signature\npayloads. All of these are **strictly typed in the SDK** — every type below is\nexported from `@idosgames/core`, built with `zod` schemas that keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON). Decimal-valued fields\n(`Amount`, balances, USD values) are decimal strings, not JS numbers — use\n`decimal.js` (already a dependency) rather than float math.\n\n## Contents\n\n- [Config: BlockchainDefinitions](#config-blockchaindefinitions) — what `getDefinitions()` returns\n- [BlockchainNetworkDefinition](#blockchainnetworkdefinition)\n- [NFT collection bindings](#nft-collection-bindings)\n- [Account safety policy](#account-safety-policy)\n- [Withdrawal gate mechanics](#withdrawal-gate-mechanics) — every check + formula the backend runs before paying out\n- [Player state: UserBlockchainState](#player-state-userblockchainstate) — what `getUserState()` returns\n- [KYC state](#kyc-state)\n- [Compliance counters](#compliance-counters) — the daily/monthly spend windows behind the limit errors\n- [Stats containers](#stats-containers)\n- [Transaction documents](#transaction-documents)\n- [Withdrawal signature payloads](#withdrawal-signature-payloads)\n- [Domain delta: BlockchainStateDelta](#domain-delta-blockchainstatedelta)\n- [Responses](#responses)\n- [Enums](#enums)\n\n---\n\n## Config: BlockchainDefinitions\n\nReturned by `getDefinitions()` as part of `BlockchainConfigResponse`; the\n`Blockchain` section is cached via\n`client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\")`. The\nsibling `CryptoCurrencies` map (`Record<string, CryptoCurrencyDefinition>`)\nrides along in the same response — see the currency-system skill for that\nshape.\n\n```ts\ninterface BlockchainDefinitions {\n SystemState?: BlockchainSystemState; // title-wide kill switches\n Networks?: Record<string, BlockchainNetworkDefinition>; // key = NetworkID\n AccountSafety?: BlockchainAccountSafetyPolicy;\n}\n\ninterface BlockchainSystemState {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState; // per-platform (Ios/Android/Web) override\n}\n\ninterface PlatformBlockchainState {\n Ios?: boolean;\n Android?: boolean;\n Web?: boolean;\n}\n```\n\n`SystemState` is the title-wide switch; each `BlockchainNetworkDefinition` has\nits own matching flags that layer on top (both must allow an action for it to\nbe permitted — the backend enforces this, but mirror the check in UI to avoid\nshowing a dead button).\n\n---\n\n## BlockchainNetworkDefinition\n\nOne connected chain. Key in `Networks` is the `NetworkID` you pass to every\nservice method (`\"polygon\"`, `\"ethereum\"`, `\"solana\"`, etc. — title-defined\nstrings, not fixed by the SDK).\n\n```ts\ninterface BlockchainNetworkDefinition {\n NetworkID?: string;\n DisplayName?: string;\n Type?: \"EVM\" | \"Solana\"; // controls which signature payload shape you get back\n ChainID?: number; // EVM chain id; 0 for Solana (unused)\n ChainTicker?: string; // e.g. \"MATIC\", \"ETH\", \"SOL\" — used server-side to route RPC calls\n PlatformPoolAddress?: string; // EVM: pool contract address; Solana: platform Program ID\n VaultDepositAddress?: string; // Solana-only: vault address for SPL deposits, when used\n ChainConfigVersion?: number; // default 1; controls the withdrawal signature payload format\n RequiredConfirmations?: number; // on-chain confirmations before the backend accepts a deposit; default 12\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState;\n NftCollections?: BlockchainNftCollectionBinding[];\n AssetPaths?: Record<string, string>; // icons, chain logos, etc.\n}\n```\n\n`Type` determines which field is populated on withdrawal responses:\n`EvmSignature` for `\"EVM\"` networks, `SolanaSignature` for `\"Solana\"`\nnetworks. Always check `Type` (or just check which signature field is\nnon-null) rather than assuming one shape.\n\n`RequiredConfirmations` is why a `depositToken`/`depositNFT` call can fail\nright after the player submits their on-chain transaction — the backend\nwon't accept it until it has enough confirmations, returning `\"Not enough\nconfirmations (required {RequiredConfirmations}). Try again in a few\nminutes.\"` Ignored for Solana networks (finality is checked via commitment\nlevel instead). Surface \"still confirming, try again shortly\" for that\nspecific message rather than a hard failure.\n\n---\n\n## NFT collection bindings\n\nBinds one on-chain NFT contract/collection to an in-game item catalog, so the\nbackend knows which `ItemCatalogID` a deposited/withdrawn NFT maps to.\n\n```ts\ninterface BlockchainNftCollectionBinding {\n ContractAddress?: string;\n ItemCatalogID?: string; // which item catalog this contract maps to in-game\n DisplayName?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nA network can bind multiple collections (e.g. one contract for weapon NFTs,\nanother for cosmetic NFTs), each independently toggle-able.\n\n---\n\n## Account safety policy\n\n```ts\ninterface BlockchainAccountSafetyPolicy {\n MinAccountAgeDays?: number; // account must be at least this old to withdraw; default 7\n MultiAccountCheckEnabled?: boolean; // default true\n BanOnSharedWithdrawalAddress?: boolean; // default true; see below\n PendingWithdrawalTtlHours?: number; // signature validity window; default 24, min enforced 1\n}\n```\n\nRead-only/informational for the client — the backend enforces these; there's\nnothing to compute. Useful for showing a \"why is withdrawal locked\" message\n(e.g. \"Available after your account is 7 days old\"). Checked only on\nwithdrawal requests and `retryWithdrawal` — deposits are always accepted\nregardless of account age (an account can be auto-flagged from a deposit, but\nnever blocked from making one).\n\n`MultiAccountCheckEnabled` + `BanOnSharedWithdrawalAddress` together mean: if\na player requests a withdrawal to a wallet address that was already used as a\nwithdrawal _or deposit_ destination by a **different** account on this title,\nthe requesting account is **banned immediately** as part of the check (not\njust rejected) — `\"Account banned. Contact support.\"` There's no warning\nstep; a title enabling this should surface it clearly in withdrawal UI\ncopy before the player submits an address.\n\n---\n\n## Withdrawal gate mechanics\n\nThe full ordered set of server-side checks a `requestTokenWithdrawal` /\n`requestNFTWithdrawal` call goes through, with the exact backend formulas.\nThe SKILL.md's [Withdrawal gates](../SKILL.md#withdrawal-gates-what-can-reject-a-request)\nsection lists the corresponding verbatim error strings; this section is the\n\"why\" behind each one.\n\n1. **Global + per-network + per-currency + per-binding enable flags** — all\n of `BlockchainSystemState.WithdrawalsEnabled`,\n `BlockchainNetworkDefinition.WithdrawalsEnabled`,\n `CryptoCurrencyPermissions.WithdrawalsEnabled` (title-wide, all networks),\n and `CryptoNetworkBinding.WithdrawalsEnabled` (this specific\n currency+network pair) must be `true`. Any one `false` rejects the\n request — a title can pause withdrawals for one currency on one network\n (e.g. a drained hot wallet) without touching the others.\n2. **`MinWithdraw`** (`CryptoNetworkBinding.MinWithdraw`, per currency+network)\n — the requested `amount` must be `>= MinWithdraw`. Set with margin above\n `WithdrawFee` by the title so net payouts don't go negative (this SDK's\n flow doesn't apply `WithdrawFee` as a separate deduction anywhere client\n -visible — see the commission note below for what actually reduces the\n payout).\n3. **Balance check** — the player's `InventoryV2.CryptoCurrencies[currencyID]\n.Amount` (tokens) or owned item count (NFTs) must cover the requested\n amount.\n4. **Account safety** — see [above](#account-safety-policy).\n5. **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, all in\n USD-equivalent of the request, computed as `amountNative * ValueInUSD`):\n - `KycRequiredAboveUsd`: if the request's USD value exceeds this and\n `UserBlockchainState.Kyc.Status !== \"Verified\"`, rejected.\n - `DailyWithdrawUsd`: rejected if `DailyWithdrawnUsd (so far today) +\nthisRequestUsd > DailyWithdrawUsd`. The daily window is a fixed UTC\n calendar day (00:00 UTC), not a rolling 24h window.\n - `MonthlyWithdrawUsd`: same shape, UTC calendar month (00:00 UTC on the\n 1st).\n - Any of the three fields being absent/null on the currency disables that\n specific check.\n6. **Title-wide collective pool cap** — independent of the individual\n player's limits: the title has one `UsersWithdrawable` pool balance per\n (network, currency), fed by the remainder of every deposit after both the\n developer share (`CryptoCurrencyDefinition.DeveloperDepositSharePercent`)\n and the Community Marketing share\n (`CryptoCurrencyDefinition.CommunityMarketingDepositSharePercent`) are\n taken off the top (developer share wins on overflow if the two sum above\n 100%), plus any `donateToUsersPool` donations. A withdrawal request is\n rejected outright if `UsersWithdrawable < requestedAmount` for that pool —\n this is a platform economics limit, not a per-player one, and isn't\n exposed through any client-readable field; you only learn about it from\n the rejection.\n7. **Platform commission + EVM burn** — an operator-wide withdrawal\n commission percentage (0–100, not exposed in `BlockchainDefinitions`) is\n applied to the _gross_ requested amount, and (EVM only) a per-currency\n burn percentage (`CryptoCurrencyDefinition.WithdrawalBurnPercent`) is\n applied on top: `commission = amountNative * (commissionPercent / 100)`,\n `burn = amountNative * (WithdrawalBurnPercent / 100)` (0 on Solana),\n `net = amountNative - commission - burn`. The player is debited the full\n `amountNative` (gross); the signed payload authorizes paying out only\n `net` on-chain, with `burn` sent to the DEAD address by the contract\n itself. If `net <= 0` (commission + burn consume the whole request), the\n withdrawal is rejected before any signature is issued. This is why\n `TokenWithdrawalResponse.NetAmountNative` can be less than `AmountNative`\n — always display `NetAmountNative` as \"you'll receive,\" and\n `BurnAmountNative` if you want to show the burned portion separately. NFT\n withdrawals have no commission/burn step (no `NetAmountNative` /\n `BurnAmountNative` on `NFTWithdrawalResponse`).\n\nNone of steps 5–7 are visible ahead of time as a single client-readable\n\"can withdraw\" flag — the pattern is: attempt the call, branch on the error\nstring.\n\n---\n\n## Player state: UserBlockchainState\n\nReturned by `getUserState()` as `{ State, CryptoBalances }`\n(`UserBlockchainStateResponse`); `State` is cached at\n`client.data.user.state?.Blockchain`, `CryptoBalances` is folded into\n`client.data.user.state?.InventoryV2?.CryptoCurrencies` (same cache\n`client.data.user.getCryptoCurrencyAmount(id)` reads).\n\n```ts\ninterface UserBlockchainState {\n Version?: number;\n Stats?: BlockchainStats;\n LinkedWallets?: Record<string, LinkedWalletInfo>; // key = NetworkID\n PendingWithdrawals?: PendingWithdrawalRef[];\n Kyc?: UserKycState;\n FirstActivityAt?: string; // ISO datetime\n LastActivityAt?: string;\n IsFlagged?: boolean; // account-safety flag (see BlockchainAccountSafetyPolicy)\n FlagReason?: string;\n}\n\ninterface LinkedWalletInfo {\n NetworkID?: string;\n Address?: string;\n LinkedAt?: string;\n LastUsedAt?: string;\n LinkType?:\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\n IsSignatureVerified?: boolean;\n}\n\n/** Light reference only — full transaction data lives in the tx history documents. */\ninterface PendingWithdrawalRef {\n TitleTransactionID?: string;\n Type?: \"Token\" | \"Nft\";\n NetworkID?: string;\n AssetID?: string; // CurrencyID for Token withdrawals, ItemID for NFT withdrawals\n Amount?: string; // decimal string\n CreatedAt?: string;\n ExpiresAt?: string;\n}\n```\n\n`LinkedWallets` is populated automatically the first time a wallet address is\nused in a deposit/withdrawal on a network (`AutoLinkedFromTransaction`) —\nthere's no separate \"link wallet\" call in this module.\n`PendingWithdrawals` is a **light** list (id/type/asset/amount/expiry only)\nfor quickly rendering \"you have N pending withdrawals\" — cross-reference\n`TitleTransactionID` against `getTransactionHistory()` for full details\n(status, hash, fail reason).\n\n---\n\n## KYC state\n\n```ts\ninterface UserKycState {\n Status?: \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\n Tier?: \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\n VerifiedAt?: string;\n ExpiresAt?: string;\n RejectedAt?: string;\n ProviderReference?: string; // third-party KYC provider's reference id\n RejectionReason?: string;\n}\n```\n\nThis module surfaces KYC status for gating UI (e.g. \"verify your identity to\nwithdraw over $X\") — there's no `startKyc`/`submitKyc` method here; KYC\nverification itself happens through whatever provider integration the title\nuses outside this SDK, and this state just reflects the result.\n\n---\n\n## Compliance counters\n\nPer-currency AML spend windows that back the `\"Daily withdraw limit\nexceeded\"` / `\"Monthly withdraw limit exceeded\"` errors (see\n[Withdrawal gate mechanics](#withdrawal-gate-mechanics)). Not part of\n`UserBlockchainState` — these live alongside the balance, on each entry of\n`CryptoBalances` (the sibling map returned by `getUserState()`, cached into\n`InventoryV2.CryptoCurrencies`, read via `client.data.user\n.getCryptoCurrencyAmount(currencyID)` for the balance itself):\n\n```ts\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc?: string; // start of the current UTC calendar day counted\n DailyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC day\n MonthlyPeriodStartUtc?: string; // start of the current UTC calendar month counted\n MonthlyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC month\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — available balance\n Frozen: string; // decimal string — reserved by pending withdrawals\n Compliance?: UserCryptoComplianceCounters; // absent if the currency has no configured limits\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n```\n\nThere is no client method to read \"USD spent so far today\" proactively — the\ncounters are internal bookkeeping the backend checks at request time and\nrolls forward automatically once the UTC day/month boundary passes (an\nexpired window resets to the new request's amount, it does not carry over).\nTreat a `\"Daily/Monthly withdraw limit exceeded (...)\"` error message as the\nonly place this data surfaces to the client, and parse the numbers out of the\nerror string if you need to show a friendlier message.\n\n---\n\n## Stats containers\n\n```ts\ninterface BlockchainStats {\n Tokens?: TokenStatsContainer;\n Nfts?: NftStatsContainer;\n}\n\ninterface TokenStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n TotalDepositsVolumeUsd?: string;\n TotalWithdrawalsVolumeUsd?: string;\n PerCurrency?: Record<string, TokenCurrencyStats>; // key = CurrencyID\n}\n\ninterface TokenCurrencyStats {\n CurrencyID?: string;\n Deposits?: number;\n DepositsVolumeNative?: string;\n DepositsVolumeUsd?: string;\n Withdrawals?: number;\n WithdrawalsVolumeNative?: string;\n WithdrawalsVolumeUsd?: string;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n\ninterface NftStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n PerCollection?: Record<string, NftCollectionStats>; // key = ItemCatalogID (or composite id)\n}\n\ninterface NftCollectionStats {\n NetworkID?: string;\n ItemCatalogID?: string;\n Deposits?: number;\n Withdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n```\n\nLifetime counters/volumes for a player's own activity — handy for a \"your\non-chain activity\" summary screen. Purely informational; nothing to act on.\n\n---\n\n## Transaction documents\n\nReturned by `getTransactionHistory()` as `{ TokenTransactions, NFTTransactions }`\n(`TransactionHistoryResponse`). These are the full records — richer than the\nlight `PendingWithdrawalRef`. Both arrays are capped to the same `limit`\n(default 50, hard server-side ceiling 200 — values above 200 are silently\nclamped, values `<= 0` fall back to the default of 50); there's no separate\nper-type limit or pagination cursor.\n\n```ts\ninterface TokenTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string; // on-chain hash once known\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\"; // which way the asset moved\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n TokenID?: string;\n CurrencyID?: string;\n AmountUsd?: string;\n NetPayoutAmount?: string; // withdrawals only: amount after platform commission\n}\n\ninterface NFTTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string;\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\";\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n NFTID?: string;\n ItemID?: string;\n CatalogID?: string;\n SkinID?: string;\n}\n```\n\n`Direction: \"UsersCryptoWallet\"` = a withdrawal (asset moving to the player's\nwallet); `Direction: \"Game\"` = a deposit (asset moving into the game). `Status`\nis the authoritative lifecycle value for a transaction — cross-reference it\nagainst `PendingWithdrawalRef` (state) or the response you got from\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (by `TitleTransactionID` ==\n`ID`) to know exactly where a withdrawal is:\n\n| Status | Meaning |\n| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `Pending` | Requested/signed but not yet confirmed on-chain. |\n| `Completed` | Confirmed on-chain (`confirmWithdrawal` succeeded and chain verified). |\n| `Failed` | Rejected — see `FailReason`. |\n| `Expired` | Modeled in the enum but not currently assigned by this backend path. |\n| `Abandoned` | TTL (`ExpiresAt`) passed without submission — this is the status a lazily-expired pending withdrawal actually lands on, not `Expired`. Can still be closed out by a late `confirmWithdrawal` if the player submitted on-chain before the backend swept it (see the SKILL.md gotcha). |\n\nNote the divergence from what the field name suggests: **`retryWithdrawal`\nonly accepts a transaction currently in `Pending`** — it rejects\n`Failed`/`Abandoned`/`Completed` alike with `\"Transaction is not in Pending\nstate (current: {status}).\"` (see the SKILL.md's\n[Gotchas](../SKILL.md#gotchas) section for the full retry/confirm lifecycle).\nIn practice a TTL-expired withdrawal (now `Abandoned`) is **not** retryable\nthrough `retryWithdrawal` — the only path forward for one is\n`confirmWithdrawal` with a hash, if the player actually submitted the\noriginal signature before it was swept.\n\n---\n\n## Withdrawal signature payloads\n\nExactly one of these is populated on a withdrawal response\n(`TokenWithdrawalResponse`, `NFTWithdrawalResponse`) and on\n`RetryWithdrawalResponse`, depending on the network's `Type`. Hand it to a\nwallet SDK/contract call outside this package — this SDK does not sign or\nbroadcast anything itself.\n\n```ts\n// EVM networks (Type: \"EVM\")\ninterface WithdrawalSignatureResponse {\n TokenAddress?: string;\n WalletAddress?: string;\n Amount?: string; // raw on-chain units (already scaled by decimals) — pass to the contract as-is\n BurnAmount?: string; // raw on-chain units burned by the contract; part of the signed hash for\n // withdrawERC20 — pass verbatim. Null on V1 / burn-disabled currencies.\n TokenId?: string; // NFT token id, when withdrawing an NFT (ERC-1155 id or ERC-721 tokenId)\n Nonce?: string;\n ContractAddress?: string; // the RewardPool contract to call withdrawERC20/ERC1155/ERC721 on\n UserID?: string;\n TitleID?: string; // part of the signed hash — pass on-chain verbatim\n Category?: string; // operation kind (\"game_topup\", …) — part of the signed hash, pass verbatim\n Signature?: string; // signed payload to submit to the withdrawal contract\n}\n\n// Solana networks (Type: \"Solana\")\ninterface SolanaWithdrawalSignature {\n Mint?: string;\n WalletAddress?: string;\n Amount?: string;\n Nonce?: string;\n ProgramID?: string;\n SignatureHex?: string;\n SigIxIndex?: number;\n Ed25519PublicKey?: string;\n Ed25519Message?: string;\n UserID?: string;\n}\n```\n\n---\n\n## Domain delta: BlockchainStateDelta\n\nReconciliation container for state changes NOT expressible via\n`ResourceOperation` — crypto balances are patched with a direct `$inc`\nserver-side rather than going through the shared resource pipeline, and the\npending-withdrawals list is a domain structure, not a grant/consume. It rides\nalong on the mutating responses below (`StateDelta`, optional, `null` on an\nidempotent replay — the client already applied it on the first success):\n\n```ts\ninterface BlockchainStateDelta {\n // Signed per-currency balance deltas applied by this call. Apply as\n // Amount += AmountDelta, Frozen += FrozenDelta. null if no crypto balance\n // changed (e.g. an NFT flow or a donation).\n CryptoBalances?: Record<string, CryptoBalanceChange>; // key = CurrencyID\n // A pending withdrawal added by this call (Request flows). null if none.\n PendingAdded?: PendingWithdrawalRef;\n // TitleTransactionIDs of pending withdrawals removed by this call — an\n // explicit confirm and/or lazily-expired stale ones. null/empty if none.\n PendingRemovedIDs?: string[];\n}\n\ninterface CryptoBalanceChange {\n CurrencyID?: string;\n AmountDelta?: string; // signed decimal string: + deposit, − withdrawal\n FrozenDelta?: string; // signed decimal string; 0 in current flows (withdrawal debits immediately)\n UpdatedAt?: string; // server-recorded UpdatedAt on the currency instance\n}\n```\n\nSee the SKILL.md's\n[StateDelta / Inventory](../SKILL.md#reading-state-and-reacting-to-changes)\nnote for which responses carry it and the current (manual-apply) cache\nbehavior.\n\n---\n\n## Responses\n\nMethod-by-method success shapes (see the main skill's Methods table for which\ncall returns which).\n\n```ts\ninterface BlockchainConfigResponse {\n Blockchain?: BlockchainDefinitions;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition>; // see currency-system skill\n}\n\ninterface UserBlockchainStateResponse {\n State?: UserBlockchainState;\n CryptoBalances?: Record<string, UserCryptoCurrencyState>; // { Amount, Frozen, ... }, decimal strings\n}\n\ninterface DepositTokenResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // credited amount, decimal string\n AmountUsd?: string;\n StateDelta?: BlockchainStateDelta; // crypto-balance credit\n}\n\ninterface DepositNFTResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n Resources?: ResourceOperation; // see currency-system skill — already applied to cache\n Inventory?: InventoryDelta; // minted NFT's UnstackableItems instance delta; see character-system skill for the shape\n}\n\ninterface TokenWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // debited (GROSS, before platform commission + burn)\n NetAmountNative?: string; // paid out on-chain (NET = GROSS − commission − burn)\n BurnAmountNative?: string; // burned on-chain for this withdrawal (0 if disabled or Solana)\n AmountUsd?: string;\n ExpiresAt?: string;\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // crypto-balance debit, added pending withdrawal, lazy-expired ones\n}\n\ninterface NFTWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n ExpiresAt?: string;\n Resources?: ResourceOperation; // the consumed item, already applied to cache\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // added pending withdrawal, lazy-expired ones (item debit is in Resources)\n Inventory?: InventoryDelta; // withdrawn NFT's UnstackableItems instance delta (removed/reduced instances)\n}\n\ninterface TransactionHistoryResponse {\n TokenTransactions?: TokenTransactionDocument[];\n NFTTransactions?: NFTTransactionDocument[];\n}\n\ninterface RetryWithdrawalResponse {\n TitleTransactionID?: string;\n Kind?: \"Token\" | \"Nft\";\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n}\n\ninterface ConfirmWithdrawalResponse {\n TitleTransactionID?: string;\n OnChainTxHash?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n StateDelta?: BlockchainStateDelta; // pending withdrawals removed (confirmed + any lazy-expired)\n}\n\ninterface DonationResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string;\n AmountUsd?: string;\n Target?: string; // \"Developer\" or \"UsersPool\"\n}\n```\n\n---\n\n## Enums\n\n```ts\ntype BlockchainNetworkType = \"EVM\" | \"Solana\";\ntype WalletLinkType =\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\ntype BlockchainTransactionType = \"Token\" | \"Nft\";\ntype KycStatus =\n \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\ntype KycTier = \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\ntype BlockchainTransactionStatus =\n \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\ntype TransactionDirection = \"UsersCryptoWallet\" | \"Game\";\n```\n\n`WalletLinkType.SignatureVerified` and `ManuallyLinked` are modeled for\nforward compatibility but this module's methods only ever produce\n`AutoLinkedFromTransaction` today — there's no explicit \"link/verify wallet\"\ncall in `BlockchainService`. Treat the other two as reserved for a future\nsignature-based wallet-linking flow.\n"
8
+ "content": "# Blockchain data model — reference\n\nFull shape of the config (`BlockchainDefinitions`), player state\n(`UserBlockchainState`), transaction documents, and the withdrawal signature\npayloads. All of these are **strictly typed in the SDK** — every type below is\nexported from `@idosgames/core`, built with `zod` schemas that keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON). Decimal-valued fields\n(`Amount`, balances, USD values) are decimal strings, not JS numbers — use\n`decimal.js` (already a dependency) rather than float math.\n\n## Contents\n\n- [Config: BlockchainDefinitions](#config-blockchaindefinitions) — what `getDefinitions()` returns\n- [BlockchainNetworkDefinition](#blockchainnetworkdefinition)\n- [NFT collection bindings](#nft-collection-bindings)\n- [Account safety policy](#account-safety-policy)\n- [Withdrawal gate mechanics](#withdrawal-gate-mechanics) — every check + formula the backend runs before paying out\n- [Player state: UserBlockchainState](#player-state-userblockchainstate) — what `getUserState()` returns\n- [KYC state](#kyc-state)\n- [Compliance counters](#compliance-counters) — the daily/monthly spend windows behind the limit errors\n- [Stats containers](#stats-containers)\n- [Transaction documents](#transaction-documents)\n- [Withdrawal signature payloads](#withdrawal-signature-payloads)\n- [Domain delta: BlockchainStateDelta](#domain-delta-blockchainstatedelta)\n- [Responses](#responses)\n- [Enums](#enums)\n\n---\n\n## Config: BlockchainDefinitions\n\nReturned by `getDefinitions()` as part of `BlockchainConfigResponse`; the\n`Blockchain` section is cached via\n`client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\")`. The\nsibling `CryptoCurrencies` map (`Record<string, CryptoCurrencyDefinition>`)\nrides along in the same response — see the currency-system skill for that\nshape.\n\n```ts\ninterface BlockchainDefinitions {\n SystemState?: BlockchainSystemState; // title-wide kill switches\n Networks?: Record<string, BlockchainNetworkDefinition>; // key = NetworkID\n AccountSafety?: BlockchainAccountSafetyPolicy;\n}\n\ninterface BlockchainSystemState {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState; // per-platform (Ios/Android/Web) override\n}\n\ninterface PlatformBlockchainState {\n Ios?: boolean;\n Android?: boolean;\n Web?: boolean;\n}\n```\n\n`SystemState` is the title-wide switch; each `BlockchainNetworkDefinition` has\nits own matching flags that layer on top (both must allow an action for it to\nbe permitted — the backend enforces this, but mirror the check in UI to avoid\nshowing a dead button).\n\n---\n\n## BlockchainNetworkDefinition\n\nOne connected chain. Key in `Networks` is the `NetworkID` you pass to every\nservice method (`\"polygon\"`, `\"ethereum\"`, `\"solana\"`, etc. — title-defined\nstrings, not fixed by the SDK).\n\n```ts\ninterface BlockchainNetworkDefinition {\n NetworkID?: string;\n DisplayName?: string;\n Type?: \"EVM\" | \"Solana\"; // controls which signature payload shape you get back\n ChainID?: number; // EVM chain id; 0 for Solana (unused)\n ChainTicker?: string; // e.g. \"MATIC\", \"ETH\", \"SOL\" — used server-side to route RPC calls\n RewardPoolAddress?: string; // EVM: pool contract address; Solana: platform Program ID\n VaultDepositAddress?: string; // Solana-only: vault address for SPL deposits, when used\n ChainConfigVersion?: number; // default 1; controls the withdrawal signature payload format\n RequiredConfirmations?: number; // on-chain confirmations before the backend accepts a deposit; default 12\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState;\n NftCollections?: BlockchainNftCollectionBinding[];\n AssetPaths?: Record<string, string>; // icons, chain logos, etc.\n}\n```\n\n`Type` determines which field is populated on withdrawal responses:\n`EvmSignature` for `\"EVM\"` networks, `SolanaSignature` for `\"Solana\"`\nnetworks. Always check `Type` (or just check which signature field is\nnon-null) rather than assuming one shape.\n\n`RequiredConfirmations` is why a `depositToken`/`depositNFT` call can fail\nright after the player submits their on-chain transaction — the backend\nwon't accept it until it has enough confirmations, returning `\"Not enough\nconfirmations (required {RequiredConfirmations}). Try again in a few\nminutes.\"` Ignored for Solana networks (finality is checked via commitment\nlevel instead). Surface \"still confirming, try again shortly\" for that\nspecific message rather than a hard failure.\n\n---\n\n## NFT collection bindings\n\nBinds one on-chain NFT contract/collection to an in-game item catalog, so the\nbackend knows which `ItemCatalogID` a deposited/withdrawn NFT maps to.\n\n```ts\ninterface BlockchainNftCollectionBinding {\n ContractAddress?: string;\n ItemCatalogID?: string; // which item catalog this contract maps to in-game\n DisplayName?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nA network can bind multiple collections (e.g. one contract for weapon NFTs,\nanother for cosmetic NFTs), each independently toggle-able.\n\n---\n\n## Account safety policy\n\n```ts\ninterface BlockchainAccountSafetyPolicy {\n MinAccountAgeDays?: number; // account must be at least this old to withdraw; default 7\n MultiAccountCheckEnabled?: boolean; // default true\n BanOnSharedWithdrawalAddress?: boolean; // default true; see below\n PendingWithdrawalTtlHours?: number; // signature validity window; default 24, min enforced 1\n}\n```\n\nRead-only/informational for the client — the backend enforces these; there's\nnothing to compute. Useful for showing a \"why is withdrawal locked\" message\n(e.g. \"Available after your account is 7 days old\"). Checked only on\nwithdrawal requests and `retryWithdrawal` — deposits are always accepted\nregardless of account age (an account can be auto-flagged from a deposit, but\nnever blocked from making one).\n\n`MultiAccountCheckEnabled` + `BanOnSharedWithdrawalAddress` together mean: if\na player requests a withdrawal to a wallet address that was already used as a\nwithdrawal _or deposit_ destination by a **different** account on this title,\nthe requesting account is **banned immediately** as part of the check (not\njust rejected) — `\"Account banned. Contact support.\"` There's no warning\nstep; a title enabling this should surface it clearly in withdrawal UI\ncopy before the player submits an address.\n\n---\n\n## Withdrawal gate mechanics\n\nThe full ordered set of server-side checks a `requestTokenWithdrawal` /\n`requestNFTWithdrawal` call goes through, with the exact backend formulas.\nThe SKILL.md's [Withdrawal gates](../SKILL.md#withdrawal-gates-what-can-reject-a-request)\nsection lists the corresponding verbatim error strings; this section is the\n\"why\" behind each one.\n\n1. **Global + per-network + per-currency + per-binding enable flags** — all\n of `BlockchainSystemState.WithdrawalsEnabled`,\n `BlockchainNetworkDefinition.WithdrawalsEnabled`,\n `CryptoCurrencyPermissions.WithdrawalsEnabled` (title-wide, all networks),\n and `CryptoNetworkBinding.WithdrawalsEnabled` (this specific\n currency+network pair) must be `true`. Any one `false` rejects the\n request — a title can pause withdrawals for one currency on one network\n (e.g. a drained hot wallet) without touching the others.\n2. **`MinWithdraw`** (`CryptoNetworkBinding.MinWithdraw`, per currency+network)\n — the requested `amount` must be `>= MinWithdraw`. Set with margin above\n `WithdrawFee` by the title so net payouts don't go negative (this SDK's\n flow doesn't apply `WithdrawFee` as a separate deduction anywhere client\n -visible — see the commission note below for what actually reduces the\n payout).\n3. **Balance check** — the player's `InventoryV2.CryptoCurrencies[currencyID]\n.Amount` (tokens) or owned item count (NFTs) must cover the requested\n amount.\n4. **Account safety** — see [above](#account-safety-policy).\n5. **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, all in\n USD-equivalent of the request, computed as `amountNative * ValueInUSD`):\n - `KycRequiredAboveUsd`: if the request's USD value exceeds this and\n `UserBlockchainState.Kyc.Status !== \"Verified\"`, rejected.\n - `DailyWithdrawUsd`: rejected if `DailyWithdrawnUsd (so far today) +\nthisRequestUsd > DailyWithdrawUsd`. The daily window is a fixed UTC\n calendar day (00:00 UTC), not a rolling 24h window.\n - `MonthlyWithdrawUsd`: same shape, UTC calendar month (00:00 UTC on the\n 1st).\n - Any of the three fields being absent/null on the currency disables that\n specific check.\n6. **Title-wide collective pool cap** — independent of the individual\n player's limits: the title has one `UsersWithdrawable` pool balance per\n (network, currency), fed by the remainder of every deposit after both the\n developer share (`CryptoCurrencyDefinition.DeveloperDepositSharePercent`)\n and the Community Marketing share\n (`CryptoCurrencyDefinition.CommunityMarketingDepositSharePercent`) are\n taken off the top (developer share wins on overflow if the two sum above\n 100%), plus any `donateToUsersPool` donations. A withdrawal request is\n rejected outright if `UsersWithdrawable < requestedAmount` for that pool —\n this is a platform economics limit, not a per-player one, and isn't\n exposed through any client-readable field; you only learn about it from\n the rejection.\n7. **Platform commission + EVM burn** — an operator-wide withdrawal\n commission percentage (0–100, not exposed in `BlockchainDefinitions`) is\n applied to the _gross_ requested amount, and (EVM only) a per-currency\n burn percentage (`CryptoCurrencyDefinition.WithdrawalBurnPercent`) is\n applied on top: `commission = amountNative * (commissionPercent / 100)`,\n `burn = amountNative * (WithdrawalBurnPercent / 100)` (0 on Solana),\n `net = amountNative - commission - burn`. The player is debited the full\n `amountNative` (gross); the signed payload authorizes paying out only\n `net` on-chain, with `burn` sent to the DEAD address by the contract\n itself. If `net <= 0` (commission + burn consume the whole request), the\n withdrawal is rejected before any signature is issued. This is why\n `TokenWithdrawalResponse.NetAmountNative` can be less than `AmountNative`\n — always display `NetAmountNative` as \"you'll receive,\" and\n `BurnAmountNative` if you want to show the burned portion separately. NFT\n withdrawals have no commission/burn step (no `NetAmountNative` /\n `BurnAmountNative` on `NFTWithdrawalResponse`).\n\nNone of steps 5–7 are visible ahead of time as a single client-readable\n\"can withdraw\" flag — the pattern is: attempt the call, branch on the error\nstring.\n\n---\n\n## Player state: UserBlockchainState\n\nReturned by `getUserState()` as `{ State, CryptoBalances }`\n(`UserBlockchainStateResponse`); `State` is cached at\n`client.data.user.state?.Blockchain`, `CryptoBalances` is folded into\n`client.data.user.state?.InventoryV2?.CryptoCurrencies` (same cache\n`client.data.user.getCryptoCurrencyAmount(id)` reads).\n\n```ts\ninterface UserBlockchainState {\n Version?: number;\n Stats?: BlockchainStats;\n LinkedWallets?: Record<string, LinkedWalletInfo>; // key = NetworkID\n PendingWithdrawals?: PendingWithdrawalRef[];\n Kyc?: UserKycState;\n FirstActivityAt?: string; // ISO datetime\n LastActivityAt?: string;\n IsFlagged?: boolean; // account-safety flag (see BlockchainAccountSafetyPolicy)\n FlagReason?: string;\n}\n\ninterface LinkedWalletInfo {\n NetworkID?: string;\n Address?: string;\n LinkedAt?: string;\n LastUsedAt?: string;\n LinkType?:\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\n IsSignatureVerified?: boolean;\n}\n\n/** Light reference only — full transaction data lives in the tx history documents. */\ninterface PendingWithdrawalRef {\n TitleTransactionID?: string;\n Type?: \"Token\" | \"Nft\";\n NetworkID?: string;\n AssetID?: string; // CurrencyID for Token withdrawals, ItemID for NFT withdrawals\n Amount?: string; // decimal string\n CreatedAt?: string;\n ExpiresAt?: string;\n}\n```\n\n`LinkedWallets` is populated automatically the first time a wallet address is\nused in a deposit/withdrawal on a network (`AutoLinkedFromTransaction`) —\nthere's no separate \"link wallet\" call in this module.\n`PendingWithdrawals` is a **light** list (id/type/asset/amount/expiry only)\nfor quickly rendering \"you have N pending withdrawals\" — cross-reference\n`TitleTransactionID` against `getTransactionHistory()` for full details\n(status, hash, fail reason).\n\n---\n\n## KYC state\n\n```ts\ninterface UserKycState {\n Status?: \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\n Tier?: \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\n VerifiedAt?: string;\n ExpiresAt?: string;\n RejectedAt?: string;\n ProviderReference?: string; // third-party KYC provider's reference id\n RejectionReason?: string;\n}\n```\n\nThis module surfaces KYC status for gating UI (e.g. \"verify your identity to\nwithdraw over $X\") — there's no `startKyc`/`submitKyc` method here; KYC\nverification itself happens through whatever provider integration the title\nuses outside this SDK, and this state just reflects the result.\n\n---\n\n## Compliance counters\n\nPer-currency AML spend windows that back the `\"Daily withdraw limit\nexceeded\"` / `\"Monthly withdraw limit exceeded\"` errors (see\n[Withdrawal gate mechanics](#withdrawal-gate-mechanics)). Not part of\n`UserBlockchainState` — these live alongside the balance, on each entry of\n`CryptoBalances` (the sibling map returned by `getUserState()`, cached into\n`InventoryV2.CryptoCurrencies`, read via `client.data.user\n.getCryptoCurrencyAmount(currencyID)` for the balance itself):\n\n```ts\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc?: string; // start of the current UTC calendar day counted\n DailyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC day\n MonthlyPeriodStartUtc?: string; // start of the current UTC calendar month counted\n MonthlyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC month\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — available balance\n Frozen: string; // decimal string — reserved by pending withdrawals\n Compliance?: UserCryptoComplianceCounters; // absent if the currency has no configured limits\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n```\n\nThere is no client method to read \"USD spent so far today\" proactively — the\ncounters are internal bookkeeping the backend checks at request time and\nrolls forward automatically once the UTC day/month boundary passes (an\nexpired window resets to the new request's amount, it does not carry over).\nTreat a `\"Daily/Monthly withdraw limit exceeded (...)\"` error message as the\nonly place this data surfaces to the client, and parse the numbers out of the\nerror string if you need to show a friendlier message.\n\n---\n\n## Stats containers\n\n```ts\ninterface BlockchainStats {\n Tokens?: TokenStatsContainer;\n Nfts?: NftStatsContainer;\n}\n\ninterface TokenStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n TotalDepositsVolumeUsd?: string;\n TotalWithdrawalsVolumeUsd?: string;\n PerCurrency?: Record<string, TokenCurrencyStats>; // key = CurrencyID\n}\n\ninterface TokenCurrencyStats {\n CurrencyID?: string;\n Deposits?: number;\n DepositsVolumeNative?: string;\n DepositsVolumeUsd?: string;\n Withdrawals?: number;\n WithdrawalsVolumeNative?: string;\n WithdrawalsVolumeUsd?: string;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n\ninterface NftStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n PerCollection?: Record<string, NftCollectionStats>; // key = ItemCatalogID (or composite id)\n}\n\ninterface NftCollectionStats {\n NetworkID?: string;\n ItemCatalogID?: string;\n Deposits?: number;\n Withdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n```\n\nLifetime counters/volumes for a player's own activity — handy for a \"your\non-chain activity\" summary screen. Purely informational; nothing to act on.\n\n---\n\n## Transaction documents\n\nReturned by `getTransactionHistory()` as `{ TokenTransactions, NFTTransactions }`\n(`TransactionHistoryResponse`). These are the full records — richer than the\nlight `PendingWithdrawalRef`. Both arrays are capped to the same `limit`\n(default 50, hard server-side ceiling 200 — values above 200 are silently\nclamped, values `<= 0` fall back to the default of 50); there's no separate\nper-type limit or pagination cursor.\n\n```ts\ninterface TokenTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string; // on-chain hash once known\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\"; // which way the asset moved\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n TokenID?: string;\n CurrencyID?: string;\n AmountUsd?: string;\n NetPayoutAmount?: string; // withdrawals only: amount after platform commission\n}\n\ninterface NFTTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string;\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\";\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n NFTID?: string;\n ItemID?: string;\n CatalogID?: string;\n SkinID?: string;\n}\n```\n\n`Direction: \"UsersCryptoWallet\"` = a withdrawal (asset moving to the player's\nwallet); `Direction: \"Game\"` = a deposit (asset moving into the game). `Status`\nis the authoritative lifecycle value for a transaction — cross-reference it\nagainst `PendingWithdrawalRef` (state) or the response you got from\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (by `TitleTransactionID` ==\n`ID`) to know exactly where a withdrawal is:\n\n| Status | Meaning |\n| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `Pending` | Requested/signed but not yet confirmed on-chain. |\n| `Completed` | Confirmed on-chain (`confirmWithdrawal` succeeded and chain verified). |\n| `Failed` | Rejected — see `FailReason`. |\n| `Expired` | Modeled in the enum but not currently assigned by this backend path. |\n| `Abandoned` | TTL (`ExpiresAt`) passed without submission — this is the status a lazily-expired pending withdrawal actually lands on, not `Expired`. Can still be closed out by a late `confirmWithdrawal` if the player submitted on-chain before the backend swept it (see the SKILL.md gotcha). |\n\nNote the divergence from what the field name suggests: **`retryWithdrawal`\nonly accepts a transaction currently in `Pending`** — it rejects\n`Failed`/`Abandoned`/`Completed` alike with `\"Transaction is not in Pending\nstate (current: {status}).\"` (see the SKILL.md's\n[Gotchas](../SKILL.md#gotchas) section for the full retry/confirm lifecycle).\nIn practice a TTL-expired withdrawal (now `Abandoned`) is **not** retryable\nthrough `retryWithdrawal` — the only path forward for one is\n`confirmWithdrawal` with a hash, if the player actually submitted the\noriginal signature before it was swept.\n\n---\n\n## Withdrawal signature payloads\n\nExactly one of these is populated on a withdrawal response\n(`TokenWithdrawalResponse`, `NFTWithdrawalResponse`) and on\n`RetryWithdrawalResponse`, depending on the network's `Type`. Hand it to a\nwallet SDK/contract call outside this package — this SDK does not sign or\nbroadcast anything itself.\n\n```ts\n// EVM networks (Type: \"EVM\")\ninterface WithdrawalSignatureResponse {\n TokenAddress?: string;\n WalletAddress?: string;\n Amount?: string; // raw on-chain units (already scaled by decimals) — pass to the contract as-is\n BurnAmount?: string; // raw on-chain units burned by the contract; part of the signed hash for\n // withdrawERC20 — pass verbatim. Null on V1 / burn-disabled currencies.\n TokenId?: string; // NFT token id, when withdrawing an NFT (ERC-1155 id or ERC-721 tokenId)\n Nonce?: string;\n ContractAddress?: string; // the RewardPool contract to call withdrawERC20/ERC1155/ERC721 on\n UserID?: string;\n TitleID?: string; // part of the signed hash — pass on-chain verbatim\n Category?: string; // operation kind (\"game_topup\", …) — part of the signed hash, pass verbatim\n Signature?: string; // signed payload to submit to the withdrawal contract\n}\n\n// Solana networks (Type: \"Solana\")\ninterface SolanaWithdrawalSignature {\n Mint?: string;\n WalletAddress?: string;\n Amount?: string;\n Nonce?: string;\n ProgramID?: string;\n SignatureHex?: string;\n SigIxIndex?: number;\n Ed25519PublicKey?: string;\n Ed25519Message?: string;\n UserID?: string;\n}\n```\n\n---\n\n## Domain delta: BlockchainStateDelta\n\nReconciliation container for state changes NOT expressible via\n`ResourceOperation` — crypto balances are patched with a direct `$inc`\nserver-side rather than going through the shared resource pipeline, and the\npending-withdrawals list is a domain structure, not a grant/consume. It rides\nalong on the mutating responses below (`StateDelta`, optional, `null` on an\nidempotent replay — the client already applied it on the first success):\n\n```ts\ninterface BlockchainStateDelta {\n // Signed per-currency balance deltas applied by this call. Apply as\n // Amount += AmountDelta, Frozen += FrozenDelta. null if no crypto balance\n // changed (e.g. an NFT flow or a donation).\n CryptoBalances?: Record<string, CryptoBalanceChange>; // key = CurrencyID\n // A pending withdrawal added by this call (Request flows). null if none.\n PendingAdded?: PendingWithdrawalRef;\n // TitleTransactionIDs of pending withdrawals removed by this call — an\n // explicit confirm and/or lazily-expired stale ones. null/empty if none.\n PendingRemovedIDs?: string[];\n}\n\ninterface CryptoBalanceChange {\n CurrencyID?: string;\n AmountDelta?: string; // signed decimal string: + deposit, − withdrawal\n FrozenDelta?: string; // signed decimal string; 0 in current flows (withdrawal debits immediately)\n UpdatedAt?: string; // server-recorded UpdatedAt on the currency instance\n}\n```\n\nSee the SKILL.md's\n[StateDelta / Inventory](../SKILL.md#reading-state-and-reacting-to-changes)\nnote for which responses carry it and the current (manual-apply) cache\nbehavior.\n\n---\n\n## Responses\n\nMethod-by-method success shapes (see the main skill's Methods table for which\ncall returns which).\n\n```ts\ninterface BlockchainConfigResponse {\n Blockchain?: BlockchainDefinitions;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition>; // see currency-system skill\n}\n\ninterface UserBlockchainStateResponse {\n State?: UserBlockchainState;\n CryptoBalances?: Record<string, UserCryptoCurrencyState>; // { Amount, Frozen, ... }, decimal strings\n}\n\ninterface DepositTokenResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // credited amount, decimal string\n AmountUsd?: string;\n StateDelta?: BlockchainStateDelta; // crypto-balance credit\n}\n\ninterface DepositNFTResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n Resources?: ResourceOperation; // see currency-system skill — already applied to cache\n Inventory?: InventoryDelta; // minted NFT's UnstackableItems instance delta; see character-system skill for the shape\n}\n\ninterface TokenWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // debited (GROSS, before platform commission + burn)\n NetAmountNative?: string; // paid out on-chain (NET = GROSS − commission − burn)\n BurnAmountNative?: string; // burned on-chain for this withdrawal (0 if disabled or Solana)\n AmountUsd?: string;\n ExpiresAt?: string;\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // crypto-balance debit, added pending withdrawal, lazy-expired ones\n}\n\ninterface NFTWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n ExpiresAt?: string;\n Resources?: ResourceOperation; // the consumed item, already applied to cache\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // added pending withdrawal, lazy-expired ones (item debit is in Resources)\n Inventory?: InventoryDelta; // withdrawn NFT's UnstackableItems instance delta (removed/reduced instances)\n}\n\ninterface TransactionHistoryResponse {\n TokenTransactions?: TokenTransactionDocument[];\n NFTTransactions?: NFTTransactionDocument[];\n}\n\ninterface RetryWithdrawalResponse {\n TitleTransactionID?: string;\n Kind?: \"Token\" | \"Nft\";\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n}\n\ninterface ConfirmWithdrawalResponse {\n TitleTransactionID?: string;\n OnChainTxHash?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n StateDelta?: BlockchainStateDelta; // pending withdrawals removed (confirmed + any lazy-expired)\n}\n\ninterface DonationResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string;\n AmountUsd?: string;\n Target?: string; // \"Developer\" or \"UsersPool\"\n}\n```\n\n---\n\n## Enums\n\n```ts\ntype BlockchainNetworkType = \"EVM\" | \"Solana\";\ntype WalletLinkType =\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\ntype BlockchainTransactionType = \"Token\" | \"Nft\";\ntype KycStatus =\n \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\ntype KycTier = \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\ntype BlockchainTransactionStatus =\n \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\ntype TransactionDirection = \"UsersCryptoWallet\" | \"Game\";\n```\n\n`WalletLinkType.SignatureVerified` and `ManuallyLinked` are modeled for\nforward compatibility but this module's methods only ever produce\n`AutoLinkedFromTransaction` today — there's no explicit \"link/verify wallet\"\ncall in `BlockchainService`. Treat the other two as reserved for a future\nsignature-based wallet-linking flow.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-compose-modules",
3
3
  "description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the cross-module event bus, or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
4
- "content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n cross-module event bus, or host-level shared state work. Builds on idosgames-getting-started\n (scaffolding) and idosgames-module-contract (a single module).\n---\n\n# Composing modules into one game\n\nThe whole point of the architecture: a developer plugs in several modules and merges them. The host\nhandles coexistence — you just register the modules and (optionally) wire shared state.\n\n## Register several modules\n\n```ts\n// src/modules.ts\nexport const modules: Module[] = [\n currencyHudModule, // shell chrome: a host-level wallet HUD (no scene, no route)\n boardGameModule, // Three tycoon\n idleRpgModule, // Phaser idle\n];\n```\n\nEach game module registers a route → the host renders a **nav-bar** (`🎲 Board | ⚔️ Idle RPG`) and\n**mode-switches**: only the active mode's scene is mounted and ticking; the rest are suspended\n(their RAF stops). This is why two different engines (Three + Phaser) can live in one project — they\nnever render at the same time. Modules of the same engine family may later share a renderer\n(composition), but the module code doesn't change either way.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode — same `client.currency`, same\n cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` — e.g. an idle module emits\n `events.emit(\"gold:granted\", …)` and another module subscribes with `events.on(...)`.\n\n## Host-level shared chrome\n\nTo show something in EVERY mode (a wallet balance, a global menu), register a panel with\n`activeOnly: false`. A \"shell\" module (`type: \"app\"`, `engine: \"dom\"`, no scene, no route) is the\nclean way to do it:\n\n```ts\nsetup(ctx) {\n ctx.registerPanel({ id: \"wallet\", slot: \"hud\", activeOnly: false, component: WalletHud });\n}\n```\n\n`WalletHud` reads `useUserState()` from `@idosgames/react`, so it shows the same balance in Board, in\nIdle RPG, and even over a module that doesn't use the SDK at all (voxelcraft) — proof that all modes\nshare one client and cache.\n\n## Reference merge\n\n`host-starter` + `currency-hud` + `board-game` + `idle-rpg`:\n\n- nav-bar switches modes; one engine scene mounted at a time;\n- the wallet HUD is pinned across all modes;\n- currency changed in one mode is immediately visible in the others.\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
4
+ "content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n cross-module event bus, or host-level shared state work. Builds on idosgames-getting-started\n (scaffolding) and idosgames-module-contract (a single module).\n---\n\n# Composing modules into one game\n\nThe whole point of the architecture: a developer plugs in several modules and merges them. The host\nhandles coexistence — you just register the modules and (optionally) wire shared state.\n\n## Register several modules\n\n```ts\n// src/modules.ts\nexport const modules: Module[] = [\n boardGameModule, // Three tycoon\n idleRpgModule, // Phaser idle\n];\n```\n\nEach game module registers a route → the host renders a **nav-bar** (`🎲 Board | ⚔️ Idle RPG`) and\n**mode-switches**: only the active mode's scene is mounted and ticking; the rest are suspended\n(their RAF stops). This is why two different engines (Three + Phaser) can live in one project — they\nnever render at the same time. Modules of the same engine family may later share a renderer\n(composition), but the module code doesn't change either way.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode — same `client.currency`, same\n cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` — e.g. an idle module emits\n `events.emit(\"gold:granted\", …)` and another module subscribes with `events.on(...)`.\n\n## Host-level shared chrome\n\nTo show something in EVERY mode (a wallet balance, a global menu), register a panel with\n`activeOnly: false`. A \"shell\" module (`type: \"app\"`, `engine: \"dom\"`, no scene, no route) is the\nclean way to do it:\n\n```ts\nsetup(ctx) {\n ctx.registerPanel({ id: \"wallet\", slot: \"hud\", activeOnly: false, component: WalletHud });\n}\n```\n\n`WalletHud` reads `useUserState()` from `@idosgames/react`, so it shows the same balance in Board, in\nIdle RPG, and even over a module that doesn't use the SDK at all (voxelcraft) — proof that all modes\nshare one client and cache.\n\n## Reference merge\n\n`host-starter` + `board-game` + `idle-rpg`:\n\n- nav-bar switches modes; one engine scene mounted at a time;\n- a shared HUD panel (`activeOnly:false`) is pinned across all modes;\n- currency changed in one mode is immediately visible in the others.\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
5
5
  "references": []
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-getting-started",
3
- "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, currency-hud, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
4
- "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, currency-hud, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client, logs in, calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client and logs in:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\nawait client.auth.loginWithDeviceID();\nmountHost({ container: app, client, modules }); // modules from ./modules\n```\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n3. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n4. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n5. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them; a shared HUD module (currency-hud, `activeOnly:false`) stays visible across all modes.\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n",
3
+ "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
4
+ "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them; a shared HUD panel (`activeOnly:false`) stays visible across all modes.\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`, authenticated with an `X-MCP-API-Key` header (the\n publisher issues the key per Title on platform.idosgames.com); every tool call takes a `title_id`\n argument. Connect it as an HTTP MCP server — and keep the key out of committed config via env\n expansion:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\",\n \"headers\": { \"X-MCP-API-Key\": \"${IDOS_MCP_API_KEY}\" }\n }\n }\n }\n ```\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
5
5
  "references": []
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-module-contract",
3
- "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, currency-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.",
4
- "content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, currency-hud), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (cross-module bus) · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module, e.g. currency-hud). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a wallet HUD).\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
3
+ "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.",
4
+ "content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (cross-module bus) · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
5
5
  "references": []
6
6
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "idosgames-title-bootstrap",
3
+ "description": "Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with starting balances, then the game-loop board config, then verify with a real login. Use this when a newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or whenever you scaffold a project for a Title that was just created and has no config yet. All writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect it).",
4
+ "content": "---\nname: idosgames-title-bootstrap\ndescription: >-\n Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with\n starting balances, then the game-loop board config, then verify with a real login. Use this when a\n newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not\n configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or\n whenever you scaffold a project for a Title that was just created and has no config yet. All\n writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect\n it).\n---\n\n# Bootstrap an empty Title\n\nA freshly created Title has an **empty `TitlePublicConfiguration`** — the game client will log in\nfine, but every feature that reads config fails until its section exists. Configure it over the\nTitle-configuration MCP (`POST https://site.idosgames.com/api/v2/mcp`, `X-MCP-API-Key` header,\nevery tool takes `title_id`). Tools are `get_<section>` / `save_<section>` — snake_case of the\nconfig model's property names (`Currency` → `save_currency`, `GameLoop` → `save_game_loop`).\n\n**Always `get_` a section before `save_` — save replaces the whole section**, so build on what is\nthere rather than authoring blind.\n\n## Error → missing config\n\n| Server error | What's missing |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |\n| `Board not found` / `Board not enabled` | `GameLoop.Board` — the whole board definition |\n| `Stage not found` / `BoardTemplate not found for stage` / `StageTemplate not found for stage` | `StagesByLevel[\"1\"]` or the template it references by id |\n| `Bots config is not configured (Bots.RankMultiplierMin/Max ...)` | `Board.Bots` — required as soon as any tile can trigger Attack/Raid |\n| `SpecialMode '<id>' OfferExpireSeconds must be configured (> 0)` (same for `ClaimExpireSeconds`) | that mode in `Board.SpecialModesByID` |\n| Player starts with zero of everything | `Currency` entries' `InitialDeposit` |\n\n## Order of operations\n\n### 1. Currencies (`save_currency`)\n\nDefine every currency the game references **before** the game loop that spends them. For the\nboard-game module that is three roles: a roll currency (dice), a shield currency, and a soft\ncurrency (building costs / rewards). Give each an `InitialDeposit` for the starting balance.\n\n`InitialDeposit` applies when a **user is created** — an account that logged in before the deposit\nwas configured stays at 0. When verifying, log in as a **fresh guest**, don't reuse the session.\n\n### 2. Game loop (`save_game_loop`)\n\nThe `Board` object wires everything together. Minimum viable shape:\n\n- `RollCurrencyID` / `ShieldCurrencyID` / `SoftCurrencyID` — ids from step 1.\n- `BoardTemplatesByID` — at least one template with the tile ring (`Reward`, `Chance`, `Attack`,\n `Raid`, `Special`, `Shield`, `Empty`, `RandomAction`).\n- `StageTemplatesByID` — at least one economy template (`StageOperations`: `OnBuild`,\n `OnTileLanding`, `OnStageComplete`, `SpecialModesByID`, …).\n- `StagesByLevel` — `{\"1\": {...}}` referencing a `BoardTemplateID` + `StageTemplateID` that exist\n in the two maps above (dangling ids are a runtime error, not a save error).\n- `AllowedRollMultipliers`, `Dice`.\n- `Bots` — **required** if any tile can resolve to Attack or Raid: `RankMultiplierMin`/`Max` with\n `Max >= Min > 0`.\n- `RaidMode` — `Sequential` (server reveals cell by cell) or `Fast` (client reveals locally from\n the pre-dealt layout, submits once). Pick one; the client adapts.\n- Any `SpecialModesByID` mode needs `OfferExpireSeconds > 0` and `ClaimExpireSeconds > 0`.\n\n### 3. Verify against the live backend\n\n1. Fresh guest login → starting balances match the `InitialDeposit`s.\n2. `client.gameLoop.getUserBoardState()` → no `Board not enabled`.\n3. Roll until each tile type triggers once — Reward, Chance, Attack, Raid, Special — and confirm\n the granted/spent currencies match the configured economy.\n\n## Scope\n\nThis checklist covers the board-game loop because it is the config-heaviest module. Other config\nsections (store, quests, lootboxes, …) follow the same pattern — `get_<section>`, fill, `save_`,\nverify with the matching `@idosgames/core` service — and each service's own skill documents the\nshape it reads.\n",
5
+ "references": []
6
+ }
@@ -1,28 +0,0 @@
1
- {
2
- "id": "currency-hud",
3
- "meta": {
4
- "name": "Wallet HUD",
5
- "type": "app",
6
- "engine": "dom"
7
- },
8
- "dependencies": {
9
- "@idosgames/core": "0.1.1",
10
- "@idosgames/module-sdk": "0.1.0",
11
- "@idosgames/react": "0.1.0",
12
- "react": "19.2.7"
13
- },
14
- "files": [
15
- {
16
- "path": "CurrencyHud.tsx",
17
- "content": "import { type CSSProperties, type ReactNode } from \"react\";\nimport type { UserVirtualCurrencyState } from \"@idosgames/core\";\nimport { useUserState } from \"@idosgames/react\";\n\n// A host-level wallet bar: the player's virtual currencies, read from the ONE shared SDK client and\n// kept live via useUserState. Registered with activeOnly:false so it stays pinned across every mode\n// — Board, Idle RPG, and even VoxelCraft (a game that itself knows nothing about the SDK). This is\n// the visible proof of cross-module shared state: one cache, every mode reads the same balance.\n\nexport function CurrencyHud(): ReactNode {\n const state = useUserState();\n const currencies: Record<string, UserVirtualCurrencyState> =\n state?.InventoryV2?.VirtualCurrencies ?? {};\n const entries = Object.entries(currencies);\n\n return (\n <div style={bar}>\n <span style={label}>👛 Wallet</span>\n {entries.length === 0 ? (\n <span style={dim}>—</span>\n ) : (\n entries.map(([id, currency]) => (\n <span key={id} style={chip}>\n {id} {currency.Amount ?? 0}\n </span>\n ))\n )}\n </div>\n );\n}\n\nconst bar: CSSProperties = {\n position: \"absolute\",\n top: 10,\n left: \"50%\",\n transform: \"translateX(-50%)\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"6px 12px\",\n borderRadius: 999,\n background: \"rgba(20,18,40,0.72)\",\n backdropFilter: \"blur(6px)\",\n border: \"1px solid #2a2342\",\n color: \"#fff\",\n fontFamily: \"system-ui, sans-serif\",\n fontSize: 13,\n whiteSpace: \"nowrap\",\n pointerEvents: \"auto\",\n};\nconst label: CSSProperties = { fontWeight: 700, opacity: 0.85 };\nconst chip: CSSProperties = {\n background: \"#241d40\",\n color: \"#ffd479\",\n borderRadius: 999,\n padding: \"3px 10px\",\n fontWeight: 600,\n};\nconst dim: CSSProperties = { opacity: 0.5 };\n"
18
- },
19
- {
20
- "path": "index.ts",
21
- "content": "// @idosgames/mod-currency-hud — a shell module contributing a host-level wallet HUD (no scene, no\n// route). Proves a module can be pure always-on chrome shared across every mode.\n\nexport { currencyHudModule } from \"./module\";\nexport { CurrencyHud } from \"./CurrencyHud\";\n"
22
- },
23
- {
24
- "path": "module.ts",
25
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { CurrencyHud } from \"./CurrencyHud\";\n\n// A \"shell\" module: it contributes no scene and no nav route — only always-on chrome. This exercises\n// two contract branches at once: a non-game module (type \"app\", engine \"dom\") and a panel with\n// activeOnly:false that the host keeps visible in every mode.\nexport const currencyHudModule: Module = defineModule({\n id: \"shell-currency\",\n meta: {\n name: \"Wallet HUD\",\n type: \"app\",\n engine: \"dom\",\n },\n setup(ctx) {\n ctx.registerPanel({\n id: \"wallet\",\n slot: \"hud\",\n activeOnly: false,\n component: CurrencyHud,\n });\n },\n});\n"
26
- }
27
- ]
28
- }