@idosgames/mcp 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/cli.js +204 -0
- package/package.json +46 -0
- package/registry/host.json +41 -0
- package/registry/index.json +215 -0
- package/registry/modules/board-game.json +121 -0
- package/registry/modules/currency-hud.json +28 -0
- package/registry/modules/idle-rpg.json +89 -0
- package/registry/modules/voxelcraft.json +163 -0
- package/registry/skills/authentication.json +11 -0
- package/registry/skills/blockchain-system.json +11 -0
- package/registry/skills/character-system.json +11 -0
- package/registry/skills/cloud-code.json +6 -0
- package/registry/skills/collection-system.json +11 -0
- package/registry/skills/coop-event-system.json +11 -0
- package/registry/skills/craft-system.json +11 -0
- package/registry/skills/currency-system.json +11 -0
- package/registry/skills/deal-offer-system.json +11 -0
- package/registry/skills/dev-test-loop.json +6 -0
- package/registry/skills/game-loop-system.json +11 -0
- package/registry/skills/idosgames-compose-modules.json +6 -0
- package/registry/skills/idosgames-getting-started.json +6 -0
- package/registry/skills/idosgames-module-contract.json +6 -0
- package/registry/skills/item-system.json +11 -0
- package/registry/skills/leaderboard-system.json +11 -0
- package/registry/skills/lootbox-system.json +11 -0
- package/registry/skills/marketplace-system.json +11 -0
- package/registry/skills/match-system.json +11 -0
- package/registry/skills/multiplayer-realtime.json +11 -0
- package/registry/skills/premium-system.json +11 -0
- package/registry/skills/quest-system.json +11 -0
- package/registry/skills/referral-system.json +11 -0
- package/registry/skills/reward-system.json +11 -0
- package/registry/skills/season-system.json +11 -0
- package/registry/skills/social-system.json +6 -0
- package/registry/skills/store-system.json +11 -0
- package/registry/skills/timed-boost-system.json +11 -0
- package/registry/skills/timed-event-system.json +11 -0
- package/registry/skills/title-system.json +6 -0
- package/registry/skills/user-custom-data.json +11 -0
- package/registry/skills/user-profile.json +11 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "authentication",
|
|
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",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Authentication data model — reference\n\nWire shapes for every login/reset request and response, the backend\nvalidation/error-code rules behind each `reason: \"server\"` failure, how a\nUserID is derived per login method, and the default-username generator. All of\nthis is transcribed from the backend source\n(`IDosGamesSDK/API/Client/v2/Authentication/*.cs`) and the TS SDK's\n`AuthenticationService.ts` / `AuthenticationModels.ts` — nothing here is\ninferred by analogy with another module.\n\n## Contents\n\n- [Request shape](#request-shape) — what each `client.auth.*` call sends\n- [Response shapes](#response-shapes) — `PlatformLoginResponse` / `SuccessResponse`\n- [UserID derivation per login method](#userid-derivation-per-login-method)\n- [Per-method validation & error codes](#per-method-validation--error-codes)\n- [Password reset flow in detail](#password-reset-flow-in-detail)\n- [Default username generation](#default-username-generation)\n- [Rate limits & locks](#rate-limits--locks)\n\n---\n\n## Request shape\n\nEvery `client.auth.*` call builds one shared shape\n(`AuthenticationRequest`, extending the SDK-wide `BaseRequest`) and only fills\nthe fields that method needs. You never construct this yourself — it's\ninternal to `AuthenticationService` — but knowing the shape explains which\nargument maps to which backend check.\n\n```ts\ninterface AuthenticationRequest {\n // BaseRequest fields used by auth:\n DeviceID?: string;\n Device?: string; // device model string\n Platform?: string; // \"iOS\" | \"Android\" | \"WebGL\" | ... (best-effort match, see below)\n Email?: string;\n Password?: string;\n ResetToken?: string;\n BuildKey?: string;\n WebAppLink?: string;\n TelegramInitData?: string;\n Username?: string; // fallback only — see Gotchas in SKILL.md\n // AuthenticationRequest-specific fields:\n PlatformAuthToken?: string;\n GoogleIDToken?: string;\n}\n```\n\n`Platform` is resolved server-side by\n`AuthenticationV2.GetPlatform(string)`: exact enum match first, then a\ncase-insensitive substring match against known `Platform` values, else\n`Platform.Unknown`. It only affects analytics/attribution tagging and the\ndevice-combined-ID string — it does not gate login.\n\n`BuildKey` and `WebAppLink` are populated automatically by the SDK\n(`ctx.settings.buildKey`, `ctx.platform.getFullURL()`) and checked by\n`IGSService.CheckTitleID` before any auth logic runs — a mismatched/inactive\ntitle fails the whole request with `\"Incorrect TitleID or Status Inactive\"`\nbefore your login method's own logic even executes.\n\n---\n\n## Response shapes\n\n### `PlatformLoginResponse` — `data` on every login/register call\n\n```ts\ninterface PlatformLoginResponse {\n PlatformUserID?: string | null; // present only for Google / platform-token logins\n PlatformAuthToken?: string | null; // present only for Google / platform-token logins\n PlatformAuthTokenExpiration?: string | null;\n TitleUserID: string; // this title's UserID — always present\n TitleClientSessionTicket: string; // Bearer value for all subsequent requests\n TitleClientSessionTicketExpiration: string;\n}\n```\n\nDevice-ID, Email, and Telegram logins never populate the `Platform*` fields —\nthere's no `PlatformUserDocument` in those flows, only a title-scoped user.\nThe SDK folds this into `AuthContext` (`client.auth.context`) and then\nimmediately calls `GetClientStateExcept` to fetch the full `ClientState`,\nwhich is what login methods actually resolve with — `PlatformLoginResponse`\nitself never reaches your code.\n\n### `SuccessResponse` — `data` on `forgotPassword` / `resetPassword`\n\n```ts\ninterface SuccessResponse {\n IsCompleted?: boolean | null;\n ServerTime?: string | null;\n}\n```\n\nBoth fields are typically absent/null in practice for these two calls — treat\n`result.ok === true` as the entire signal; don't branch on `IsCompleted`.\n\n---\n\n## UserID derivation per login method\n\nThe backend computes `UserID` deterministically so the same real-world\nidentity always maps to the same title-scoped account — this is also exactly\nwhy `autoLogin()` refuses to cross login families (see SKILL.md Gotchas).\n\n| Login method | Hash input | Function |\n| -------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------ |\n| `loginWithDeviceID` | `platform + device + deviceID` | `IGSService.GenerateUserID` |\n| `loginWithTelegram` | `Platform.Telegram + null + telegramUserID` | `IGSService.GenerateUserID` (fixed platform arg) |\n| `loginWithEmail` / `registerWithEmail` | `email + titleID` | `IGSService.GenerateUserIDFromEmail` |\n| `loginWithGoogle` / `loginWithPlatformToken` | `email + titleID` (email comes from the linked `PlatformUserDocument`) | `IGSService.GenerateUserIDFromEmail` |\n\nBoth functions SHA-256 the input, base64-encode, strip `=+/`, truncate to 16\nchars, uppercase, and append `TitleID`. The practical consequence: **a device\nthat later registers with email, or logs in with Google using that email,\ngets a different UserID** than its guest session — they are two separate\naccounts unless you explicitly merge them yourself (see \"Upgrade a guest\naccount to an email account\" in SKILL.md).\n\nGoogle and platform-token logins additionally go through\n`EnsureTitleAccountLinkedAsync`, which is idempotent per\n`(PlatformUserID, titleID)`: the first login for that pair creates the\ntitle-scoped `UserDataDocument` and records a `TitleLink`; every subsequent\nlogin on any device reuses the same title account and syncs `Username` /\n`AvatarUrl` from the platform profile into it.\n\n---\n\n## Per-method validation & error codes\n\nAll codes below are the literal `error` string on `{ ok: false, reason:\n\"server\", error }` (they're `MessageCode` enum names serialized via\n`.ToString()`, except where noted as plain text).\n\n### `loginWithDeviceID(deviceID)`\n\n- Missing/blank device ID → **client-side**: the SDK doesn't call this, but if\n the platform adapter returns an empty ID, the backend would reply\n `\"INVALID_INPUT_DATA\"`.\n- Otherwise always succeeds — creates the user on first call, logs in on\n every subsequent call. There is no \"wrong credentials\" case for this method.\n\n### `loginWithEmail(email, password)`\n\n- Empty email or password → `\"INVALID_INPUT_DATA\"`.\n- No `UserDataDocument` found for the (normalized) email in this title →\n `\"INCORRECT_EMAIL_OR_PASSWORD\"`.\n- Email found but password hash doesn't match → `\"INCORRECT_EMAIL_OR_PASSWORD\"`\n (same code as \"unknown email\" — deliberate anti-enumeration; never tell the\n player which one it was).\n- Per-account lock contention (see [Rate limits](#rate-limits--locks)) →\n `\"RATE_LIMIT_EXCEEDED\"` or `\"OPERATION_IN_PROGRESS\"`.\n\n### `registerWithEmail(email, password)`\n\n- Empty email or password → `\"INVALID_INPUT_DATA\"`.\n- An account with this (normalized) email already exists in this title →\n `\"EMAIL_ALREADY_EXISTS\"`.\n- Otherwise creates the account and logs in immediately. A password is always\n hashed before storage (`DataBaseService.HashPassword`) — plaintext never\n touches the document.\n\n### `loginWithGoogle(googleIDToken)`\n\nAll codes in this subsection are ad-hoc string literals specific to this\nmethod, not `MessageCode` enum values — same rules as above (surface through\nyour own copy layer), just a different source list.\n\n- `GOOGLE_CLIENT_ID` not configured on the server → `\"GOOGLE_CLIENT_ID_NOT_CONFIGURED\"`.\n- Empty token → `\"INVALID_INPUT_DATA\"`.\n- Token fails Google's own signature/audience validation →\n `\"INVALID_GOOGLE_TOKEN\"` (or `\"GOOGLE_TOKEN_VALIDATE_FAILED:<message>\"` for\n other validation exceptions).\n- Google didn't return an email, or the email isn't verified →\n `\"GOOGLE_EMAIL_MISSING\"` / `\"GOOGLE_EMAIL_NOT_VERIFIED\"`.\n- The platform account (by email) is globally banned → `\"BANNED_GLOBAL\"`.\n- The email's platform account already has a **different** Google subject\n linked (someone else's Google account claimed this email first) →\n `\"GOOGLE_ACCOUNT_CONFLICT\"`.\n- Internal failures around linking/patching surface as\n `\"FAILED_TO_LINK_TITLE_ACCOUNT\"` or `\"PATCH_SESSION_TOKENS_FAILED\"` — these\n indicate a transient backend problem, not a player-fixable error; treat like\n `\"connection\"` in your UI even though the SDK reports `reason: \"server\"`.\n\n### `loginWithPlatformToken(authToken)`\n\n- Empty token → fails **client-side** before any request\n (`reason: \"client\"`, `\"AuthToken is null or empty.\"`) — the SDK's own guard,\n separate from the backend's `\"AuthToken is null\"` for the same condition if\n it ever reached the server.\n- Token doesn't resolve to any `PlatformUserDocument` → `\"INVALID_PLATFORM_SESSION\"`.\n- Token resolves but is expired → `\"AuthToken expired. Please relogin in iDos\nGames Platform.\"` (plain text, not an enum code).\n- Platform account is globally banned → `\"BANNED_GLOBAL\"`.\n\n### `loginWithTelegram()`\n\n- No init data available from the platform adapter → fails **client-side**\n (`reason: \"client\"`, `\"Telegram initData is not available.\"`) — the request\n never reaches the network.\n- Init data is present but the title has no Telegram bot token configured →\n `\"Invalid Telegram Bot configuration (invalid or empty TelegramBotToken)\"`.\n- HMAC signature over the init data doesn't match → `\"Invalid Telegram data\"`\n (wraps an `AuthenticationException: Invalid Telegram data hash` internally).\n- `auth_date` missing, or older than 24 hours, or more than 5 minutes in the\n future (clock-skew allowance) → `\"Invalid Telegram data\"` as well (the\n underlying exception message differs — `\"Telegram auth_date is missing\"` /\n `\"...is stale\"` — but it's surfaced through the same generic path).\n- Parsed payload has no `user` object → `\"User data inside TelegramInitData is null\"`.\n\nAll of the above are plain-text messages, not `MessageCode` enum names — don't\ntry to pattern-match them as codes; treat any non-enum string as an opaque,\ndisplayable-as-is message.\n\n---\n\n## Password reset flow in detail\n\n`forgotPassword(email)`:\n\n1. If `email` is empty → `\"Email is required\"`.\n2. Email is normalized (trim + lowercase) before lookup.\n3. If no account exists for that email in this title → returns `{ ok: true }`\n anyway, **no email sent** (anti-enumeration by design — do not treat this\n as a signal the account exists).\n4. If a reset was requested less than **60 seconds** ago\n (`LastPasswordResetRequestDate`) → returns `{ ok: true }` again, silently\n skips sending a second email.\n5. If a still-valid, unexpired reset code already exists, a **new** one is\n minted anyway (old code is overwritten) — a 6-digit numeric code\n (`GenerateVerificationCode`, cryptographically random, zero-padded to 6\n digits), valid for **5 minutes** from issuance, and `PasswordResetAttempts`\n is reset to 0.\n6. Email delivery failure → `{ ok: false, error: \"Failed to send reset email.\nPlease try again later\" }`.\n\n`resetPassword(email, resetToken, password)`:\n\n1. Empty `resetToken` → `\"Reset Code is required\"`. Empty `password` →\n `\"New Password is required\"`.\n2. No account for the (normalized) email → `\"USER_NOT_FOUND\"`.\n3. If the account has already used up **5 attempts**\n (`PasswordResetAttempts >= maxResetAttempts`), the stored token is cleared\n server-side and the call fails with `\"Too many attempts. Please request a\nnew reset token\"` — a fresh `forgotPassword` call is required to continue.\n4. Every call to `resetPassword` increments `PasswordResetAttempts` by 1\n _before_ checking the code — even a call with a syntactically-valid-looking\n but wrong code counts against the 5-attempt budget.\n5. No active token stored → `\"No active reset token. Please request a new\none\"`. Token expired (past the 5-minute window) → `\"Reset token has expired.\nPlease request a new one\"`.\n6. Wrong code (but attempts remain) → `\"Invalid reset token. N attempts\nremaining\"` where `N = maxResetAttempts - attemptsSoFar` — the SDK doesn't\n parse this number out for you; if you want a countdown UI, parse the\n trailing integer out of `result.error` yourself.\n7. On success: password is rehashed and stored, the reset token/attempts/\n cooldown fields are all cleared, and a \"your password was changed\"\n notification email is sent (fire-and-forget — its failure doesn't fail the\n call).\n\nPassword itself has no server-side complexity/character-class requirement\nbeyond length — `AuthenticationService.isValidPasswordLength` (8–100 chars) is\nthe same bound the backend's hashing step tolerates; there's no separate\n\"must contain a digit\" style rule anywhere in this flow.\n\n---\n\n## Default username generation\n\nWhen a login/register path has no usable name from any source (no\nclient-supplied `Username`, and — for Google — no profile name either), the\nbackend assigns one via `UsernameGenerator.Generate()`:\n\n```\n{Adjective}{Noun}{2-4 digit number}\n```\n\ne.g. `BraveTiger482`, `MysticFalcon17`. Built from fixed pools of 512\nadjectives and 512 nouns (CamelCase, no separators, no whitespace), with the\nnumber drawn via the SDK's cryptographic RNG (`SecureRandom`, inclusive range\n10–9999) — roughly 2.6 billion possible combinations. This only happens at\naccount-creation time, inside `RegisterNewUserDocAsync`; it never overwrites\nan existing player's username later. There is no client-facing way to\nrequest regeneration through the Authentication module — a later username\nchange goes through the User module's own rename call, not through auth.\n\n---\n\n## Rate limits & locks\n\nTwo independent layers sit in front of every auth call — both are backend\nenforcement, not something the SDK can (or should) pre-check:\n\n- **Per-IP flood guard** (all `AuthenticationV2` actions): at most 60 requests\n per IP before a ~999-second block kicks in, checked before the action even\n parses. A tripped guard returns HTTP 429 directly (surfaces to the SDK as\n `reason: \"connection\"` or a non-2xx failure, not a normal `OperationResult`\n body).\n- **Per-account lock** (`loginWithEmail`, and the Google email-conflict path):\n a Redis lock keyed by UserID/email with a ~1-second rate window and a\n 5-second lock duration. A second login for the _same account_ landing\n inside that window gets `\"RATE_LIMIT_EXCEEDED\"`; one that arrives while the\n first is still being processed gets `\"OPERATION_IN_PROGRESS\"`. This is a\n brute-force/duplicate-submit guard scoped to one account — it does not\n throttle different accounts logging in concurrently.\n\nNeither layer is configurable from the client side. If you see either code,\nback off and retry after a short delay rather than looping.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "blockchain-system",
|
|
3
|
+
"description": "Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.blockchain (BlockchainService): load blockchain network/config definitions, load the player's on-chain state (linked wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a wallet into the game, request a token or NFT withdrawal out to a wallet, read on-chain transaction history, retry a still-pending withdrawal's signature, confirm a withdrawal's on-chain tx hash, and donate crypto to the developer or a users' pool. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT deposits/withdrawals, token bridging, on-chain asset transfers, KYC status, or otherwise touches client.blockchain, BlockchainService, BlockchainDefinitions, UserBlockchainState, DepositTokenResponse, TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: blockchain-system\ndescription: >-\n Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.blockchain (BlockchainService): load blockchain\n network/config definitions, load the player's on-chain state (linked\n wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a\n wallet into the game, request a token or NFT withdrawal out to a wallet,\n read on-chain transaction history, retry a still-pending withdrawal's\n signature, confirm a withdrawal's on-chain tx hash, and donate crypto to\n the developer or a users' pool. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT\n deposits/withdrawals, token bridging, on-chain asset transfers, KYC status,\n or otherwise touches client.blockchain, BlockchainService,\n BlockchainDefinitions, UserBlockchainState, DepositTokenResponse,\n TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name\n the module explicitly.\n---\n\n# Blockchain system (iDosGames TS SDK)\n\nThe Blockchain module bridges in-game assets to and from real wallets on\nsupported chains (EVM and Solana networks). A player can deposit a token or\nNFT they already sent on-chain (crediting their in-game balance/inventory),\nor request a withdrawal that pays an in-game token/NFT out to their wallet\n(debiting their in-game balance/inventory and producing a signature the\nplayer submits on-chain themselves). Everything is **server-authoritative**:\nthe client reports/requests, the backend validates the transaction against\nthe chain, applies rules (network enabled, KYC, account-safety policy), and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever credit/debit balances yourself.\n\nThis skill is for **using** the production `BlockchainService`, not for\nporting or extending it, and not for signing/broadcasting transactions\nyourself — this SDK reports deposits and requests withdrawals; actually\nsending the on-chain transaction (the deposit transfer, or broadcasting a\nwithdrawal signature) happens with a wallet SDK outside this client.\n\n> **The on-chain half is the `@idosgames/wallet` companion package.** It\n> connects browser & mobile wallets (EVM via wagmi/viem/WalletConnect, Solana\n> via wallet-adapter) and runs the exact RewardPool contract calls, threading\n> them through this service's request → submit → confirm / approve → deposit →\n> report lifecycle. If the user wants to actually connect a wallet and move\n> tokens/NFTs (not just call `client.blockchain.*`), reach for that package —\n> see its README. Everything below documents the server-authoritative\n> `client.blockchain` surface that `@idosgames/wallet` builds on. The wallet\n> package's EVM surface covers both NFT standards: `submitEvmNftWithdrawal` /\n> `depositNftEvm` (+ `erc1155Abi`) for ERC-1155 collections, and\n> `submitEvmNftWithdrawal721` / `depositNftEvm721` (+ `erc721Abi`) for\n> ERC-721 unique-item collections (4-arg `safeTransferFrom`, no `id`/`amount`\n> — the token is always qty 1). `submitEvmTokenWithdrawal`'s `withdrawERC20`\n> call now also threads `sig.BurnAmount` through (see\n> [Burn on withdrawal](#gotchas) below) — the ABI/contract call order changed,\n> so an app pinned to an older `@idosgames/wallet` build will revert on-chain\n> against an updated RewardPool contract.\n\n### Operation category (`game_topup` by default)\n\nThe updated RewardPool contract tags every deposit/withdrawal with a string\n**category** (default `\"game_topup\"`; `\"community_reward\"` is the other known\nvalue, and arbitrary strings are allowed). On a **withdrawal** the server signs\nthe category into the on-chain hash and returns it on the signature payload\n(`EvmSignature.Category` / `.TitleID`); whoever submits the transaction **must\npass the same value on-chain verbatim** or the contract rejects the signature —\n`@idosgames/wallet` does this for you. Pass it as the optional last arg to\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (omit → `\"game_topup\"`). On a\n**deposit** the category is read back from the on-chain transaction, so you\ndon't pass it to `depositToken`/`depositNFT`. It also appears on transaction\ndocuments as `Category`. Import the constants from `@idosgames/core`:\n`BlockchainOperationCategory.GameTopUp` / `.CommunityReward`.\n\n## Mental model: deposits vs. withdrawals\n\n- **Deposit** = the player already sent tokens/an NFT to the platform's pool\n or vault address on-chain. The client then calls `depositToken`/`depositNFT`\n with that transaction's hash so the backend can verify it and credit the\n player in-game. One-shot: the credit happens directly on a successful call.\n- **Withdrawal** = the player wants an in-game token/NFT sent out to their\n wallet. The client calls `requestTokenWithdrawal`/`requestNFTWithdrawal`,\n which **debits in-game immediately** and returns a signed payload\n (`EvmSignature` or `SolanaSignature`) the player's wallet must submit\n on-chain to actually receive the asset. This is a **multi-step, async\n flow** — see [Recipes](#recipes) for the full lifecycle, including what to\n do when the on-chain submission fails.\n\nBoth flows are per-network: every call takes a `networkID` that must match one\nof the title's configured `Networks` (EVM or Solana), each with its own\ndeposit/withdrawal enable flags, contract/vault addresses, and (for NFTs) a\ncollection binding to an item catalog. Withdrawals additionally run through a\nlong chain of server-side gates (balances, per-network minimums, account\nsafety, KYC, daily/monthly compliance limits, a collective title-wide pool\ncap, and platform commission) — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) for the full\nlist with verbatim error strings.\n\nFor the full config/state field shapes (network definitions, NFT collection\nbindings, KYC tiers, transaction documents, signature payloads), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (network pickers, KYC gates,\ntransaction history tables).\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 auth.* method\n\nconst blockchain = client.blockchain; // the BlockchainService\n```\n\nEvery blockchain method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (missing/empty required arg — rejected before any network call),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\n600ms client-side throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the exact backend message). Withdrawals in particular can be\nrejected by a long chain of server-side gates — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) below for the\nfull list with verbatim error strings.\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `getDefinitions()` | Load the title's blockchain config: networks, NFT bindings, crypto currencies. | `BlockchainConfigResponse` |\n| `getUserState()` | Load this player's on-chain state: linked wallets, pending withdrawals, KYC, stats, crypto balances. | `UserBlockchainStateResponse` |\n| `depositToken(networkID, transactionHash)` | Report an on-chain token transfer; credits the matching crypto balance. | `DepositTokenResponse` |\n| `depositNFT(networkID, transactionHash)` | Report an on-chain NFT transfer; grants the matching in-game item. | `DepositNFTResponse` |\n| `requestTokenWithdrawal(currencyID, networkID, walletAddress, amount, category?)` | Debit a crypto balance and get a signed payload to withdraw on-chain. | `TokenWithdrawalResponse` |\n| `requestNFTWithdrawal(itemID, networkID, walletAddress, amount, category?, level?, itemInstanceID?)` | Consume an in-game item and get a signed payload to withdraw the NFT on-chain. | `NFTWithdrawalResponse` |\n| `getTransactionHistory(limit?)` | Load recent token + NFT transaction documents (default limit 50). | `TransactionHistoryResponse` |\n| `retryWithdrawal(titleTransactionID)` | Re-issue a fresh signature for a still-`Pending` withdrawal without re-debiting. | `RetryWithdrawalResponse` |\n| `confirmWithdrawal(titleTransactionID, onChainTransactionHash)` | Tell the backend the signed withdrawal was submitted on-chain, with its tx hash. | `ConfirmWithdrawalResponse` |\n| `donateToDeveloper(networkID, transactionHash)` | Report an on-chain transfer as a donation to the developer pool (no personal credit). | `DonationResponse` |\n| `donateToUsersPool(networkID, transactionHash)` | Report an on-chain transfer as a donation to the users' pool (no personal credit). | `DonationResponse` |\n\nAll string args (`networkID`, `transactionHash`, `currencyID`,\n`walletAddress`, `amount`, `itemID`, `titleTransactionID`,\n`onChainTransactionHash`) are required and checked client-side before any\nnetwork call — an empty one short-circuits with `reason: \"client\"`. `amount`\nis a decimal string for token withdrawals and an integer-as-string for NFT\nwithdrawals / not used for deposits (deposit amounts come from the verified\non-chain transaction, not from the client). `getTransactionHistory(limit)`\ndefaults to `50`, is capped at **200** server-side (values above are silently\nclamped, values `<= 0` fall back to 50), and is sent as `Amount` on the wire\n(reused request field, not an actual currency amount).\n\n`requestNFTWithdrawal`'s trailing `level`/`itemInstanceID` are both optional\nand only matter for NFT catalogs with leveled or unique (ERC-721) bindings:\n`level` selects which leveled instance/tokenId to withdraw (omit or `1` →\nbase level, prior behavior); `itemInstanceID` is **required** when the\nitem's NFT binding is ERC-721 — it tells the server exactly which\nunstackable instance to debit and tokenize (preserving its Level/RemainingUses/\nCustomData in the on-chain registry via a separate ItemBridge contract).\nBoth are ignored for stackable/ERC-1155 items.\n\nOn success, most methods **mirror the confirmed change into the cache and\nemit an event** — see the next section for exactly which cache each method\ntouches, since it's not uniform across this module.\n\n## Withdrawal gates (what can reject a request)\n\n`requestTokenWithdrawal` / `requestNFTWithdrawal` run through a long chain of\nserver-side checks, each a `reason: \"server\"` failure with a specific `error`\nstring. Surface the string; don't try to pre-validate all of these\nclient-side — the gate list can change without a client update:\n\n- **Global kill switch** — withdrawals can be turned off platform-wide\n independently of any per-network/per-currency flag: `\"Withdrawals are\ncurrently disabled.\"`\n- **Network / currency / binding disabled** — `\"Withdrawals disabled for this\nnetwork.\"`, `\"Withdrawals are disabled for currency '{id}'.\"`, `\"This\ncurrency cannot be withdrawn in this network.\"`, or (currency under\n maintenance) `\"Currency '{id}' is under maintenance. Try again later.\"`\n- **Per-network minimum** — every currency has a `MinWithdraw` for each\n network it's bound to (`CryptoNetworkBinding.MinWithdraw`, see the\n currency-system skill's data-model for the full shape): `\"Minimum withdraw\nis {MinWithdraw} {currencyID}.\"`\n- **Insufficient balance** — `\"Not enough balance: have {available}\n{currencyID}, need {amount}.\"` (tokens) or `\"Not enough items: have {owned},\nneed {amount}.\"` (NFTs).\n- **Account safety** (`BlockchainAccountSafetyPolicy`, read-only in\n `getDefinitions()`'s `AccountSafety` block) — applies to withdrawals only,\n never deposits: account younger than `MinAccountAgeDays` →\n `\"Account is too fresh. Try again later.\"`; banned account → `\"Account is\nbanned. Contact support.\"`; withdrawing to a wallet address another account\n already used, when `MultiAccountCheckEnabled` +\n `BanOnSharedWithdrawalAddress` are both on, **bans the account on the\n spot** and returns `\"Account banned. Contact support.\"`\n- **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, per currency) —\n checked in USD-equivalent of the requested amount:\n - Above `Limits.KycRequiredAboveUsd` without `Kyc.Status === \"Verified\"` →\n `\"KYC verification required for withdrawals above {threshold} USD.\"`\n - This UTC calendar day's spend would exceed `Limits.DailyWithdrawUsd`\n (window resets at 00:00 UTC, not a rolling 24h window) →\n `\"Daily withdraw limit exceeded ({spentSoFar} + {thisAmount} >\n{dailyLimit} USD).\"`\n - This UTC calendar month's spend would exceed `Limits.MonthlyWithdrawUsd`\n (window resets 00:00 UTC on the 1st) → `\"Monthly withdraw limit exceeded\n({spentSoFar} + {thisAmount} > {monthlyLimit} USD).\"`\n - Any `Limits` field can be absent/null, which disables that specific\n check for that currency. The daily/monthly counters live server-side on\n `UserCryptoCurrencyState.Compliance` (not exposed as its own client\n method) and reset at UTC day/month boundaries — there is no way to read\n \"USD spent so far today\" from the client ahead of a request; read it off\n a rejection's `error` string instead.\n- **Collective pool cap** — independent of the player's own balance, the\n title's whole player-withdrawable pool for that (network, currency) pair\n can be exhausted: `\"Title users-withdrawable limit reached: available\n{available} {currencyID}, requested {amount}.\"` This is a title-wide\n economic limit, not specific to one player — if you see it, don't retry\n immediately.\n- **Platform commission** — a platform-wide withdrawal commission percent can\n reduce the net payout; if it would consume the entire requested amount,\n the request is rejected outright: `\"Withdrawal amount is fully consumed by\nplatform commission.\"` Otherwise the withdrawal proceeds and\n `NetAmountNative` reflects the amount after commission (see\n [Gotchas](#gotchas)).\n\nNone of these are configurable or visible as a single \"can I withdraw right\nnow\" flag — the practical pattern is: build the request, call it, and render\n`error` on failure. Use `getDefinitions()`'s `AccountSafety` block and the\ncurrency's `Limits` (from `getDefinitions()`'s sibling `CryptoCurrencies` map)\nonly for soft, non-authoritative UI hints (e.g. \"KYC may be required above\n$X\").\n\n## Reading state and reacting to changes\n\n```ts\n// On-chain activity state (only present after getUserState()):\nconst bc = client.data.user.state?.Blockchain;\nbc?.LinkedWallets; // Record<networkID, LinkedWalletInfo>\nbc?.PendingWithdrawals; // PendingWithdrawalRef[] — light refs, not full tx docs\nbc?.Kyc; // UserKycState\nbc?.Stats; // BlockchainStats (deposit/withdrawal counters & volume)\n\n// Crypto balances (decimal-as-string), same cache Currency module reads:\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\n\n// Definitions (cached after getDefinitions()):\nimport type { BlockchainDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `blockchain:definitionsLoaded` → `BlockchainConfigResponse`\n- `blockchain:userStateLoaded` → `UserBlockchainStateResponse`\n- `blockchain:tokenDeposited` → `DepositTokenResponse`\n- `blockchain:nftDeposited` → `DepositNFTResponse`\n- `blockchain:tokenWithdrawalRequested` → `TokenWithdrawalResponse`\n- `blockchain:nftWithdrawalRequested` → `NFTWithdrawalResponse`\n- `blockchain:transactionHistoryLoaded` → `TransactionHistoryResponse`\n- `blockchain:withdrawalRetried` → `RetryWithdrawalResponse`\n- `blockchain:withdrawalConfirmed` → `ConfirmWithdrawalResponse`\n- `blockchain:donatedToDeveloper` → `DonationResponse`\n- `blockchain:donatedToUsersPool` → `DonationResponse`\n\n**Cache writes are not uniform across this module — read this carefully:**\n\n- `getUserState()` is the only call that writes `client.data.user.state.Blockchain`\n (`LinkedWallets`, `PendingWithdrawals`, `Kyc`, `Stats`) and fires the coarse\n `user:blockchainUpdated` (+ `user:anyUpdated`).\n- `depositToken` / `requestTokenWithdrawal` patch only the crypto **balance**\n (`InventoryV2.CryptoCurrencies`) via a decimal delta, firing\n `user:inventoryUpdated` (+ `user:anyUpdated`) — **not** `user:blockchainUpdated`.\n- `depositNFT` / `requestNFTWithdrawal` patch inventory (items and/or\n currencies) via the shared `Resources` resource-operation pipeline, firing\n `user:inventoryUpdated` (and `user:virtualCurrencyUpdated` if VC moved) —\n again **not** `user:blockchainUpdated`.\n- `getTransactionHistory`, `retryWithdrawal`, `confirmWithdrawal`,\n `donateToDeveloper`, `donateToUsersPool` only emit their own\n `blockchain:*` event — they don't touch `client.data.user.state` at all.\n\nPractical consequence: after a deposit or withdrawal request, your **balance**\nis fresh in the cache, but `client.data.user.state.Blockchain.PendingWithdrawals`\nand `.Stats` are stale until you call `getUserState()` again. Re-fetch\n`getUserState()` after a withdrawal request/confirm/retry if your UI shows the\npending-withdrawals list or stats.\n\n**`StateDelta` / `Inventory` — the response already carries what changed, if\nyou want to apply it yourself instead of re-fetching.** `DepositTokenResponse`,\n`TokenWithdrawalResponse`, `NFTWithdrawalResponse`, and\n`ConfirmWithdrawalResponse` all carry an optional `StateDelta`\n(`BlockchainStateDelta`): a signed `CryptoBalances` delta per currency\n(`{ AmountDelta, FrozenDelta, UpdatedAt }` — add, don't overwrite), a\n`PendingAdded` ref (this call's newly-added pending withdrawal, if any), and\n`PendingRemovedIDs` (pending withdrawals this call confirmed or lazily\nexpired). `DepositNFTResponse` / `NFTWithdrawalResponse` similarly carry an\n`Inventory` (`InventoryDelta`) for the NFT's `UnstackableItems` instance —\nsame shape/semantics as the character-system module's `Inventory` deltas\n(`ChangedInstances` to upsert, `RemovedInstanceIDs` to drop). This mirrors the\nself-sufficient-response pattern used elsewhere in this SDK (see the\ncharacter-system skill) so a client that wants to reconcile\n`PendingWithdrawals`/balances/instances without another round trip can do so\nstraight from the mutating call's response. **Note:** `BlockchainService`\nitself does not auto-apply `StateDelta`/`Inventory` into\n`client.data.user.state.Blockchain` today — only the crypto **balance**\n(via the existing `AmountNative`-based patch) and item `Resources` are\napplied automatically. If you need `PendingWithdrawals` reconciled without a\nfull `getUserState()` refetch, read `result.data.StateDelta` yourself. Both\nare `null`/absent on an idempotent replay (nothing new to apply).\n\n```ts\nconst off = client.on(\"blockchain:tokenWithdrawalRequested\", (r) => {\n console.log(`Withdrawal ${r.TitleTransactionID} expires at ${r.ExpiresAt}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state on a wallet/blockchain screen\n\n```ts\nawait client.blockchain.getDefinitions();\nawait client.blockchain.getUserState();\n\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\nconst bc = client.data.user.state?.Blockchain;\n\nfor (const [networkID, net] of Object.entries(defs?.Networks ?? {})) {\n if (!net.DepositsEnabled && !net.WithdrawalsEnabled) continue;\n // render a network card; net.NftCollections binds contracts to item catalogs\n}\nbc?.Kyc?.Status; // gate withdrawal UI on KYC if the title requires it\n```\n\n### Deposit a token (player already sent it on-chain)\n\n```ts\nconst res = await client.blockchain.depositToken(\"polygon\", \"0xabc123...\");\nif (!res.ok) return showError(res.error); // e.g. \"Transaction not found on chain.\",\n// \"Not enough confirmations (required 12). Try again in a few minutes.\",\n// \"Transaction hash already used.\"\n\nres.data.CurrencyID; // e.g. \"usdt\"\nres.data.AmountNative; // decimal string credited\n// Balance is already updated in the cache:\nclient.data.user.getCryptoCurrencyAmount(res.data.CurrencyID!);\n```\n\n### Full withdrawal lifecycle: request -> submit on-chain -> confirm, with a retry-after-failure path\n\n```ts\n// 1. Request the withdrawal — debits in-game immediately, returns a signature payload.\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"25.00\",\n);\nif (!req.ok) return showError(req.error); // e.g. \"Not enough balance: have 10 usdt, need 25.00.\",\n// \"KYC verification required for withdrawals above 1000 USD.\",\n// \"Title users-withdrawable limit reached: available 5 usdt, requested 25.00.\"\n// — see \"Withdrawal gates\" above for the full list.\n\nconst { TitleTransactionID, EvmSignature, ExpiresAt } = req.data;\n// Balance is already debited (gross amount) in the cache.\n\n// 2. Hand EvmSignature (or SolanaSignature on a Solana network) to the\n// player's wallet SDK to submit the on-chain transaction yourself —\n// this SDK does not sign/broadcast. That step can fail (rejected in\n// wallet, gas issue).\n\n// 2a. If on-chain submission failed WHILE the transaction is still Pending\n// (before ExpiresAt), retry — this re-issues a fresh signature WITHOUT\n// debiting again:\nconst retry = await client.blockchain.retryWithdrawal(TitleTransactionID!);\nif (!retry.ok) return showError(retry.error); // e.g. \"Transaction is not in Pending state (current: Abandoned).\"\nconst freshSignature = retry.data.EvmSignature ?? retry.data.SolanaSignature;\n// Submit freshSignature on-chain instead, then continue to step 3.\n//\n// IMPORTANT: retryWithdrawal only works while the transaction is Pending. If\n// ExpiresAt already passed, the backend has lazily moved it to Abandoned and\n// retryWithdrawal will reject it — there is no \"re-request\" for an Abandoned\n// withdrawal (the asset was already debited and is not refunded). The only\n// way to still complete it is confirmWithdrawal with a hash, if the player\n// actually managed to submit the original signature before it was swept —\n// see the Gotchas section.\n\n// 3. Once the wallet actually broadcasts the transaction, tell the backend\n// the resulting on-chain hash so it can verify and close out the withdrawal:\nconst confirm = await client.blockchain.confirmWithdrawal(\n TitleTransactionID!,\n \"0xOnChainTxHash...\",\n);\nif (!confirm.ok) return showError(confirm.error);\nconfirm.data.Status; // e.g. \"Completed\" once the chain confirms it\n\n// 4. Refresh state — request/retry/confirm don't touch Blockchain cache themselves.\nawait client.blockchain.getUserState();\nclient.data.user.state?.Blockchain?.PendingWithdrawals; // should no longer list it once Completed\n```\n\n### KYC-gated withdrawal\n\nThe client never decides whether KYC is required — the backend compares the\nwithdrawal's USD-equivalent against the currency's configured threshold at\nrequest time. Use `Kyc.Status` only to pre-empt an obvious rejection in the\nUI; still branch on the real error:\n\n```ts\nawait client.blockchain.getUserState();\nconst kyc = client.data.user.state?.Blockchain?.Kyc;\n\nif (kyc?.Status !== \"Verified\") {\n // Optional UX nicety: warn before the call for large amounts. This SDK has\n // no startKyc/submitKyc method — verification happens through whatever KYC\n // provider integration the title uses outside this SDK; Kyc here only\n // reflects the result.\n}\n\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"5000.00\",\n);\nif (!req.ok) {\n if (req.error.startsWith(\"KYC verification required\")) {\n // Route the player to the title's KYC verification flow.\n }\n return showError(req.error);\n}\n```\n\n### Deposit / withdraw an NFT\n\n```ts\n// Deposit: player already transferred the NFT to the vault address on-chain.\nconst dep = await client.blockchain.depositNFT(\"ethereum\", \"0xNftDepositTx...\");\nif (!dep.ok) return showError(dep.error);\ndep.data.ItemID; // the in-game item granted\ndep.data.Resources; // already applied to inventory in the cache\n\n// Withdraw: consumes the in-game item, returns a signature to submit on-chain.\nconst wd = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers-inst-1\", // ItemID (per NFTWithdrawalResponse/BlockchainRequest shape)\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n);\nif (!wd.ok) return showError(wd.error);\nwd.data.TitleTransactionID; // use with retryWithdrawal / confirmWithdrawal exactly as tokens above\n\n// ERC-721 unique NFT: pass the specific instance to tokenize. level/itemInstanceID\n// are the trailing optional args — see the Methods table above.\nconst wd721 = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers\",\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n undefined, // category\n 1, // level\n \"sword-of-embers-inst-1\", // ItemInstanceID — required for ERC-721 bindings\n);\n```\n\n### Edge case: not logged in / missing args\n\n```ts\nconst res = await client.blockchain.depositToken(\"\", \"0xabc\");\n// res.ok === false, res.reason === \"client\" — \"NetworkID is required.\" — no network call.\n\nconst res2 = await client.blockchain.getUserState();\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res2.ok === false, res2.reason === \"unauthorized\"\n```\n\n### Donate crypto (no personal credit)\n\n```ts\nconst res = await client.blockchain.donateToDeveloper(\n \"polygon\",\n \"0xdonateTx...\",\n);\nif (!res.ok) return showError(res.error);\nres.data.Target; // \"Developer\" — confirms which pool bucket it landed in\n\n// donateToUsersPool is identical in shape, credits the users' pool bucket instead:\nawait client.blockchain.donateToUsersPool(\"polygon\", \"0xdonateTx2...\");\n```\n\n## Gotchas\n\n- **Withdrawal request debits immediately; the on-chain leg is separate and\n can fail.** `requestTokenWithdrawal`/`requestNFTWithdrawal` already took the\n asset from the player before any on-chain transaction exists. If the\n player's wallet fails to submit (rejected, gas issue) **while the\n transaction is still `Pending`**, don't ask them to request again — that\n would debit twice. Use `retryWithdrawal` with the same\n `TitleTransactionID` to get a fresh signature without a new charge. This\n only works before `ExpiresAt` — see the next two points for what happens\n after.\n- **`retryWithdrawal` vs `confirmWithdrawal` are opposite ends of the same\n flow.** Retry re-issues the _signed payload_ before submission (nothing has\n reached the chain yet); confirm reports the _resulting tx hash_ after\n submission (the chain now has it). Calling confirm with a hash from a\n transaction that never actually landed on-chain will simply fail\n server-side verification — don't fabricate a hash to \"force\" completion.\n- **`ExpiresAt` is real, expiry does not refund the player, and — contrary to\n what the name suggests — an expired withdrawal is NOT retryable.** A\n withdrawal signature is time-boxed (`TokenWithdrawalResponse.ExpiresAt` /\n `NFTWithdrawalResponse.ExpiresAt`, driven by\n `BlockchainAccountSafetyPolicy.PendingWithdrawalTtlHours`). Once it passes\n without a submission, the backend lazily transitions the transaction to\n **`Abandoned`** (not `Expired` — that enum value exists but this backend\n path never assigns it) and drops it off `PendingWithdrawals` — but the\n already-debited asset is **not** credited back; this is intentional, not a\n bug. Critically, `retryWithdrawal` requires the transaction to still be\n `Pending` — calling it on an `Abandoned` one fails with `\"Transaction is\nnot in Pending state (current: Abandoned).\"` There is no \"re-request\"\n operation for an abandoned withdrawal.\n- **A withdrawal can still be confirmed after it's `Abandoned`.** If the\n player submits late — after `ExpiresAt` passed and the backend already\n swept it to `Abandoned` — `confirmWithdrawal` still accepts it as long as\n the on-chain transaction verifies (the signature itself doesn't expire\n on-chain, only the title's own bookkeeping window does). Don't treat an\n `Abandoned` transaction as unrecoverable if the player insists they\n submitted it; calling `confirmWithdrawal` with the resulting hash is still\n the right move, and is in fact the _only_ way to close out an\n already-expired-but-actually-submitted withdrawal.\n- **`retryWithdrawal` only works on a `Pending` transaction the caller owns.**\n It fails with `\"Transaction not found.\"` for an unknown or someone else's\n `TitleTransactionID`, `\"Transaction is not in Pending state (current:\n{status}).\"` if it already completed/failed/was abandoned, or\n `\"Signature data not found for this transaction.\"` if there's nothing to\n reissue. A banned account additionally gets `\"Account is banned. Contact\nsupport.\"` on retry (deposits stay allowed for banned accounts; retrying a\n withdrawal does not).\n- **Gross vs. net amounts on token withdrawals.** `AmountNative` is what was\n debited from the player (gross); `NetAmountNative` is what actually gets\n paid out on-chain after a platform commission percentage **and** an\n optional on-chain burn are deducted (`NetAmountNative = AmountNative −\ncommission − BurnAmountNative`). Show the player the net figure they'll\n receive, not the gross debit, to avoid support tickets about a \"missing\"\n amount. NFT withdrawals have no such split — there's no `NetAmountNative`\n on `NFTWithdrawalResponse`.\n- **Burn on withdrawal (EVM-only).** `TokenWithdrawalResponse.BurnAmountNative`\n is the amount burned on-chain (sent to the DEAD address) for this\n withdrawal, driven by the currency's `WithdrawalBurnPercent` (see the\n currency-system skill) — `0` if burn is disabled for that currency or the\n network is Solana. The raw-units counterpart, `WithdrawalSignatureResponse.\nBurnAmount`, is bound into the signed hash and must be passed to the\n contract call verbatim, same as `Amount`/`Nonce` — `@idosgames/wallet`'s\n `submitEvmTokenWithdrawal` does this for you; a client calling\n `withdrawERC20` directly must include it too, or the signature check fails.\n- **`client.data.user.state.Blockchain` goes stale after deposits/withdrawal\n requests.** Only `getUserState()` refreshes `LinkedWallets`,\n `PendingWithdrawals`, `Kyc`, and `Stats`. A deposit/withdrawal call updates\n your _balance_/_inventory_ cache correctly, but if your UI also shows the\n pending-withdrawals list or lifetime stats, re-call `getUserState()`\n afterward (see the withdrawal recipe above).\n- **Deposits are reporting, not sending.** `depositToken`/`depositNFT` don't\n move any asset on-chain — they tell the backend \"verify this transaction\n hash and credit me.\" The actual on-chain transfer to the platform's pool/\n vault address must already have happened via a wallet SDK before you call\n these.\n- **This SDK never signs or broadcasts.** `EvmSignature`/`SolanaSignature`\n payloads are inputs to a wallet SDK/contract call that happens outside\n `@idosgames/core`. Don't look for a \"submit on-chain\" method here — there\n isn't one; `confirmWithdrawal` only reports the result afterward. The\n `@idosgames/wallet` companion package is that outside layer — it submits the\n signature on-chain and calls `confirmWithdrawal` for you.\n- **Donations never touch personal balances.** `donateToDeveloper` /\n `donateToUsersPool` intentionally don't credit the player anything and\n don't touch `client.data.user.state` — they only emit their own\n `blockchain:donated*` event for a confirmation toast/receipt.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the relevant cache slice + emits an event; the failure path\n gives you `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: network definitions, NFT collection bindings, account-safety\npolicy, KYC state, transaction documents, the withdrawal-gate limits, and the\nEVM/Solana withdrawal signature payload shapes. Read it when building network\npickers, a transaction-history table, or KYC/limit-aware withdrawal UI. For\nthe shared `ResourceConsume`/`ResourceGrant`/`ResourceOperation`\ncost-and-reward shapes riding along on `depositNFT`/`requestNFTWithdrawal`,\nand for the full `CryptoCurrencyDefinition` shape (`Limits`, `Networks[].\nMinWithdraw`/`WithdrawFee`, `Permissions`) that the withdrawal gates enforce,\nsee the currency-system skill.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
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"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "character-system",
|
|
3
|
+
"description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\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 auth.* method\n\nconst characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ 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), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID)` | Buy/unlock a locked character (charges its `Unlock.Cost`). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.Cost and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has an `Unlock.Cost` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no `Cost` and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Character data model — reference\n\nFull shape of the config (Definitions) and player state, the cost/scaling\nformulas, the equip rule matrix, and presets. All of these are **strictly typed\nin the SDK** — `CharacterDefinitions` and every nested block (`CharacterDefinition`,\n`StatDefinition`, `CharacterLevelDefinition`, `CharacterEquipmentSlot`, the\npresets, …) are exported from `@idosgames/core`, so `getCharacterDefinitions()`\nand `getSection<CharacterDefinitions>(\"Character\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserCharacters()` returns\n- [Config: CharacterDefinitions](#config-characterdefinitions) — what `getCharacterDefinitions()` returns\n- [CharacterDefinition](#characterdefinition)\n- [StatDefinition + formulas](#statdefinition--formulas)\n- [CharacterLevelDefinition (ranks)](#characterleveldefinition-ranks)\n- [Equipment rules & the two-sided matrix](#equipment-rules)\n- [Presets](#presets)\n- [Mutation responses & InventoryDelta](#mutation-responses)\n- [Power model](#power-model)\n\n---\n\n## Player state\n\nReturned by `getUserCharacters()` as `{ Characters: Record<CharacterID, CharacterModel> }`\nand cached at `client.data.user.state?.Character?.Characters`.\n\n```ts\ninterface CharacterModel {\n CharacterID: string;\n Level?: number; // rank; 0 = not activated, >=1 = owned/active\n Experience?: number; // accrued XP (e.g. from PvP); not the manual rank\n Power?: number; // server-computed combat score — read-only\n StatLevels?: Record<string, number>; // statID -> current level (absent = 0)\n Equipment?: Record<string, EquippedItem>; // slotID -> equipped item (cache view)\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface EquippedItem {\n CatalogID?: string;\n ItemID?: string;\n ItemInstanceID?: string; // key into InventoryV2.UnstackableItems (source of truth)\n EquippedAt?: string;\n}\n```\n\nThe `Equipment` map is a convenience cache. The authoritative \"is this item\nequipped and where\" lives on the item instance itself\n(`InventoryV2.UnstackableItems[id].EquippedSlot = { CharacterID, SlotID }`). The\nSDK keeps both consistent on every equip/unequip.\n\n---\n\n## Config: CharacterDefinitions\n\nReturned by `getCharacterDefinitions()`; cached via\n`client.data.config.getSection<CharacterDefinitions>(\"Character\")`.\n\n```ts\ninterface CharacterDefinitions {\n Definitions?: Record<string, CharacterDefinition>; // key = CharacterID\n Presets?: {\n Stats?: Record<string, { Stats?: Record<string, StatDefinition> }>;\n Levels?: Record<\n string,\n { Levels?: Record<string, CharacterLevelDefinition> }\n >;\n Equipment?: Record<string, { Equipment?: CharacterEquipment }>;\n };\n}\n```\n\nA character is \"allowed\" iff it has an entry in `Definitions`. `\"Main\"` must have\nan entry and is `UnlockedByDefault`.\n\n---\n\n## CharacterDefinition\n\nSelf-contained template for one hero.\n\n```ts\ninterface CharacterDefinition {\n CharacterID: string; // no '.' or '$' (MongoDB path rule)\n\n Identity?: {\n DisplayName?: string;\n Description?: string;\n Lore?: string;\n SortOrder?: number; // lower = earlier in roster UI\n AssetPaths?: Record<string, string>; // \"icon\",\"portrait\",\"fullArt\",\"sprite\",...\n };\n\n Classification?: {\n ClassID?: string; // \"Mage\",\"Warrior\",...\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\"\n Tags?: string[]; // free-form: \"ranged\",\"flying\",\"event-2026\"\n };\n\n Unlock?: {\n UnlockedByDefault: boolean; // true = available without unlocking\n Cost?: ResourceConsume; // null/absent = not purchasable (grant-only)\n };\n\n // Inline blocks; merged with the matching preset (if any) via Presets below.\n Stats?: Record<string, StatDefinition>; // statID -> stat\n Levels?: Record<string, CharacterLevelDefinition>; // \"1\",\"2\",... -> rank\n Equipment?: CharacterEquipment;\n\n Presets?: {\n Stats?: { PresetID?: string; Remove?: string[] };\n Levels?: { PresetID?: string; Remove?: string[] };\n Equipment?: { PresetID?: string; Remove?: string[] };\n };\n}\n```\n\nUnlock semantics:\n\n- `UnlockedByDefault: true` → owned from the start (virtualized as `Level 1` by\n `getUserCharacters`). `unlockCharacter` rejects it.\n- `UnlockedByDefault: false` **with** `Cost` → purchasable via `unlockCharacter`.\n- `UnlockedByDefault: false` **without** `Cost` → grant-only (lootbox/quest/etc);\n `unlockCharacter` rejects with \"must be granted by other systems\".\n\n---\n\n## StatDefinition + formulas\n\nOne upgradable stat. Key in `Stats` and in `StatLevels` is the `StatID`.\n\n```ts\ninterface StatDefinition {\n StatID: string; // no '.' or '$'\n TypeID?: string; // free-form category: \"Combat\",\"Resistance\",\"AttackType\",...\n DisplayName?: string;\n Description?: string;\n MaxLevel: number; // base cap (see effective cap below)\n Weight: number; // contribution to Power\n BaseCostResource: ResourceConsume; // cost of level 1 (must be non-empty)\n CostScalingFactor: number; // linear cost growth per level\n BaseStatValue: number; // base effect value at stat level 0 (before the first upgrade)\n StatScalingFactor: number; // flat value added per stat level (additive, not a %)\n CharacterLevelScalingFactor: number; // fractional per-rank value growth (multiplicative)\n Requirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AssetPaths?: Record<string, string>;\n}\n```\n\nFormulas (the backend applies these; use them for previews):\n\n- **Cost of level `N`** (N = target level, 1-based): each entry's base `Amount`\n scaled by `Amount * (1 + CostScalingFactor * (N - 1))`, rounded to the nearest\n whole amount.\n- **Effective max level** = `floor(MaxLevel * StatMaxLevelMultiplier)` where the\n multiplier comes from the character's _current_ rank\n (`CharacterLevelDefinition.StatMaxLevelMultiplier`, default 1). This is why a\n stat can be capped until you rank the character up.\n- **Stat value at stat-level `N`, character-rank `R`** =\n `(BaseStatValue + N * StatScalingFactor) * (1 + CharacterLevelScalingFactor * (R - 1))`.\n The stat part grows **additively** (`N = 0` before the first upgrade, so the\n base value is what a fresh character has); the rank part multiplicatively.\n- **Requirements** are checked before charging: every listed `RequiredStatID`\n must already be at `RequiredLevel`.\n\n`Cost` uses `ResourceConsume`, which may carry `PremiumDiscounts` — the backend\nauto-applies the player's best subscription tier, so the charged amount can be\nbelow the base. Don't assume the displayed base equals what's debited.\n\n---\n\n## CharacterLevelDefinition (ranks)\n\nConfig for one character Level/rank. Key in `Levels` is the level number as a\nstring (`\"1\"`, `\"2\"`, ...). Level `0` = uninitialized, has no config.\n\n```ts\ninterface CharacterLevelDefinition {\n Level: number;\n UpgradeCost?: ResourceConsume; // cost to reach this level; absent = free\n GlobalStatMultiplier?: number; // multiplies the character's stat values (and thus Power) at this rank (1.0 = none)\n StatMaxLevelMultiplier?: number; // raises every stat's cap at this rank\n AssetPaths?: Record<string, string>;\n}\n```\n\n`upgradeCharacterLevel` moves the character from its current level to the next\none and charges that level's `UpgradeCost`.\n\n---\n\n## Equipment rules\n\nEquipping is gated on **both** the character side and the item side; both must\npass. The character side lives here in `CharacterEquipment`; the item side lives\non the item's own `ItemDefinition.Equipment` (from the Item module).\n\n```ts\ninterface CharacterEquipment {\n Slots?: Record<string, CharacterEquipmentSlot>; // key = SlotID; absence = slot forbidden\n}\n\ninterface CharacterEquipmentSlot {\n SlotID: string; // \"Head\",\"Weapon\",\"Armor\",...\n MinCharacterLevel?: number; // 0 = always available\n StatRequirements?: { RequiredStatID: string; RequiredLevel: number }[];\n AllowedRarityIDs?: string[]; // null/empty = any item rarity\n AllowedItemTags?: string[]; // item must have >=1 of these; null/empty = no filter\n MinItemLevel?: number; // vs item-instance Level; 0 = no lower bound\n MaxItemLevel?: number; // 0 = no upper bound\n}\n```\n\nThe two-sided matrix — an equip succeeds only when **all** apply:\n\n| Side | Rule | Rejection when… |\n| --------- | ----------------------------------------------- | -------------------------------------------------- |\n| Character | slot exists in `Slots` | slot not configured for this character |\n| Character | `MinCharacterLevel` | character rank below it |\n| Character | `StatRequirements` | a required stat below its level |\n| Character | `AllowedRarityIDs` | item rarity not in the list |\n| Character | `AllowedItemTags` | item shares no listed tag |\n| Character | `MinItemLevel` / `MaxItemLevel` | item instance level out of range |\n| Item | `AllowedSlotIDs` | item can't go in this slot |\n| Item | `MinCharacterLevel` | character rank below the item's requirement |\n| Item | `AllowedCharacterIDs` | item not allowed on this character |\n| Item | `UseRequirements` | a required stat below its level |\n| Item | equippable + not expired + not already equipped | item isn't equippable / expired / in use elsewhere |\n\nItem-instance `Level` (per-instance, upgraded via the Item module's\n`UpgradeLevel`) is what `MinItemLevel`/`MaxItemLevel` compare against — not a\nper-definition value.\n\n---\n\n## Presets\n\nTo avoid repeating identical stat/level/equipment blocks across many characters,\na title can define shared presets and reference them via a `PresetBinding`\n(`{ PresetID?, Remove? }`) on `CharacterDefinition.Presets`. Combination is\ndata-driven, no mode: no `PresetID` → inline only; `PresetID` set, inline\nempty/absent → preset as-is; both set → **merge** (preset base, inline\noverrides/adds by key, `Remove` drops keys). A missing/invalid preset id\nresolves to \"no preset\" — same as a character without one.\n\n- `Stats`: preset from `Presets.Stats.PresetID` merged with inline `Stats` by\n `StatID` (preset base + inline override/add), `Presets.Stats.Remove` drops keys.\n- `Levels`: preset from `Presets.Levels.PresetID` merged with inline `Levels` by\n level key, `Presets.Levels.Remove` drops keys.\n- `Equipment`: preset from `Presets.Equipment.PresetID` merged with inline\n `Equipment.Slots` by `SlotID` (nested — `Equipment` itself isn't replaced\n wholesale, only its `Slots` dictionary is merged), `Presets.Equipment.Remove`\n drops slot keys.\n\nWhen reading config for UI, resolve the effective block the same way (preset\nbase + inline overlay + Remove) so previews match what the server will enforce.\n\n---\n\n## Mutation responses\n\nEvery mutating action returns the recomputed `Power` and (for equip/unequip) an\n`InventoryDelta` that reconciles `InventoryV2.UnstackableItems`. The SDK applies\nall of this to the cache for you; the shapes are documented here for building\nricher UI (e.g. animating the exact items that moved).\n\n```ts\n// Port of InventoryDelta.cs — a minimal unstackable-items reconcile, so the\n// client never re-reads the whole inventory after an equip/unequip.\ninterface InventoryDelta {\n // upsert by ItemInstanceID; value is the FULL post-state of the instance\n ChangedInstances?: Record<string, UnstackableItemInstanceState>;\n // remove by ItemInstanceID (fully-consumed packs, instances merged back)\n RemovedInstanceIDs?: string[];\n}\n```\n\nApplying it: for each `ChangedInstances[id]`, overwrite\n`UnstackableItems[id]` with the full post-state (this is how `EquippedSlot`\nflips on/off, how stack-splits introduce new instance ids, and how a pack's\nreduced `Quantity` lands); then delete every id in `RemovedInstanceIDs`. The\ndelta covers only the operation's **main atomic patch** — a best-effort pristine\ndefrag may sweep duplicate packs slightly later, which reconverges on the next\nfull inventory read.\n\n```ts\ninterface EquipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n Equipment?: Record<string, EquippedItem>; // slotID -> final equipped record\n ReplacedInstanceIDs?: string[]; // instances bumped out of those slots\n Power?: number;\n Inventory?: InventoryDelta; // authoritative UnstackableItems changes\n}\n\ninterface UnequipItemsResponse {\n ServerTimeUtc: string;\n CharacterID: string;\n ClearedSlotIDs?: string[]; // only slots ACTUALLY cleared (empty ones skipped)\n Power?: number | null; // null when the request was an empty no-op\n Inventory?: InventoryDelta;\n}\n\ninterface CharacterUnequipResult {\n ClearedSlotIDs?: string[];\n Power?: number;\n}\n\ninterface UnequipAllCharactersResponse {\n ServerTimeUtc: string;\n // per-character results; characters with no gear are omitted\n Characters?: Record<string, CharacterUnequipResult>;\n Inventory?: InventoryDelta; // one delta for the whole sweep\n}\n```\n\nThe three batch actions resolve to a **`BatchResponse<T>` wrapper**, not a bare\narray:\n\n```ts\ninterface BatchResponse<T> {\n ServerTimeUtc: string;\n Items: BatchItemResult<T>[]; // per-item Success/Error/Data (Data.Resources is null)\n Resources?: ResourceOperation | null; // ONE merged charge for the whole batch\n}\n```\n\nRead per-item outcomes from `data.Items`; the merged consumed/granted resources\nare at `data.Resources` (applied to the cache once). Each successful item's\n`Data` carries its own recomputed `Power`.\n\n---\n\n## Power model\n\n`Power` is an integer the backend recomputes on every unlock / stat upgrade /\nrank upgrade / equip / unequip, and stores on the `CharacterModel`. It blends:\n\n- each stat's contribution — its value (see the stat formula) times its\n `Weight`, summed across stats;\n- the rank's `GlobalStatMultiplier`;\n- flat/percent bonuses from equipped item instances, plus any explicit item\n Power.\n\nThe exact blend is server-owned and may evolve. **Never reproduce it on the\nclient** — read `Power` from the response (`EquipItemsResponse.Power`) or the\ncached `CharacterModel.Power`. It's used for PvP leaderboards and matchmaking, so\na client-side estimate that drifts from the server value will mislead players.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cloud-code",
|
|
3
|
+
"description": "Call custom server-side game logic on the iDosGames TypeScript SDK (@idosgames/core) via client.cloudCode (CloudCodeService): execute a title-defined cloud script by name with an arbitrary JSON args payload and get back its arbitrary JSON result. Use this whenever the user wants to run custom/bespoke server logic, a \"cloud script\", \"cloud function\", \"server callable\", crafting/trading/matchmaking logic not covered by a dedicated SDK module, or otherwise touches client.cloudCode, CloudCodeService, or ExecuteCloudCodeResponse — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: cloud-code\ndescription: >-\n Call custom server-side game logic on the iDosGames TypeScript SDK\n (@idosgames/core) via client.cloudCode (CloudCodeService): execute a\n title-defined cloud script by name with an arbitrary JSON args payload and\n get back its arbitrary JSON result. Use this whenever the user wants to run\n custom/bespoke server logic, a \"cloud script\", \"cloud function\", \"server\n callable\", crafting/trading/matchmaking logic not covered by a dedicated SDK\n module, or otherwise touches client.cloudCode, CloudCodeService, or\n ExecuteCloudCodeResponse — even if they don't name the module explicitly.\n---\n\n# Cloud Code (iDosGames TS SDK)\n\nCloudCodeService is the **escape hatch**: a generic way to run a title-defined\nserver-side script (a \"handler\") and get back whatever JSON that script\nreturns. Use it when a feature doesn't have a dedicated SDK module (Character,\nItem, Economy, etc.) — e.g. bespoke crafting rules, custom matchmaking, an\nadmin action, anything that's easier to write once as server logic than to\ncompose from generic client calls. If a dedicated module already covers what\nyou need, prefer that module — it gives you typed request/response shapes and\ncache integration; Cloud Code gives you neither.\n\nThis skill is for **using** production Cloud Code, not for writing the scripts\nthemselves (that's title/server-side configuration — a JavaScript revision\ndeployed and administered outside this SDK, out of scope here).\n\n## Mental model\n\nThere is exactly one client-facing action: `execute`. You pass a **function\nname** (a handler defined in the title's deployed script's `handlers` object)\nand an optional **arbitrary JSON payload**; the server runs it in a sandboxed\nJS engine and returns an arbitrary JSON result plus execution metadata (logs,\ntiming, error info). The SDK has no idea what a given script's args or result\nlook like — **you** know your title's script contract, so you type the\npayload and result yourself (see Gotchas). There's no \"config vs state\" split\nhere like other modules — Cloud Code has no persistent per-player data model\nof its own; it's pure request/response.\n\nScript failure is a **first-class outcome, not a network error**: if the\nscript throws, times out, is rate-limited, or the handler doesn't exist, the\ncall still comes back `{ ok: true, data }` with `data.Error` populated and\n`data.FunctionResult` empty. Only infrastructure problems (not logged in, bad\nlocal args, connection issues, backend down) surface as `{ ok: false }`.\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 auth.* method\n\nconst cloudCode = client.cloudCode; // the CloudCodeService\n```\n\nRequires an authenticated session — without one, `execute` returns\n`{ ok: false, reason: \"unauthorized\" }` rather than making a request.\n\n## Methods\n\n`execute` returns `Promise<OperationResult<ExecuteCloudCodeResponse>>`: either\n`{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data` — and then check `data.Error` before\ntrusting `data.FunctionResult` (see below). `reason` is one of `\"client\"`\n(empty/whitespace-only function name), `\"unauthorized\"`, `\"throttled\"` (fired\nthe same call again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"`\n(infrastructure-level rejection — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------- |\n| `execute(functionName, functionParameter?, revisionSelection?, specificRevision?)` | Run a title-defined cloud script handler by name. | `ExecuteCloudCodeResponse` |\n\nParameters:\n\n- `functionName` — the handler name inside the deployed script's `handlers`\n object. Case-sensitive; trimmed before sending. Client-side, only\n empty/whitespace is rejected (`reason: \"client\"`). Server-side, the backend\n additionally rejects (as a script-level `InvalidFieldName` error, not an\n `OperationResult` failure) names containing `.`, `$`, whitespace, or control\n characters, or longer than 128 characters — these are illegal as MongoDB\n field names since the name can end up in audit/log paths.\n- `functionParameter?` — any `JsonValue` (object, array, string, number,\n boolean, or null) passed as the handler's first argument. Omit if the script\n needs no input. If it's an object (at any nesting depth), none of its keys\n may contain `.` or `$` — the backend rejects such payloads with a\n script-level `InvalidFieldName` error before the script ever runs.\n- `revisionSelection?` — `\"Live\"` (default when omitted), `\"Latest\"`, or\n `\"Specific\"`. Lets you target a non-live revision for testing.\n- `specificRevision?` — the revision number to run; only used when\n `revisionSelection` is `\"Specific\"`.\n\n`ExecuteCloudCodeResponse` shape:\n\n| Field | Meaning |\n| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |\n| `FunctionName` | Echo of the handler that ran. |\n| `Revision` | Which revision actually executed. |\n| `FunctionResult` | The script's return value — arbitrary JSON, `null` if it returned nothing or on error. |\n| `FunctionResultTooLarge` | `true` if the result was dropped for exceeding the title's result-size limit (`FunctionResult` is `null` in that case). |\n| `Logs` | Array of `{ Level, Message?, Data? }` entries from `log.debug/info/warn/error` calls inside the script. |\n| `LogsTooLarge` | `true` if logs were truncated for exceeding the title's log-size limit. |\n| `ExecutionTimeSeconds` | Server-side wall-clock execution duration. |\n| `APIRequestsIssued` | Count of server API calls the script made internally (e.g. reading user data) — counts toward a per-execution cap. |\n| `Error` | `{ Error: CloudCodeErrorCode, Message?, StackTrace? }`, present only when the script failed or never ran; `null`/absent on success. |\n\n`CloudCodeErrorCode` values: `None`, `Disabled`, `NoActiveRevision`,\n`RevisionNotFound`, `InvalidFieldName`, `RateLimited`, `HandlerNotFound`,\n`HandlerDisabled`, `Timeout`, `StatementCountExceeded`, `StackOverflow`,\n`ApiCallLimitExceeded`, `JavaScriptException`, `ExecutionError` — stable, safe\nto switch on for retry/UX logic (e.g. treat `RateLimited`/`Timeout` as\nretryable, others as not).\n\nOn success, the SDK emits an event — it does **not** write anything into\n`client.data`, since the result shape is script-specific and there's no\ngeneric cache slot for it. If your script mutates player state (grants\ncurrency, items, etc. via server-side APIs), re-fetch that state through its\nowning module afterward — Cloud Code itself won't refresh your local cache.\n\n## Events\n\nSubscribe with `client.on(...)`; returns an unsubscribe fn.\n\n- `cloudCode:executed` → `ExecuteCloudCodeResponse` — fired whenever `execute` returns `{ ok: true }`, regardless of whether the script itself succeeded (check `data.Error` inside the handler).\n\n```ts\nconst off = client.on(\"cloudCode:executed\", (r) => {\n if (r.Error) console.warn(\"script failed:\", r.Error.Error, r.Error.Message);\n});\n// later: off();\n```\n\n## Recipes\n\n### Call a script and handle both failure layers\n\n```ts\ninterface GrantBonusArgs {\n reason: string;\n}\ninterface GrantBonusResult {\n granted: number;\n}\n\nconst args: GrantBonusArgs = { reason: \"daily\" };\nconst result = await client.cloudCode.execute(\"grantLoginBonus\", args);\nif (!result.ok) return showError(result.error ?? result.reason); // infra-level failure\n\nif (result.data.Error) {\n return showError(result.data.Error.Message ?? result.data.Error.Error); // script-level failure\n}\n\nconst payload = result.data.FunctionResult as GrantBonusResult; // your contract — cast/validate it yourself\nconsole.log(`granted ${payload.granted}`);\n```\n\n### Fire-and-forget script with no input\n\n```ts\nconst result = await client.cloudCode.execute(\"resetDailyQuests\");\nif (!result.ok || result.data.Error) {\n console.warn(\"resetDailyQuests failed\", result.error ?? result.data.Error);\n}\n```\n\n### Test against a specific revision before it goes live\n\n```ts\nconst result = await client.cloudCode.execute(\n \"computeMatchReward\",\n { matchID },\n \"Specific\",\n 42, // revision number\n);\n```\n\n### Surface script logs during development\n\n```ts\nconst result = await client.cloudCode.execute(\"debugScript\", { x: 1 });\nif (result.ok) {\n for (const log of result.data.Logs ?? []) {\n console.log(`[${log.Level}]`, log.Message, log.Data);\n }\n}\n```\n\nLogs only come back at all if the title has logs enabled for clients; on\ntitles that don't, `Logs` is always an empty array even though the script did\nlog server-side — don't treat an empty array as proof the script logged\nnothing.\n\n### Chain a cloud-code call with a resource refresh\n\n```ts\nconst res = await client.cloudCode.execute(\"craftSpecialItem\", { recipeID });\nif (!res.ok || res.data.Error) return showError(res.error ?? res.data.Error);\n\n// The script granted items/currency server-side — Cloud Code didn't touch the\n// cache, so pull the owning module's state to see the new balance/inventory.\nawait client.user.getClientState(); // or the specific module's getter, e.g. client.item...\n```\n\n## Gotchas\n\n- **Two failure layers, don't conflate them.** `result.ok === false` means the\n call itself failed (auth, bad args, connection) — the script never ran or\n its outcome is unknown. `result.ok === true && result.data.Error` means the\n call succeeded but the _script_ failed (threw, timed out, disabled,\n unknown/undeclared handler, rate-limited) — always check both before\n trusting `FunctionResult`.\n- **Unknown handler is a script-level error, not a client-side check.** The\n SDK never validates that `functionName` refers to a real handler — that's\n entirely server-side. Depending on the title's config you can get\n `HandlerNotFound` either because the name isn't in the title's declared\n handler whitelist, or because the deployed script simply never defined\n `handlers[functionName]`; both look the same to the caller. A handler can\n also be individually killed by an admin, which comes back as\n `HandlerDisabled`.\n- **A hard 10-second ceiling always applies.** Whatever timeout the title/\n revision configures, the backend clamps every single execution to a 10\n second wall-clock budget; past that you get `Timeout` no matter what. Don't\n design a script-based feature around long-running work.\n- **Rate limiting can hit independently of the generic per-endpoint throttle.**\n Beyond the SDK's own ~600ms client-side throttle per call and the\n transport's per-user rate limit, the title can configure CloudCode-specific\n limits at three levels — whole title, this user, or this user+handler pair.\n Any of them tripping comes back as `data.Error.Error === \"RateLimited\"`\n (an in-band script-level outcome, `result.ok` is still `true`), with\n `data.Error.Message` naming which layer triggered it — treat it as\n retryable-after-a-delay, not a hard failure.\n- **No client-side validation of script logic.** The SDK only validates that\n `functionName` is non-empty and that you're logged in. Argument shape,\n business rules, and error handling are entirely up to the script — a\n malformed `functionParameter` will fail server-side (`JavaScriptException`\n or similar), not client-side.\n- **Type the payload and result yourself.** `functionParameter` is `JsonValue`\n and `FunctionResult` is `JsonValue | null` — the SDK has no schema for your\n title's specific scripts. Define your own request/response interfaces per\n handler (as in the recipes above) and cast/validate after the call.\n- **Cloud Code doesn't touch `client.data`.** Unlike feature modules, a\n successful `execute` doesn't mirror anything into the cache. If the script\n changed player-facing state, re-fetch it via the owning module (e.g. call\n the Economy/Item/Character module's getter) so the UI reflects it.\n- **`Logs`/`FunctionResult` can be silently dropped.** Both are subject to a\n title-configured byte-size ceiling; check `LogsTooLarge` /\n `FunctionResultTooLarge` before assuming absence means the script produced\n nothing. Whether `Logs` is populated at all (even under the size limit) also\n depends on a title setting — some titles never reveal script logs to\n clients.\n- **Keys in your JSON payload can't contain `.` or `$`.** This is a MongoDB\n field-name restriction the backend enforces recursively on\n `functionParameter` (and on whatever the script returns) — a payload with a\n dotted or `$`-prefixed key fails with `InvalidFieldName` before the script\n even starts. Stick to plain alphanumeric/underscore keys.\n- **Prefer a dedicated module when one exists.** Cloud Code has no typed\n contract, no cache integration, and no per-feature event — reach for it only\n when the feature genuinely isn't covered elsewhere.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "collection-system",
|
|
3
|
+
"description": "Build a collection / sticker-album / TCG system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.collection (CollectionService): open collectible packs and pity-driven collection chests, spend \"joker\" wildcards to fill a specific slot, claim set-completion rewards (single + batch) and the collection Grand Prize, and run peer-to-peer collectible trading (send/cancel/accept/decline trade offers, list my/incoming offers). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a sticker album, TCG-style collection/set-completion screen, pack-opening UI, duplicate/pity systems, or player-to-player item trading — or otherwise touches client.collection, CollectionService, CollectionDefinitions, UserCollectionState, or trade offers — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: collection-system\ndescription: >-\n Build a collection / sticker-album / TCG system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.collection (CollectionService):\n open collectible packs and pity-driven collection chests, spend \"joker\"\n wildcards to fill a specific slot, claim set-completion rewards (single +\n batch) and the collection Grand Prize, and run peer-to-peer collectible\n trading (send/cancel/accept/decline trade offers, list my/incoming offers).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a sticker album, TCG-style\n collection/set-completion screen, pack-opening UI, duplicate/pity systems,\n or player-to-player item trading — or otherwise touches client.collection,\n CollectionService, CollectionDefinitions, UserCollectionState, or trade\n offers — even if they don't name the module explicitly.\n---\n\n# Collection system (iDosGames TS SDK)\n\nThe Collection module is a \"sticker album\": a title defines one or more\n**Collections**, each made of thematic **Sets** (\"pages\"), each Set made of\n**Collectibles** (\"stickers\", each optionally with a rarer Special version).\nPlayers fill the album by opening **Packs** (lootboxes) and **Collection\nChests** (pity-driven, bought with Collection Currency earned from\nduplicates), can burn a **Joker** wildcard to fill one specific missing\nCollectible, claim a reward when a Set is completed, and claim a **Grand\nPrize** when the whole Collection is completed. A separate **trading**\nsub-system lets players swap Collectibles peer-to-peer.\n\nEverything is **server-authoritative**, same contract as the rest of the SDK:\ncall a method, check `result.ok`, render from the mirrored cache. This skill\nis for **using** the production `CollectionService`, not porting or extending\nit — a rejection is the backend enforcing a rule, surface the error rather\nthan reproducing the check client-side.\n\nThis module frequently sits next to [item-system](../item-system/SKILL.md) or\ncharacter loadouts — Collectibles are a separate currency-and-progress track\nfrom `client.item`/`client.character`, not items themselves, though a title\nmay reward items via `SetCompletionReward` / `GrandPrize`.\n\n## The two data shapes\n\n1. **Definitions** (config) — the title's catalog: `Collections` (each with\n `Sets`, each with `Collectibles`), `PackTypes` (lootbox-style openable\n packs), `CollectionChests` (pity-buy chests priced in Collection\n Currency), `DuplicateConversions` (duplicate → currency rate by rarity),\n `DailyTradeLimit`, the joker's `CollectibleJokerCatalogID` /\n `CollectibleJokerItemID`, and `SpecialTradeEvents` (time windows that\n unlock Special-collectible trading). Fetched with `getDefinitions()`.\n2. **User state** (state, per player) — `CollectionCurrencyBalance`,\n `OwnedCollectibles` / `OwnedSpecialCollectibles` (id → count),\n `ClaimedSetRewards`, `IsCollectionCompleted`, `GrandPrizeClaimed`,\n `DailyTradesSent` (+ reset date), `PendingTradeOfferIDs`, and pity\n `PityCounters`. Fetched with `getUserState()`. **This state object is\n stored wholesale in the cache and typed leniently (`Record`-style\n passthrough)** — read fields defensively (`?.`), don't assume every field\n is always present.\n\nFor the full field-by-field shape, formulas for duplicate conversion, and the\ntrade-offer document shape, read\n[references/data-model.md](references/data-model.md).\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 auth.* method\n\nconst collection = client.collection; // the CollectionService\n```\n\nEvery method requires an authenticated session; without one they return\n`{ ok: false, reason: \"unauthorized\" }` — none of them throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: `{ ok: true, data }` or\n`{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is one\nof `\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (same\nendpoint fired again inside the 600ms default window), `\"connection\"`\n(transient — offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` has the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's collection catalog (config). | `CollectionDefinitions` |\n| `getUserState()` | Load this player's collection progress (state). | `UserCollectionState` |\n| `openPack(collectionID, packTypeID)` | Open one pack (charges the pack's `Cost`). | `OpenPackResponse` |\n| `openCollectionChest(collectionID, collectionChestID)` | Open a pity chest (charges Collection Currency). | `OpenCollectionChestResponse` |\n| `useCollectibleJoker(collectionID, collectibleID)` | Burn one Joker item to grant a specific Collectible. | `UseCollectibleJokerResponse` |\n| `claimSetReward(collectionID, setID)` | Claim a completed Set's reward. | `ClaimSetRewardResponse` |\n| `claimSetRewardsBatch(sets)` | Claim several completed Sets in one atomic call (deduped by SetID). | `ClaimSetRewardsBatchResponse` (`BatchItemResult<ClaimSetRewardResponse>[]`) |\n| `claimGrandPrize(collectionID)` | Claim the Grand Prize once the whole Collection is completed. | `ClaimGrandPrizeResponse` |\n| `sendTradeOffer(collectionID, collectibleID, collectibleIsSpecial, receiverUserID, requestedCollectibleID?, requestedCollectibleIsSpecial?)` | Offer one of your Collectibles to another player, optionally requesting a specific one back. | `SendTradeOfferResponse` |\n| `cancelTradeOffer(offerID)` | Cancel a trade offer you sent. | `CancelTradeOfferResponse` |\n| `acceptTradeOffer(offerID)` | Accept an incoming trade offer (transfers both sides). | `AcceptTradeOfferResponse` |\n| `declineTradeOffer(offerID)` | Decline an incoming trade offer. | `DeclineTradeOfferResponse` |\n| `getMyTradeOffers(collectionID)` | List trade offers you've sent for a collection. | `GetTradeOffersResponse` (`{ Offers: CollectionTradeOfferDocument[] }`) |\n| `getIncomingTradeOffers(collectionID)` | List trade offers sent to you for a collection. | `GetTradeOffersResponse` |\n\nOn success, resource-affecting methods (`openPack`, `openCollectionChest`,\n`useCollectibleJoker`, `claimSetReward`, `claimSetRewardsBatch`,\n`claimGrandPrize`) mirror `data.Resources` (a `ResourceOperation`) into the\ncached currency/item balances — read updated balances straight from\n`client.data.user`. **Trade-offer methods do not touch resource balances or\nthe `Collection` cache slice** — they're domain-only actions that surface\npurely through their event; refetch `getUserState()` / `getMyTradeOffers()` /\n`getIncomingTradeOffers()` to see the effect of a trade.\n\n## Reading state and reacting to changes\n\n```ts\n// Cached after getUserState():\nconst state = client.data.user.state?.Collection;\nstate?.CollectionCurrencyBalance;\nstate?.OwnedCollectibles; // { collectibleID: count }\nstate?.ClaimedSetRewards; // string[]\n\n// Cached after getDefinitions():\nimport type { CollectionDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `collection:definitionsLoaded` → `CollectionDefinitions`\n- `collection:userStateLoaded` → `UserCollectionState`\n- `collection:packOpened` → `OpenPackResponse`\n- `collection:chestOpened` → `OpenCollectionChestResponse`\n- `collection:jokerUsed` → `UseCollectibleJokerResponse`\n- `collection:setRewardClaimed` → `ClaimSetRewardResponse`\n- `collection:setRewardsClaimedBatch` → `ClaimSetRewardsBatchResponse`\n- `collection:grandPrizeClaimed` → `ClaimGrandPrizeResponse`\n- `collection:tradeOfferSent` → `SendTradeOfferResponse`\n- `collection:tradeOfferCancelled` → `CancelTradeOfferResponse`\n- `collection:tradeOfferAccepted` → `AcceptTradeOfferResponse`\n- `collection:tradeOfferDeclined` → `DeclineTradeOfferResponse`\n- `collection:myTradeOffersLoaded` → `GetTradeOffersResponse`\n- `collection:incomingTradeOffersLoaded` → `GetTradeOffersResponse`\n\nThe coarse `user:collectionUpdated` (+ umbrella `user:anyUpdated`) fires only\nfrom `getUserState()` (it's emitted by `applyCollection`, the whole-state\ncache write) — it does **not** fire from pack/chest/joker/claim calls, since\nthose patch resource balances rather than the `Collection` state slice\ndirectly. Re-`getUserState()` after those calls (or after a trade) if you need\nthe cached collection progress to reflect the change.\n\n```ts\nconst off = client.on(\"collection:packOpened\", (r) => {\n for (const c of r.GrantedCollectibles ?? []) console.log(c.CollectibleID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the album and open a pack\n\n```ts\nawait client.collection.getDefinitions();\nawait client.collection.getUserState();\n\nconst defs = client.data.config.getSection<CollectionDefinitions>(\"Collection\");\nconst owned = client.data.user.state?.Collection?.OwnedCollectibles ?? {};\n\nconst pack = await client.collection.openPack(\"main-collection\", \"starter\");\nif (!pack.ok) return showError(pack.error); // e.g. insufficient currency/items\nfor (const c of pack.data.GrantedCollectibles ?? []) {\n // new sticker; check pack.data.DuplicateCollectibles for ones already owned\n}\nif (pack.data.CollectionJustCompleted) showGrandPrizeAvailable();\nfor (const setID of pack.data.NewlyCompletedSetIDs ?? [])\n showSetComplete(setID);\n\n// Balances (pack Cost debited, CollectionCurrencyEarned credited from\n// duplicate conversion) are already reflected here:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\n```\n\n### Spend Collection Currency on a pity chest, then a Joker\n\n```ts\nconst chest = await client.collection.openCollectionChest(\n \"main-collection\",\n \"silver-chest\",\n);\nif (!chest.ok) return showError(chest.error);\n// chest.data.NewCollectionCurrencyBalance reflects the debit; TriggeredPity\n// lists any pity rule(s) that fired on this open.\n\n// Jokers are a regular item (CollectibleJokerItemID in Definitions) burned\n// to grant one specific missing Collectible. Special versions can't be\n// targeted this way — pass collectibleIsSpecial via a Special-only Collectible\n// and it's rejected: \"CollectibleJoker cannot be used for Special Collectibles.\"\nconst joker = await client.collection.useCollectibleJoker(\n \"main-collection\",\n \"card-042\",\n);\nif (!joker.ok) return showError(joker.error); // e.g. \"already owned\", no joker item\nif (joker.data.CollectionJustCompleted) showGrandPrizeAvailable();\n```\n\n### Claim set rewards, then the Grand Prize\n\n```ts\nconst setClaim = await client.collection.claimSetReward(\n \"main-collection\",\n \"set-forest\",\n);\nif (!setClaim.ok) return showError(setClaim.error); // e.g. \"set not completed\", \"already claimed\"\n\nif (client.data.user.state?.Collection?.IsCollectionCompleted) {\n const grand = await client.collection.claimGrandPrize(\"main-collection\");\n if (!grand.ok) return showError(grand.error); // e.g. \"already claimed\"\n}\n```\n\n### Batch-claim several completed sets\n\n```ts\nconst res = await client.collection.claimSetRewardsBatch([\n { CollectionID: \"main-collection\", SetID: \"set-forest\" },\n { CollectionID: \"main-collection\", SetID: \"set-ocean\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) markClaimed(item.Id);\n else showItemError(item.Id, item.Error); // e.g. that set wasn't complete\n}\n```\n\nBatch results are **partial-aware**: `res.ok` says the call ran; each\nelement's `Success`/`Error` says whether that specific set's reward applied.\nOnly the **first** successful item's `Resources` is applied to the cache by\nthe SDK (a `resourcesApplied` guard short-circuits after the first) — if you\nneed every claimed set's grant reflected precisely, re-fetch balances (e.g.\n`client.user`/inventory refresh) after a multi-set batch rather than trusting\nthe cache to have summed them all.\n\n### Trade Collectibles peer-to-peer\n\n```ts\n// Offer my duplicate for a specific card back. Receiver must already be a friend\n// (see social-system) — the server rejects otherwise.\nconst sent = await client.collection.sendTradeOffer(\n \"main-collection\",\n \"card-011\", // CollectibleID I'm giving\n false, // not a Special version\n \"u2\", // receiver\n \"card-042\", // requested back (optional — omit for an open/gift offer)\n);\nif (!sent.ok) return showError(sent.error); // e.g. daily trade limit reached, don't own it\n\n// Receiver's side:\nconst incoming =\n await client.collection.getIncomingTradeOffers(\"main-collection\");\nfor (const offer of incoming.data?.Offers ?? []) {\n // offer.Status === \"Pending\" -> show Accept/Decline\n}\nconst accept = await client.collection.acceptTradeOffer(offer.OfferID);\nif (!accept.ok) return showError(accept.error); // e.g. offer expired, requested card no longer owned\naccept.data.ReceivedCollectibleID; // what I got\naccept.data.SentCollectibleID; // what I gave up\n\n// Sender can cancel while still Pending:\nawait client.collection.cancelTradeOffer(sent.data.OfferID);\n```\n\nRules enforced server-side, not client-side — surface the `error` string, don't\npre-validate:\n\n- **Receiver must be a friend.** `sendTradeOffer` rejects with \"Receiver must be\n in your friends list\" otherwise (see [social-system](../social-system/SKILL.md)\n to add them first).\n- **You need a spare copy to offer or request one back.** A normal Collectible\n needs `OwnedCollectibles[id] >= 2` to be offered or requested (one copy stays\n with you); a Special needs only `>= 1` (it moves entirely, no copy kept\n behind). Rejections read \"You need a duplicate (count >= 2) to trade this\n Collectible.\" / \"You don't own this Special Collectible.\"\n- **The receiver's inbox caps at 10 pending offers**; sending past that fails\n with \"Receiver has too many pending trade offers.\"\n- **Offers expire after 7 days** (168h) from creation — `ExpiresAtUtc` on the\n response and on `CollectionTradeOfferDocument`; `acceptTradeOffer` past that\n point fails with \"Offer has expired.\" Nothing ever flips the stored `Status`\n to `\"Expired\"` server-side, though — `getIncomingTradeOffers` just filters\n lapsed offers out of the list, while `getMyTradeOffers` keeps returning them\n as `Status: \"Pending\"` with a stale `ExpiresAtUtc`. Compare `ExpiresAtUtc`\n to now yourself when rendering your own sent-offers list.\n- Special-version Collectibles can normally only be traded during a\n `SpecialTradeEventDefinition` window (`AllowedSpecialCollectibleIDs`,\n `SpecialTradeEventDailyTradeLimit`) — offering **or being asked for** a\n Special outside that window is rejected server-side on both `sendTradeOffer`\n and `acceptTradeOffer`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key —\n two separate calls are two real operations. A double-clicked \"Open Pack\"\n can open (and charge) twice. Disable the control while a call is in\n flight; the 600ms default throttle window rejects same-endpoint spam with\n `reason: \"throttled\"` but isn't a substitute for disabling the button.\n- **`user:collectionUpdated` is a state-replace signal, not a delta signal.**\n It only fires from `getUserState()`. Don't wire \"refresh the album UI\" to\n it and expect pack/chest/claim calls to trigger it — listen to the\n specific action events (`collection:packOpened`, etc.) instead, or\n re-`getUserState()` after mutating actions if you need the state slice\n itself refreshed.\n- **Trade offers never touch resources or the `Collection` cache slice.**\n There's no automatic balance/inventory update from send/cancel/accept/\n decline — re-fetch `getUserState()` (and re-list offers) to see the\n post-trade picture.\n- **`claimSetRewardsBatch` only applies the first successful item's\n `Resources` to the cache.** If the batch claims multiple sets, don't assume\n the cached currency/item balances reflect all of them — verify against a\n fresh state fetch if the UI shows exact totals.\n- **`UserCollectionState` is loosely typed (passthrough over `{}`).** Unlike\n `CollectionDefinitions` (strictly typed), the per-player state interface is\n a best-effort shape — treat documented fields as likely-present, not\n guaranteed, and code defensively.\n- **Duplicates aren't wasted — they convert to Collection Currency** per\n `DuplicateConversions` (rate keyed by rarity), which is what funds\n `openCollectionChest`. `OpenPackResponse.DuplicateCollectibles` lists which\n pulls were duplicates and `CollectionCurrencyEarned` is the resulting\n credit for that pack.\n- **A season-linked collection can wipe out from under you.** If a\n `CollectionDefinition` has `SeasonChainID` set, the backend resets the\n player's entire `Collection` state (owned Collectibles, currency, claimed\n sets, pity, everything) the moment the linked season rolls over — lazily, on\n the next call that touches Collection. There's no client-side warning event\n for this; just always render from a fresh `getUserState()` rather than\n assuming yesterday's cache is still valid across a session boundary.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the pack/chest reward-slot and pity-rule shape, the trade-offer\ndocument lifecycle, and the joker/duplicate-conversion mechanics.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Collection data model — reference\n\nFull shape of the config (`CollectionDefinitions`), player state\n(`UserCollectionState`), the pack/chest reward mechanics, and the trade-offer\nlifecycle. Config types are **strictly typed in the SDK** — `CollectionDefinitions`\nand every nested block are exported from `@idosgames/core`. Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CollectionDefinitions](#config-collectiondefinitions)\n- [CollectionDefinition / Sets / Collectibles](#collectiondefinition--sets--collectibles)\n- [PackTypes and CollectionChests (reward slots + pity)](#packtypes-and-collectionchests)\n- [Duplicate conversion](#duplicate-conversion)\n- [SpecialTradeEvents](#specialtradeevents)\n- [Player state: UserCollectionState](#player-state-usercollectionstate)\n- [Season-linked wipe](#season-linked-wipe)\n- [Trade offers](#trade-offers)\n- [Responses](#responses)\n\n---\n\n## Config: CollectionDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CollectionDefinitions>(\"Collection\")`.\n\n```ts\ninterface CollectionDefinitions {\n Collections?: Record<string, CollectionDefinition> | null; // key = CollectionID\n PackTypes?: Record<string, CollectionPackTypeDefinition> | null; // key = PackTypeID\n CollectionChests?: CollectionChestDefinition[] | null;\n DuplicateConversions?: DuplicateCollectionCurrencyConversion[] | null;\n DailyTradeLimit?: number | null;\n CollectibleJokerCatalogID?: string | null;\n CollectibleJokerItemID?: string | null;\n SpecialTradeEvents?: SpecialTradeEventDefinition[] | null;\n}\n```\n\n`DailyTradeLimit` bounds `sendTradeOffer` calls per calendar day (tracked by\n`UserCollectionState.DailyTradesSent` / `DailyTradesResetDate`, reset at UTC\nmidnight); backend default is **5/day** if the title doesn't set it.\n`CollectibleJokerItemID` (optionally scoped by `CollectibleJokerCatalogID`) is\nthe item burned by `useCollectibleJoker` — grant this item to players through\nthe Item/Store/Lootbox modules; the Collection module only consumes it. It's a\nnormal `InventoryV2.Items` item and does not burn on a season wipe, so players\ncan bank Jokers across seasons.\n\n---\n\n## CollectionDefinition / Sets / Collectibles\n\nThree-level hierarchy: Collection → Set → Collectible.\n\n```ts\ninterface CollectionDefinition {\n CollectionID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n SeasonChainID?: string; // links this collection to a season chain, if any\n Sets?: CollectionSetDefinition[];\n GrandPrize?: ResourceGrant; // claimed once via claimGrandPrize()\n}\n\ninterface CollectionSetDefinition {\n SetID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n SortOrder?: number;\n Collectibles?: CollectibleDefinition[];\n SetCompletionReward?: ResourceGrant; // claimed once via claimSetReward()\n}\n\ninterface CollectibleDefinition {\n CollectibleID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Rarity?: number;\n HasSpecialVersion?: boolean; // a rarer \"Special\" variant exists for this id\n SortOrder?: number;\n}\n```\n\nA Collection is \"completed\" (`IsCollectionCompleted`) once every Set inside it\nis completed; a Set is completed once every listed Collectible has been\nobtained at least once (`OwnedCollectibles[id] >= 1`). Owning duplicates past\n1 does not grant anything further directly — see\n[Duplicate conversion](#duplicate-conversion).\n\n---\n\n## PackTypes and CollectionChests\n\nBoth are openable reward containers priced differently: Packs cost the shared\n`ResourceConsume` type (currency/items/event tokens); Chests are priced purely\nin `CollectionCurrencyCost` (the module's own soft currency, earned from\nduplicates).\n\n```ts\ninterface CollectionPackTypeDefinition {\n PackTypeID?: string;\n Cost?: ResourceConsume; // charged by openPack(); required non-empty or the open is rejected\n BonusRewardSlots?: LootboxRewardSlot[]; // extra non-collectible rewards\n PityRules?: LootboxPityRule[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectibleCount?: number; // how many Collectibles this pack grants (backend default 3)\n GuaranteedMinRarity?: number; // backend default 1\n GuaranteeMaxRarity?: boolean; // backend default false\n RarityWeights?: Record<string, number>; // rarity id (as string \"1\"..\"5\") -> drop weight\n ColorTier?: number; // 1=Green,2=Blue,3=Orange,4=Purple; backend default 1\n}\n\ninterface CollectionChestDefinition {\n CollectionChestID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CollectionCurrencyCost?: number;\n MinCollectibleCount?: number; // backend default 1\n MaxCollectibleCount?: number; // backend default 2\n GuaranteedMinRarity?: number; // backend default 2\n BonusRewardSlots?: LootboxRewardSlot[];\n PityRules?: LootboxPityRule[];\n Tier?: number; // 1=Bronze,2=Silver,3=Gold; backend default 1\n}\n```\n\n`BonusRewardSlots` / `PityRules` reuse the shared reward-slot primitives from\n`_shared/RewardSlotModels.ts` (the same ones the Lootbox module uses — see\n[lootbox-system](../../lootbox-system/SKILL.md) if you need the full\nslot/pool/pity mechanics):\n\n```ts\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only; no Consume side\n Weight?: number;\n AmountRange?: { Min?: number; Max?: number };\n}\ninterface LootboxRewardSlot {\n SlotID?: string;\n MinRolls?: number; // independent rolls to make over Pool\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxPityRule {\n RuleID?: string;\n Threshold?: number; // every Nth open without a qualifying pull, force one\n Pool?: LootboxRewardRoll[];\n}\n```\n\n**Pack roll (`OpenPack`, per Collectible slot, `Collection.cs`\n`RollCollectiblesForPack`):** slot 0 gets the pack's guarantee — if\n`GuaranteeMaxRarity` is true it forces rarity 5, otherwise it weighted-picks\nfrom `RarityWeights` with a floor of `GuaranteedMinRarity`; every other slot\nweighted-picks with a floor of rarity 1. The weighted pick\n(`PickRarityWeighted`) filters `RarityWeights` entries to `rarity >= floor`,\nsums their weights, and rolls uniformly in `[0, total)` via `SecureRandom`; if\nno candidate matches the exact rarity it widens to `>= targetRarity`, then\nfalls back to the full Collectible pool. A picked Collectible that already has\n`HasSpecialVersion: true` additionally has a **flat 5% chance** to be granted\nas its Special version instead of the normal one (`SecureRandom.Next(0,100) <\n5`) — this 5% is hardcoded, not title-configurable.\n\n**Chest roll (`OpenCollectionChest`, `RollCollectiblesForCollectionChest`):**\npicks a random Collectible count uniformly in\n`[MinCollectibleCount, MaxCollectibleCount]`, then for each pick filters the\npool to `Rarity >= GuaranteedMinRarity` (falling back to the full pool if that\nfilter is empty) and picks uniformly at random. Chests never roll Special\nversions.\n\n**Duplicate detection happens per-roll, in-order, within the same open**: the\n\"already owned\" check adds in any Collectibles already granted earlier in the\n_same_ pack/chest before checking the next slot, so pulling the same\nCollectible twice in one 5-slot pack correctly flags the second as a\nduplicate even though neither has hit the database yet.\n\nPity progress is tracked per rule in\n`UserCollectionState.PityCounters: Record<string, UserLootboxPityCounter>`,\nkeyed by **`\"{PackTypeID or CollectionChestID}:{RuleID}\"`** (literal colon\njoin; `CollectionPityHelpers.CounterKey`) — not by `RuleID` alone, so the same\n`RuleID` reused across two pack types tracks independently. The counter type\nis shared with the Lootbox module. Math per open (count is always 1 for\nCollection, unlike Lootbox's multi-open): `totalSteps = counter + 1`,\n`triggers = totalSteps / Threshold` (0 or 1), `newCounter = totalSteps %\nThreshold` — i.e. classic hard-pity, resets to 0 exactly on the open that\nhits the threshold. `OpenPackResponse` / `OpenCollectionChestResponse` both\ncarry `TriggeredPity: unknown[]` — the response signals _that_ pity fired\n(with `RuleID`, always `BoxIndex: 0` for Collection) but doesn't strictly\ntype the payload shape; treat it as informational (e.g. a \"pity!\" toast)\nrather than something to branch business logic on.\n\n---\n\n## Duplicate conversion\n\n```ts\ninterface DuplicateCollectionCurrencyConversion {\n Rarity?: number;\n CollectionCurrencyGranted?: number;\n}\n```\n\nWhen a pack/chest pull is a Collectible the player already owns, instead of\nstacking uselessly it auto-converts into `CollectionCurrencyGranted` (looked\nup by the pulled Collectible's `Rarity` in this list) — that's the\n`CollectionCurrencyEarned` you see on `OpenPackResponse` /\n`OpenCollectionChestResponse`, and it's what funds `openCollectionChest`. This\nis why chests exist: a way to spend \"wasted\" duplicate pulls on guaranteed\nprogress instead.\n\n**Fallback when no rule matches the rarity:** `GetCollectionCurrencyForDuplicate`\nfalls back to `rarity` itself (i.e. a rarity-3 duplicate grants 3 Collection\nCurrency) if `DuplicateConversions` has no entry for that rarity — so an\nincomplete conversion table doesn't silently grant 0, but also won't match\nwhatever curve you intended. Configure every rarity 1-5 explicitly rather than\nrelying on the fallback.\n\nA duplicate normally caps ownership at effectively 1 (the doc comment on\n`UserCollectionState.OwnedCollectibles` calls `>= 2` a rare/transient state —\nconversion is meant to be immediate) but the code path that increments it is\nplain `Dictionary` arithmetic in memory before the Mongo patch, so treat\n`OwnedCollectibles[id]` as \"0, 1, or rarely-briefly more,\" not a strict\nboolean.\n\n---\n\n## SpecialTradeEvents\n\n```ts\ninterface SpecialTradeEventDefinition {\n SpecialTradeEventID?: string;\n StartUtc?: string;\n EndUtc?: string;\n AllowedSpecialCollectibleIDs?: string[];\n SpecialTradeEventDailyTradeLimit?: number;\n}\n```\n\nSpecial-version Collectibles (`HasSpecialVersion: true` on the base\nCollectible, traded with `collectibleIsSpecial: true`) can only move via\n`sendTradeOffer` while an active event's window covers `now` **and** lists\nthat Collectible in `AllowedSpecialCollectibleIDs`. Outside any such window,\noffering a Special is rejected server-side. The event also carries its own\ndaily limit distinct from the title-wide `DailyTradeLimit`.\n\n---\n\n## Player state: UserCollectionState\n\nReturned by `getUserState()`; cached at `client.data.user.state?.Collection`.\n**Loosely typed** (`z.object({}).passthrough()` cast to the interface) —\nunlike the config side, this is not field-validated, so treat it as\nbest-effort and read defensively.\n\n```ts\ninterface UserCollectionState {\n CollectionID?: string;\n SeasonVersion?: number;\n CollectionCurrencyBalance?: number;\n TotalCollectionCurrencyEarned?: number;\n OwnedCollectibles?: Record<string, number>; // CollectibleID -> count owned\n OwnedSpecialCollectibles?: Record<string, number>;\n ClaimedSetRewards?: string[]; // SetIDs already claimed\n IsCollectionCompleted?: boolean;\n GrandPrizeClaimed?: boolean;\n DailyTradesSent?: number;\n DailyTradesResetDate?: string;\n PendingTradeOfferIDs?: string[];\n PityCounters?: Record<string, UserLootboxPityCounter>; // key = \"{PackTypeID|CollectionChestID}:{RuleID}\"\n}\n```\n\n`SeasonVersion` defaults to `0` when the collection isn't season-linked.\n`PityCounters` (like `OwnedCollectibles`/`OwnedSpecialCollectibles`) is a plain\ndictionary that only gains a key the first time that pool triggers — treat a\nmissing key as counter `0`, not an error.\n\n---\n\n## Season-linked wipe\n\nA `CollectionDefinition` may set `SeasonChainID` to bind itself to a season\nchain (`Season` module). Every Collection action re-derives the \"current\"\n`(activeCollectionID, SeasonVersion)` pair on each call\n(`EnsureCollectionWipedIfNeededAsync` in `Collection.cs`):\n\n- If any season chain has a `LinkedCollectionID` whose window is currently\n active (not paused), that collection is the active one, and\n `SeasonVersion = CycleIndex * 1000 + SeasonOrder` of that window.\n- Otherwise, if the title has no season-linked collection, the **first**\n collection in config-declaration order (`Collections.Keys.First()`) is used\n with `SeasonVersion = 0`.\n\nIf the player's stored `UserCollectionState.CollectionID` /\n`SeasonVersion` doesn't match, the **entire** Collection state is wiped and\nreplaced with a fresh zeroed one (new `CollectionID`, `SeasonVersion`, empty\n`OwnedCollectibles`/`OwnedSpecialCollectibles`/`ClaimedSetRewards`/\n`PendingTradeOfferIDs`, zeroed currency, `IsCollectionCompleted`/\n`GrandPrizeClaimed` reset to `false`) — this happens **lazily**, on the very\nnext Collection call the player makes after the season rolls over, not on a\nschedule. There is no dedicated wipe event; the wiped state is simply what\nthe next `getUserState()` (or any other Collection call) returns. Design\naround this: don't assume a cached `Collection` state survives across a\nsession gap without a fresh fetch, and don't build UI that depends on\n`OwnedCollectibles` persisting across a season boundary for a season-linked\ncollection.\n\n---\n\n## Trade offers\n\n```ts\ninterface CollectionTradeOfferDocument {\n OfferID: string;\n TitleID?: string;\n CollectionID?: string;\n SenderUserID?: string;\n SenderPublicData?: UserPublicDataModel; // sender's public profile snapshot\n OfferedCollectibleID?: string;\n OfferedCollectibleIsSpecial?: boolean;\n ReceiverUserID?: string;\n RequestedCollectibleID?: string; // absent = open/gift offer, no ask-back\n RequestedCollectibleIsSpecial?: boolean;\n Status?: \"Pending\" | \"Accepted\" | \"Declined\" | \"Cancelled\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RespondedAtUtc?: string;\n DeclineReason?: string;\n IsSpecialTradeEvent?: boolean;\n SpecialTradeEventID?: string;\n}\n```\n\n**Preconditions checked by `sendTradeOffer` (`Collection.cs` `SendTradeOffer`),\nin order:** `ReceiverUserID` can't equal your own `UserID` (\"Cannot trade with\nyourself\"); the receiver must be in your **Social.Accepted friends list**\n(\"Receiver must be in your friends list\" — see\n[social-system](../../social-system/SKILL.md)); if `CollectibleIsSpecial` a\nmatching active `SpecialTradeEventDefinition` must exist (\"Special\nCollectibles can only be traded during an active SpecialTradeEvent\"); the\neffective daily limit (event-specific limit if trading a Special during its\nevent window, else the title's `DailyTradeLimit`, lazily reset at UTC\nmidnight) must not already be hit (\"Daily trade limit reached (N/day)\"); you\nmust hold enough of the offered Collectible — **`>= 2`** for a normal\nCollectible (you keep one, offer the spare) or **`>= 1`** for a Special (the\nwhole thing moves, no spare kept back); and the receiver's\n`PendingTradeOfferIDs` must have fewer than **10** entries\n(`MaxPendingIncomingOffers`) — \"Receiver has too many pending trade offers.\"\nThe same `>= 2` (normal) / `>= 1` (Special) ownership check re-runs against\nthe **receiver's** balance for `RequestedCollectibleID` at `acceptTradeOffer`\ntime, since their holdings may have changed since the offer was sent.\n\nLifecycle: `sendTradeOffer` creates a document with `Status: \"Pending\"`,\n`CreatedAtUtc: now`, and `ExpiresAtUtc: now + 168h` (7 days —\n`TradeOfferExpirationHours` in `Collection.cs`, not title-configurable). The\nreceiver calls `getIncomingTradeOffers` to see it, then either\n`acceptTradeOffer` (→ `Status: \"Accepted\"`, both Collectibles swap owners; also\nrejected if `ExpiresAtUtc <= now`, \"Offer has expired\") or `declineTradeOffer`\n(→ `Status: \"Declined\"`). The sender can `cancelTradeOffer` any offer still\n`\"Pending\"` (→ `Status: \"Cancelled\"`).\n\n**`\"Expired\"` is a declared `Status` value the backend never actually\nwrites** — there is no sweep job that flips stale offers to `Expired`.\n`getIncomingTradeOffers` filters server-side to `Status == \"Pending\" &&\nExpiresAtUtc > now`, so an expired incoming offer just silently drops out of\nthat list (it doesn't surface with a distinguishable status). `getMyTradeOffers`\n(outgoing) has **no such filter** — it returns everything you've ever sent for\nthat collection (newest 20), so a lapsed offer you sent still reads\n`Status: \"Pending\"` with an `ExpiresAtUtc` in the past; compare `ExpiresAtUtc`\nagainst the current time yourself if you need to grey it out in a \"my offers\"\nlist. None of the four trade actions mutate `client.data.user` directly (no\n`Resources`, no `Collection` cache patch) — re-fetch `getUserState()` / the\noffer lists to observe the effect.\n\n---\n\n## Responses\n\n```ts\ninterface GrantedCollectible {\n CollectibleID: string;\n Rarity?: number;\n IsSpecial?: boolean;\n IsDuplicate?: boolean;\n CollectionCurrencyConverted?: number; // set when IsDuplicate\n}\n\ninterface OpenPackResponse {\n GrantedCollectibles?: GrantedCollectible[]; // full pull list (incl. duplicates)\n DuplicateCollectibles?: GrantedCollectible[]; // subset that were duplicates\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n NewlyCompletedSetIDs?: string[];\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // pack Cost debit (+ BonusRewardSlots grants)\n TriggeredPity?: unknown[];\n}\n\ninterface OpenCollectionChestResponse {\n GrantedCollectibles?: GrantedCollectible[];\n DuplicateCollectibles?: GrantedCollectible[];\n CollectionCurrencyEarned?: number;\n NewCollectionCurrencyBalance?: number;\n Resources?: ResourceOperation; // CollectionCurrencyCost debit (+ bonus grants)\n TriggeredPity?: unknown[];\n}\n\ninterface UseCollectibleJokerResponse {\n GrantedCollectibleID?: string;\n NewlyCompletedSetID?: string;\n CollectionJustCompleted?: boolean;\n Resources?: ResourceOperation; // Joker item consumed\n}\n\ninterface ClaimSetRewardResponse {\n SetID: string;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimGrandPrizeResponse {\n Resources?: ResourceOperation;\n}\n\ninterface SendTradeOfferResponse {\n OfferID: string;\n ExpiresAtUtc?: string;\n Resources?: ResourceOperation; // usually absent; trading has no inherent cost\n}\n\ninterface AcceptTradeOfferResponse {\n OfferID: string;\n ReceivedCollectibleID?: string;\n ReceivedIsSpecial?: boolean;\n SentCollectibleID?: string;\n SentCollectibleIsSpecial?: boolean;\n Transfer?: unknown; // server-internal transfer record, not strictly typed\n}\n```\n\n`ClaimSetRewardsBatchResponse` is `BatchItemResult<ClaimSetRewardResponse>[]`\n— see the shared `BatchItemResult<T>` shape\n(`_shared/BatchModels.ts`): `{ Id, Success, Error?, Data? }` per item, one\natomic charge across the whole batch.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|