@idosgames/mcp 0.1.7 → 0.1.8
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/dist/cli.js +5 -5
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +16 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/authentication.json +3 -3
- package/registry/skills/collection-system.json +1 -1
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/game-loop-system.json +2 -2
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-title-bootstrap.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/social-system.json +1 -1
- package/registry/skills/user-profile.json +2 -2
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "user-profile",
|
|
3
3
|
"description": "Work with the player's own account/session state in the iDosGames TS SDK (@idosgames/core) via client.user (UserService): bootstrap the whole per-player cache at login (ClientState — title config + every module's user state), load the raw inventory snapshot (currencies, items, unstackable instances), read usage-time / session stats, change the username, and delete the account. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants login/session bootstrapping, a profile or account screen, usage-time / playtime tracking, username changes, account deletion, raw inventory reads, or otherwise touches client.user, UserService, ClientState, UserState, UserInventoryState, UsageTimeStats, or client.data.user.state — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\n\n### `ClientState.Title` is often absent on the wire — and that is not an error\n\nThe title config is identical for every player and changes rarely, so the SDK\ncaches it across sessions. Each response carries `ClientState.TitleConfigVersion`;\nthe SDK stores it next to the config and sends it back as\n`KnownTitleConfigVersion` on the next call. When it still matches, the backend\n**omits the `Title` key entirely** and only the player state travels.\n\n`UserService` resolves this for you — it re-fills `result.data.Title` from local\nstorage before applying it, so `client.data.config.titlePublicConfiguration` is\nalways populated and nothing in game code changes. What you must **not** do is\nread `Title` straight off a raw envelope you captured yourself (a network log, a\nhand-rolled fetch) and conclude the config is gone.\n\nStorage is `localStorage` with a memory fallback; pass `configStorage` to\n`createIDosGamesClient` to supply your own (React Native, a native shell). Any\nstorage failure degrades to the previous behaviour — a full config download —\nnever to a broken launch. The cached config is public title data, not player\ndata, so it deliberately survives logout.\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// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ 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).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
|
|
4
|
+
"content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\n\n### `ClientState.Title` is often absent on the wire — and that is not an error\n\nThe title config is identical for every player and changes rarely, so the SDK\ncaches it across sessions. Each response carries `ClientState.TitleConfigVersion`;\nthe SDK stores it next to the config and sends it back as\n`KnownTitleConfigVersion` on the next call. When it still matches, the backend\n**omits the `Title` key entirely** and only the player state travels.\n\n`UserService` resolves this for you — it re-fills `result.data.Title` from local\nstorage before applying it, so `client.data.config.titlePublicConfiguration` is\nalways populated and nothing in game code changes. What you must **not** do is\nread `Title` straight off a raw envelope you captured yourself (a network log, a\nhand-rolled fetch) and conclude the config is gone.\n\nStorage is `localStorage` with a memory fallback; pass `configStorage` to\n`createIDosGamesClient` to supply your own (React Native, a native shell). Any\nstorage failure degrades to the previous behaviour — a full config download —\nnever to a broken launch. The cached config is public title data, not player\ndata, so it deliberately survives logout.\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// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ 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).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n⚠ **Per-day history is a WINDOW, not everything.** The server folds days older\nthan the window (400 by default) into `Usage.Monthly`, and months of finished\nyears into `Usage.Yearly`; the fold is irreversible. `UsageTimeStats.History`\nis that same windowed `Daily` map. So never sum per-day records for a lifetime\nor year-long figure — `Total`/`TotalSessions` are kept separately and are\nexact. See [references/data-model.md](references/data-model.md#userusagestate).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# User data model — reference\n\nFull shape of `ClientState`, `UserState`, and the other types owned by\n`packages/core/src/models/user/UserModels.ts`. Field names are PascalCase\n(straight from the backend JSON); schemas keep `.passthrough()` so a field the\nbackend adds later still round-trips. `UserState` is the single object every\nfeature module's cache hangs off of — this doc covers the trunk; each\nmodule's own skill/reference covers its own branch in depth.\n\n## Contents\n\n- [ClientState](#clientstate) — what `getClientState()` / `getClientStateExcept()` return\n- [UserState](#userstate) — the per-player state tree, `client.data.user.state`\n- [UserInventoryState](#userinventorystate) — what `getUserInventory()` returns\n- [UsageTimeStats](#usagetimestats) — what `getUsageTime()` returns\n- [UserUsageState](#userusagestate) — the persisted `UserState.Usage` shape\n- [Requests](#requests) — `UserRequest` fields\n- [Backend rules](#backend-rules-transcribed-from-usercs) — username validation, delete-account cascade\n\n---\n\n## ClientState\n\n```ts\ninterface ClientState {\n Title?: TitlePublicConfigurationModel | null; // -> client.data.config.titlePublicConfiguration\n User?: UserState | null; // -> client.data.user.state\n [k: string]: unknown;\n}\n```\n\nThe wire schema (`zClientState`) only validates that `Title`/`User` are present\n(or nullish) at the top level — it does not deep-validate their contents. This\nis intentional: `ClientState` carries the _entire_ title config and the\n_entire_ per-player state in one payload, and field-level schemas live with\neach owning module instead of being re-validated here.\n\n---\n\n## UserState\n\n```ts\ninterface UserState {\n UserID?: string;\n InventoryV2?: UserInventoryState;\n EventToken?: UserEventTokensState;\n PublicData?: UserPublicDataModel | null;\n EconomyTuning?: PlayerEconomyTuningState | null;\n Usage?: UserUsageState | null;\n\n // One key per feature module — each owned and written by that module's own\n // service, not by UserService. Present here only because they all live on\n // the same shared state object.\n Store?: UserStoreState | null;\n Lootbox?: UserLootboxState | null;\n Reward?: UserRewardState | null;\n Quest?: UserQuestState | null;\n Leaderboard?: UserLeaderboardsState | null;\n Season?: UserSeasonsState | null;\n Premium?: UserPremiumState | null;\n Character?: UserCharactersState | null;\n Match?: UserMatchState | null;\n Collection?: UserCollectionState | null;\n CoopEvent?: UserCoopEventState | null;\n DealOffer?: UserDealOffersState | null;\n Referral?: UserReferralState | null;\n Social?: UserSocialState | null;\n TimedBoost?: UserTimedBoostsState | null; // NOT populated by ClientState — see note below\n CustomData?: UserCustomDataState | null;\n GameLoop?: UserGameLoopsState | null;\n Blockchain?: UserBlockchainState | null;\n Marketplace?: UserMarketplaceState | null; // NOT populated by ClientState — see note below\n\n [k: string]: unknown; // future/unmodeled fields round-trip via passthrough\n}\n```\n\nFields **actually owned by UserService** (written by its own cache calls, not\nby another module): `InventoryV2` (via `getUserInventory()`, also kept current\nby other modules' resource operations), `EventToken` (via `getEventTokens()`),\nand the whole tree wholesale via `getClientState()`/`getClientStateExcept()`.\n`PublicData`, `EconomyTuning`, and `Usage` arrive as part of that wholesale\nfetch — there's no dedicated \"get just PublicData\" call.\n\n### `TimedBoost` and `Marketplace` are envelope-only — ClientState never fills them\n\nThe TS type declares `TimedBoost` and `Marketplace` because they're real keys\non the backend's `UserDataDocument` (`IDosGamesSDK/API/Client/v2/User/Models/UserDataDocument.cs:69,89`),\nbut the backend's `ClientState.User` builder does not copy either one. The\nresponse's `UserState` class\n(`IDosGamesSDK/API/Client/v2/User/Models/ClientState.cs:22-48`) has no\n`TimedBoost`/`Marketplace` property at all, and in\n`IDosGamesSDK/API/Client/v2/User/User.cs` neither name appears in\n`_defaultUserFields` (lines 279-304) nor in `_userStateFieldCopiers` (lines\n310-334) — every other module listed above (including `Match`, `Blockchain`,\n`Reward`, `Character`, which read as later additions) does appear in both.\nPractically: `getClientState()` / `getClientStateExcept()` will never\npopulate `state.TimedBoost` or `state.Marketplace`, regardless of\n`Fields`/`ExcludeFields` (those two names aren't in the copier table to\nselect in the first place).\n\nEach module owns and fetches its own per-player state instead, and the TS\ncache (`packages/core/src/cache/UserData.ts`) only ever writes these two keys\nfrom that module's own apply method:\n\n- `TimedBoost` ← `client.timedBoost.getActiveTimedBoosts()` →\n `UserData.applyTimedBoost({ Active })` — see the timed-boost-system skill.\n- `Marketplace` ← `client.marketplace.getMyState()` →\n `UserData.applyMarketplaceState(result.data.Limits)` — see the\n marketplace-system skill.\n\n### PublicData — denormalized public profile\n\n```ts\ninterface UserPublicDataModel {\n Username?: string | null;\n Country?: string | null;\n AvatarUrl?: string | null;\n Premium?: boolean | null;\n Level?: number | null;\n Power?: number | null;\n BoardRank?: number | null;\n [k: string]: unknown;\n}\n```\n\nThis same shape is embedded (as a snapshot, not a live reference) in several\nother modules' responses: leaderboard entries (`PublicProfile`), collection\ntrade offers (`SenderPublicData`), social timeline events (`ActorProfile`),\nGameLoop PvP/raid opponents (`TargetPublicData`/`PublicData`), and coop-event\ngroup members (`PublicData`). Treat any of those as a point-in-time copy, not\nas this player's live state.\n\n### EconomyTuning — personal balance multiplier\n\n```ts\ninterface PlayerEconomyTuningState {\n Segment: string;\n RewardMultiplier: number;\n CostMultiplier: number;\n MaxRollMultiplierOverride: number;\n ExpiresAtUtc: string;\n Version: number;\n}\n```\n\nServer-computed A/B or player-segment tuning (e.g. a new-player reward boost).\nIntended for server-side use in pricing/reward math; present on `ClientState.User`\nmainly so the client can display it (e.g. \"2x rewards active\") if the title\nchooses to.\n\n---\n\n## UserInventoryState\n\nReturned by `getUserInventory()`; cached at `client.data.user.state.InventoryV2`.\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable item totals, key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface UserVirtualCurrencyState {\n Amount: number;\n Recharge?: UserRechargeState | null; // energy-style regen, if configured\n Daily?: UserDailyCounters | null; // daily earn/spend caps tracking\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n\ninterface UserRechargeState {\n LastRechargeAt?: string;\n PendingSeconds?: number;\n}\n\ninterface UserDailyCounters {\n PeriodStartUtc: string;\n Earned: number;\n Spent: number;\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — use a decimal library, not native float\n Frozen: string; // decimal string\n DepositAddresses?: Record<string, UserDepositAddress>;\n Compliance?: UserCryptoComplianceCounters;\n CreatedAt?: string;\n UpdatedAt?: string;\n [k: string]: unknown;\n}\n\ninterface UserDepositAddress {\n Address: string;\n Memo?: string | null;\n AssignedAt: string;\n}\n\n/** AML spend-compliance window counters, checked against CryptoCurrencyDefinition.Limits. */\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc: string;\n DailyWithdrawnUsd: string; // decimal string\n MonthlyPeriodStartUtc: string;\n MonthlyWithdrawnUsd: string; // decimal string\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number;\n RemainingUses?: number;\n Level?: number; // per-instance upgrade level (Item module's UpgradeLevel)\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for equip state\n CustomData?: string | null;\n}\n\n/** Daily-window counter for one conversion pair (\"{srcType}:{srcID}->{tgtType}:{tgtID}\"). */\ninterface ConversionDailyCounter {\n PeriodStartUtc: string;\n AmountToday: string; // decimal string\n}\n```\n\n`EquippedSlot` on an unstackable item instance is the authoritative record of\nwhat's equipped where — the Character module's per-character `Equipment` map\nis a convenience cache view kept in sync with this by the SDK.\n\nThe wire schema for `UserInventoryState` (`zUserInventoryState`) is currently\nlenient (`z.object({}).passthrough()` cast to the type) — Phase 1 of the\nstrict-typing port validated `ClientState`'s top-level shape only; inventory's\nfield-level schema is expected to tighten in a later phase. Don't rely on\nruntime validation catching a malformed inventory field today; the TS types\nare still accurate for what the backend currently sends.\n\n---\n\n## UsageTimeStats\n\nReturned by `getUsageTime()` (a fetch/response type — distinct from the\npersisted `UserUsageState` below).\n\n```ts\ninterface UsageTimeStats {\n Today: number;\n Yesterday: number;\n CurrentWeek: number;\n CurrentMonth: number;\n Total: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt?: string | null;\n LongestSessionEverSeconds?: number | null;\n CurrentWeekActiveHoursMask?: number | null;\n CurrentMonthActiveHoursMask?: number | null;\n ReactivationCount?: number | null;\n History?: Record<string, unknown> | null;\n [k: string]: unknown;\n}\n```\n\nAll duration fields are seconds. `*ActiveHoursMask` is a bitmask (which hours\nof the day had activity) — decode per-bit if you need an activity heatmap.\n\n## UserUsageState\n\nThe persisted shape on `UserState.Usage` (`UserDataDocument.Usage` server-side)\n— foreground-activity seconds only, aggregated per day.\n\n```ts\ninterface UserUsageState {\n TotalSeconds: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt: string;\n Reactivations?: UsageReactivationEvent[];\n Daily?: Record<string, DailyUsageRecord>; // key = \"ddMMyyyy\" (UTC)\n}\n\n/** One recorded reactivation — a return after a silence gap of 7+ days. */\ninterface UsageReactivationEvent {\n ReactivatedAt: string;\n DaysSinceLastActive: number;\n}\n\ninterface DailyUsageRecord {\n Seconds: number;\n Sessions: number;\n LongestSessionSeconds: number;\n ActiveHoursMask: number;\n LastActiveAt: string;\n}\n```\n\n`UsageTimeStats` (from `getUsageTime()`) and `UserUsageState` (on `UserState.Usage`)\noverlap in intent but are separate types with separate field names — don't\nconflate `Today`/`CurrentWeek`/`CurrentMonth` (rolling windows in the fetch\nresponse) with `Daily` (a raw per-day map, keyed by date, in the persisted\nstate).\n\n---\n\n## Requests\n\n```ts\ninterface UserRequest extends BaseRequest {\n IsNewSession?: boolean; // addUsageTime\n SessionDurationSeconds?: number; // addUsageTime\n Fields?: string[]; // reserved — not currently populated by UserService\n TitleFields?: string[]; // reserved — not currently populated by UserService\n ExcludeFields?: string[]; // getClientStateExcept — User.* keys to preserve from cache\n ExcludeTitleFields?: string[]; // getClientStateExcept — Title.* keys to preserve from cache\n}\n```\n\n`BaseRequest` supplies `UserID`, `ClientSessionTicket`, `BuildKey`,\n`WebAppLink`, and a fresh `RelatedEntityID` per call (all filled in\nautomatically by `UserService`'s internal `baseRequest()` — you never build a\n`UserRequest` by hand). `Fields`/`TitleFields` exist on the request type but\n`UserService`'s methods never set them today — only `ExcludeFields` /\n`ExcludeTitleFields` are wired up, exclusively by `getClientStateExcept`.\n`BaseRequest` also carries `Username` and `UsageTime` (used by\n`changeUsername`/`addUsageTime` respectively) — those two live on the shared\n`BaseRequest` interface, not on `UserRequest` itself, since other modules'\nrequests (e.g. registration) also set `Username`.\n\n---\n\n## Backend rules (transcribed from `User.cs`)\n\n### Username validation (`ChangeUsername`)\n\nSource: `IDosGamesSDK/API/Client/v2/User/User.cs:245-267`.\n\n1. The server trims the incoming `Username` (`args.Username?.Trim()`).\n2. Rejects with the literal string `\"INVALID_USERNAME\"` if the trimmed value\n is null/whitespace, or its length is `< 3` or `> 24` characters. There is\n no character-set restriction beyond length — any non-whitespace string in\n range is accepted verbatim (no profanity filter, no uniqueness check).\n3. On success it does a single field patch\n (`Builders<UserDataDocument>.Update.Set(u => u.PublicData.Username, desired)`)\n directly via `ResourceService.PatchUserDataDocumentByIDAsync` — it does\n **not** go through `ResourceService`'s resource-operation/OCC path, so\n there's no idempotency-by-reason key for this call; a resend just\n overwrites the name again with the same (or a new) value.\n4. The response (`ChangeUsernameResponse.Username`) is the exact trimmed\n string that was stored — never a suffixed/deduplicated variant, since\n there's no uniqueness constraint to disambiguate against.\n\n### Delete-account cascade (`DeleteUserAccount`)\n\nSource: `IGSService.DeleteUserAccount` (`IDosGamesSDK/Core/CoreScripts/IGServer/Service/IGSService.cs:1275-1278`)\n→ `DataBaseService.DeleteAllDataUserAsync` (`IDosGamesSDK/Core/CoreScripts/IGServer/DataBase/DataBaseService.cs:498-519`).\n\n- The call is a single MongoDB `DeleteOneAsync` against the `USER_TYPE`\n collection, filtered by `UserID` **and** `TitleID` together. It deletes\n exactly one `UserDataDocument` — this title's per-player document only.\n- There is no fan-out to other collections in this code path: no explicit\n cleanup of Marketplace escrow/listings, leaderboard documents, coop-group\n membership, blockchain deposit-address records, or the cross-title\n `PlatformUserDocument`/`LinkedTitleAccounts` map. If a player is linked to a\n platform account, deleting one title's `UserDataDocument` does not unlink\n or delete the platform-level document or any other title's data.\n- The operation returns a plain `bool` (`DeletedCount > 0`); the endpoint\n turns a `false` into `OperationResult.Fail(\"Failed to Delete User Account\")`\n (`User.cs:269-275`). There is no soft-delete/undo flag anywhere in this\n path — a successful call is a permanent, synchronous hard delete of the row.\n"
|
|
8
|
+
"content": "# User data model — reference\n\nFull shape of `ClientState`, `UserState`, and the other types owned by\n`packages/core/src/models/user/UserModels.ts`. Field names are PascalCase\n(straight from the backend JSON); schemas keep `.passthrough()` so a field the\nbackend adds later still round-trips. `UserState` is the single object every\nfeature module's cache hangs off of — this doc covers the trunk; each\nmodule's own skill/reference covers its own branch in depth.\n\n## Contents\n\n- [ClientState](#clientstate) — what `getClientState()` / `getClientStateExcept()` return\n- [UserState](#userstate) — the per-player state tree, `client.data.user.state`\n- [UserInventoryState](#userinventorystate) — what `getUserInventory()` returns\n- [UsageTimeStats](#usagetimestats) — what `getUsageTime()` returns\n- [UserUsageState](#userusagestate) — the persisted `UserState.Usage` shape\n- [Requests](#requests) — `UserRequest` fields\n- [Backend rules](#backend-rules-transcribed-from-usercs) — username validation, delete-account cascade\n\n---\n\n## ClientState\n\n```ts\ninterface ClientState {\n Title?: TitlePublicConfigurationModel | null; // -> client.data.config.titlePublicConfiguration\n User?: UserState | null; // -> client.data.user.state\n [k: string]: unknown;\n}\n```\n\nThe wire schema (`zClientState`) only validates that `Title`/`User` are present\n(or nullish) at the top level — it does not deep-validate their contents. This\nis intentional: `ClientState` carries the _entire_ title config and the\n_entire_ per-player state in one payload, and field-level schemas live with\neach owning module instead of being re-validated here.\n\n---\n\n## UserState\n\n```ts\ninterface UserState {\n UserID?: string;\n InventoryV2?: UserInventoryState;\n EventToken?: UserEventTokensState;\n PublicData?: UserPublicDataModel | null;\n EconomyTuning?: PlayerEconomyTuningState | null;\n Usage?: UserUsageState | null;\n\n // One key per feature module — each owned and written by that module's own\n // service, not by UserService. Present here only because they all live on\n // the same shared state object.\n Store?: UserStoreState | null;\n Lootbox?: UserLootboxState | null;\n Reward?: UserRewardState | null;\n Quest?: UserQuestState | null;\n Leaderboard?: UserLeaderboardsState | null;\n Season?: UserSeasonsState | null;\n Premium?: UserPremiumState | null;\n Character?: UserCharactersState | null;\n Match?: UserMatchState | null;\n Collection?: UserCollectionState | null;\n CoopEvent?: UserCoopEventState | null;\n DealOffer?: UserDealOffersState | null;\n Referral?: UserReferralState | null;\n Social?: UserSocialState | null;\n TimedBoost?: UserTimedBoostsState | null; // NOT populated by ClientState — see note below\n CustomData?: UserCustomDataState | null;\n GameLoop?: UserGameLoopsState | null;\n Blockchain?: UserBlockchainState | null;\n Marketplace?: UserMarketplaceState | null; // NOT populated by ClientState — see note below\n\n [k: string]: unknown; // future/unmodeled fields round-trip via passthrough\n}\n```\n\nFields **actually owned by UserService** (written by its own cache calls, not\nby another module): `InventoryV2` (via `getUserInventory()`, also kept current\nby other modules' resource operations), `EventToken` (via `getEventTokens()`),\nand the whole tree wholesale via `getClientState()`/`getClientStateExcept()`.\n`PublicData`, `EconomyTuning`, and `Usage` arrive as part of that wholesale\nfetch — there's no dedicated \"get just PublicData\" call.\n\n### `TimedBoost` and `Marketplace` are envelope-only — ClientState never fills them\n\nThe TS type declares `TimedBoost` and `Marketplace` because they're real keys\non the backend's `UserDataDocument` (`IDosGamesSDK/API/Client/v2/User/Models/UserDataDocument.cs:69,89`),\nbut the backend's `ClientState.User` builder does not copy either one. The\nresponse's `UserState` class\n(`IDosGamesSDK/API/Client/v2/User/Models/ClientState.cs:22-48`) has no\n`TimedBoost`/`Marketplace` property at all, and in\n`IDosGamesSDK/API/Client/v2/User/User.cs` neither name appears in\n`_defaultUserFields` (lines 279-304) nor in `_userStateFieldCopiers` (lines\n310-334) — every other module listed above (including `Match`, `Blockchain`,\n`Reward`, `Character`, which read as later additions) does appear in both.\nPractically: `getClientState()` / `getClientStateExcept()` will never\npopulate `state.TimedBoost` or `state.Marketplace`, regardless of\n`Fields`/`ExcludeFields` (those two names aren't in the copier table to\nselect in the first place).\n\nEach module owns and fetches its own per-player state instead, and the TS\ncache (`packages/core/src/cache/UserData.ts`) only ever writes these two keys\nfrom that module's own apply method:\n\n- `TimedBoost` ← `client.timedBoost.getActiveTimedBoosts()` →\n `UserData.applyTimedBoost({ Active })` — see the timed-boost-system skill.\n- `Marketplace` ← `client.marketplace.getMyState()` →\n `UserData.applyMarketplaceState(result.data.Limits)` — see the\n marketplace-system skill.\n\n### PublicData — denormalized public profile\n\n```ts\ninterface UserPublicDataModel {\n Username?: string | null;\n Country?: string | null;\n AvatarUrl?: string | null;\n Premium?: boolean | null;\n Level?: number | null;\n Power?: number | null;\n BoardRank?: number | null;\n [k: string]: unknown;\n}\n```\n\nThis same shape is embedded (as a snapshot, not a live reference) in several\nother modules' responses: leaderboard entries (`PublicProfile`), collection\ntrade offers (`SenderPublicData`), social timeline events (`ActorProfile`),\nGameLoop PvP/raid opponents (`TargetPublicData`/`PublicData`), and coop-event\ngroup members (`PublicData`). Treat any of those as a point-in-time copy, not\nas this player's live state.\n\n### EconomyTuning — personal balance multiplier\n\n```ts\ninterface PlayerEconomyTuningState {\n Segment: string;\n RewardMultiplier: number;\n CostMultiplier: number;\n MaxRollMultiplierOverride: number;\n ExpiresAtUtc: string;\n Version: number;\n}\n```\n\nServer-computed A/B or player-segment tuning (e.g. a new-player reward boost).\nIntended for server-side use in pricing/reward math; present on `ClientState.User`\nmainly so the client can display it (e.g. \"2x rewards active\") if the title\nchooses to.\n\n---\n\n## UserInventoryState\n\nReturned by `getUserInventory()`; cached at `client.data.user.state.InventoryV2`.\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable item totals, key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface UserVirtualCurrencyState {\n Amount: number;\n Recharge?: UserRechargeState | null; // energy-style regen, if configured\n Daily?: UserDailyCounters | null; // daily earn/spend caps tracking\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n\ninterface UserRechargeState {\n LastRechargeAt?: string; // reference point of the next accrual (ISO, UTC)\n PendingSeconds?: number; // carried time that did not add up to a FULL period (< Period)\n}\n\n// Recharge is credited in BATCHES: every full `Recharge.Period` the player gets\n// `Recharge.Rate` units at once (see the currency-system skill). The server materializes\n// it lazily — on a state read and on any operation with that currency — so the `Amount`\n// you receive is already up to date; there is no background job and no push.\n//\n// Countdown to the NEXT BATCH (not to the next single unit):\n// const elapsed = (Date.now() - Date.parse(LastRechargeAt)) / 1000 + PendingSeconds;\n// const secondsToNextBatch = Math.max(0, Period - elapsed);\n// At or above `Recharge.Max` there is no countdown: nothing accrues until the player spends.\n\ninterface UserDailyCounters {\n PeriodStartUtc: string;\n Earned: number;\n Spent: number;\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — use a decimal library, not native float\n Frozen: string; // decimal string\n DepositAddresses?: Record<string, UserDepositAddress>;\n Compliance?: UserCryptoComplianceCounters;\n CreatedAt?: string;\n UpdatedAt?: string;\n [k: string]: unknown;\n}\n\ninterface UserDepositAddress {\n Address: string;\n Memo?: string | null;\n AssignedAt: string;\n}\n\n/** AML spend-compliance window counters, checked against CryptoCurrencyDefinition.Limits. */\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc: string;\n DailyWithdrawnUsd: string; // decimal string\n MonthlyPeriodStartUtc: string;\n MonthlyWithdrawnUsd: string; // decimal string\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number;\n RemainingUses?: number;\n Level?: number; // per-instance upgrade level (Item module's UpgradeLevel)\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for equip state\n CustomData?: string | null;\n}\n\n/** Daily-window counter for one conversion pair (\"{srcType}:{srcID}->{tgtType}:{tgtID}\"). */\ninterface ConversionDailyCounter {\n PeriodStartUtc: string;\n AmountToday: string; // decimal string\n}\n```\n\n`EquippedSlot` on an unstackable item instance is the authoritative record of\nwhat's equipped where — the Character module's per-character `Equipment` map\nis a convenience cache view kept in sync with this by the SDK.\n\nThe wire schema for `UserInventoryState` (`zUserInventoryState`) is currently\nlenient (`z.object({}).passthrough()` cast to the type) — Phase 1 of the\nstrict-typing port validated `ClientState`'s top-level shape only; inventory's\nfield-level schema is expected to tighten in a later phase. Don't rely on\nruntime validation catching a malformed inventory field today; the TS types\nare still accurate for what the backend currently sends.\n\n---\n\n## UsageTimeStats\n\nReturned by `getUsageTime()` (a fetch/response type — distinct from the\npersisted `UserUsageState` below).\n\n```ts\ninterface UsageTimeStats {\n Today: number;\n Yesterday: number;\n CurrentWeek: number;\n CurrentMonth: number;\n Total: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt?: string | null;\n LongestSessionEverSeconds?: number | null;\n CurrentWeekActiveHoursMask?: number | null;\n CurrentMonthActiveHoursMask?: number | null;\n ReactivationCount?: number | null;\n History?: Record<string, unknown> | null; // = Usage.Daily, i.e. the recent WINDOW — see below\n [k: string]: unknown;\n}\n```\n\nAll duration fields are seconds. `*ActiveHoursMask` is a bitmask (which hours\nof the day had activity) — decode per-bit if you need an activity heatmap.\n\n⚠ `History` is `Usage.Daily`, so it only covers the recent window (see\n`UserUsageState` below) — not the player's whole history. The folded\nmonthly/yearly totals are **not** in this response; read them from\n`UserState.Usage`. `Total`/`TotalSessions` here are lifetime and exact.\n\n## UserUsageState\n\nThe persisted shape on `UserState.Usage` (`UserDataDocument.Usage` server-side)\n— foreground-activity seconds only, aggregated per day.\n\n⚠ **History is rolled up: days → months → years, and the rollup is\nIRREVERSIBLE.** `Daily` used to hold every day the player had ever been active;\nit grew forever inside the document read on every request. It is now a WINDOW\nof the most recent days (400 by default, per-title configurable). Older days\nare folded into `Monthly`, and months of finished years into `Yearly`; the\nper-day detail outside the window is gone for good.\n\n**Do not sum `Daily` to get a lifetime or year-long total** — you will silently\nundercount. Use `TotalSeconds`/`TotalSessions` (kept separately, exact), or add\nthe period records.\n\n```ts\ninterface UserUsageState {\n TotalSeconds: number; // lifetime, never touched by the rollup\n TotalSessions: number; // lifetime, never touched by the rollup\n FirstActiveAt?: string | null;\n LastActiveAt: string;\n Reactivations?: UsageReactivationEvent[];\n Daily?: Record<string, DailyUsageRecord>; // key = \"ddMMyyyy\" (UTC) — RECENT WINDOW only\n Monthly?: Record<string, UsagePeriodRecord>; // key = \"yyyyMM\" (sortable, unlike the day key)\n Yearly?: Record<string, UsagePeriodRecord>; // key = \"yyyy\"\n}\n\n/** One folded period. `ActiveDays` is the reason the rollup exists: total seconds\n * can't tell you whether the player came daily or once, and per-day records are gone. */\ninterface UsagePeriodRecord {\n Seconds: number;\n Sessions: number;\n ActiveDays: number;\n LongestSessionSeconds: number;\n ActiveHoursMask: number;\n FirstActiveDay: string;\n LastActiveAt: string;\n}\n\n/** One recorded reactivation — a return after a silence gap of 7+ days. */\ninterface UsageReactivationEvent {\n ReactivatedAt: string;\n DaysSinceLastActive: number;\n}\n\ninterface DailyUsageRecord {\n Seconds: number;\n Sessions: number;\n LongestSessionSeconds: number;\n ActiveHoursMask: number;\n LastActiveAt: string;\n}\n```\n\n`UsageTimeStats` (from `getUsageTime()`) and `UserUsageState` (on `UserState.Usage`)\noverlap in intent but are separate types with separate field names — don't\nconflate `Today`/`CurrentWeek`/`CurrentMonth` (rolling windows in the fetch\nresponse) with `Daily` (a raw per-day map, keyed by date, in the persisted\nstate).\n\n---\n\n## Requests\n\n```ts\ninterface UserRequest extends BaseRequest {\n IsNewSession?: boolean; // addUsageTime\n SessionDurationSeconds?: number; // addUsageTime\n Fields?: string[]; // reserved — not currently populated by UserService\n TitleFields?: string[]; // reserved — not currently populated by UserService\n ExcludeFields?: string[]; // getClientStateExcept — User.* keys to preserve from cache\n ExcludeTitleFields?: string[]; // getClientStateExcept — Title.* keys to preserve from cache\n}\n```\n\n`BaseRequest` supplies `UserID`, `ClientSessionTicket`, `BuildKey`,\n`WebAppLink`, and a fresh `RelatedEntityID` per call (all filled in\nautomatically by `UserService`'s internal `baseRequest()` — you never build a\n`UserRequest` by hand). `Fields`/`TitleFields` exist on the request type but\n`UserService`'s methods never set them today — only `ExcludeFields` /\n`ExcludeTitleFields` are wired up, exclusively by `getClientStateExcept`.\n`BaseRequest` also carries `Username` and `UsageTime` (used by\n`changeUsername`/`addUsageTime` respectively) — those two live on the shared\n`BaseRequest` interface, not on `UserRequest` itself, since other modules'\nrequests (e.g. registration) also set `Username`.\n\n---\n\n## Backend rules (transcribed from `User.cs`)\n\n### Username validation (`ChangeUsername`)\n\nSource: `IDosGamesSDK/API/Client/v2/User/User.cs:245-267`.\n\n1. The server trims the incoming `Username` (`args.Username?.Trim()`).\n2. Rejects with the literal string `\"INVALID_USERNAME\"` if the trimmed value\n is null/whitespace, or its length is `< 3` or `> 24` characters. There is\n no character-set restriction beyond length — any non-whitespace string in\n range is accepted verbatim (no profanity filter, no uniqueness check).\n3. On success it does a single field patch\n (`Builders<UserDataDocument>.Update.Set(u => u.PublicData.Username, desired)`)\n directly via `ResourceService.PatchUserDataDocumentByIDAsync` — it does\n **not** go through `ResourceService`'s resource-operation/OCC path, so\n there's no idempotency-by-reason key for this call; a resend just\n overwrites the name again with the same (or a new) value.\n4. The response (`ChangeUsernameResponse.Username`) is the exact trimmed\n string that was stored — never a suffixed/deduplicated variant, since\n there's no uniqueness constraint to disambiguate against.\n\n### Delete-account cascade (`DeleteUserAccount`)\n\nSource: `IGSService.DeleteUserAccount` (`IDosGamesSDK/Core/CoreScripts/IGServer/Service/IGSService.cs:1275-1278`)\n→ `DataBaseService.DeleteAllDataUserAsync` (`IDosGamesSDK/Core/CoreScripts/IGServer/DataBase/DataBaseService.cs:498-519`).\n\n- The call is a single MongoDB `DeleteOneAsync` against the `USER_TYPE`\n collection, filtered by `UserID` **and** `TitleID` together. It deletes\n exactly one `UserDataDocument` — this title's per-player document only.\n- There is no fan-out to other collections in this code path: no explicit\n cleanup of Marketplace escrow/listings, leaderboard documents, coop-group\n membership, blockchain deposit-address records, or the cross-title\n `PlatformUserDocument`/`LinkedTitleAccounts` map. If a player is linked to a\n platform account, deleting one title's `UserDataDocument` does not unlink\n or delete the platform-level document or any other title's data.\n- The operation returns a plain `bool` (`DeletedCount > 0`); the endpoint\n turns a `false` into `OperationResult.Fail(\"Failed to Delete User Account\")`\n (`User.cs:269-275`). There is no soft-delete/undo flag anywhere in this\n path — a successful call is a permanent, synchronous hard delete of the row.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|