@idosgames/mcp 0.1.2 → 0.1.4

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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "title-custom-data",
3
+ "description": "Read title-wide shared data in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.titleCustomData (TitleCustomDataService): the key-value store every player of a title sees — live event state, global counters and progress bars, server-side thresholds, feature flags, remote config. Use this whenever the user wants a value that is the SAME for all players (a server-wide event, a global goal, a kill switch, a balancing knob changed without a rebuild), or touches client.titleCustomData, TitleCustomDataService, GetPublicTitleDataResponse, TitleDataScope or TitleDataBucket — even if they don't name the module. For per-player values use user-custom-data; to WRITE title data at runtime use cloud-code.",
4
+ "content": "---\nname: title-custom-data\ndescription: >-\n Read title-wide shared data in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.titleCustomData (TitleCustomDataService): the\n key-value store every player of a title sees — live event state, global\n counters and progress bars, server-side thresholds, feature flags, remote\n config. Use this whenever the user wants a value that is the SAME for all\n players (a server-wide event, a global goal, a kill switch, a balancing knob\n changed without a rebuild), or touches client.titleCustomData,\n TitleCustomDataService, GetPublicTitleDataResponse, TitleDataScope or\n TitleDataBucket — even if they don't name the module. For per-player values\n use user-custom-data; to WRITE title data at runtime use cloud-code.\n---\n\n# Title custom data (iDosGames TS SDK)\n\n`TitleCustomDataService` is the title-wide key-value store: one set of values\nshared by **every player** of the title. Use it for anything global — the state\nof a live event, a server-wide progress bar, thresholds the server enforces, a\nfeature flag you want to flip without shipping a build.\n\nFor per-player values use **user-custom-data**. Putting a global value in each\nplayer's data means N copies that immediately disagree.\n\n## Mental model: two scopes, two buckets, zero client writes\n\nEvery record has a **scope** (who may write it) and a **bucket** (who may read\nit).\n\n| Scope | Written by | Cached | Typical content |\n| --------- | --------------------------------------------- | ------ | -------------------------------------------------- |\n| `Static` | publisher / AI Coder (title-data admin tools) | yes | authored config: schedules, texts, balancing knobs |\n| `Runtime` | **CloudCode scripts only** | no | live state: counters, current event phase, winners |\n\n| Bucket | Readable by |\n| --------- | --------------------------------- |\n| `Public` | game clients (this service) |\n| `Private` | server code and CloudCode scripts |\n\n**The client has no write path at all** — not a restricted one, none. That is\nthe point: a value every player reads must not be settable by any player. To\nchange title data at runtime, write a CloudCode handler\n(`server.SetTitleCustomData` / `server.IncrementTitleCustomData`) and call it —\nsee **cloud-code**.\n\n`Static` is cached server-side alongside the title config, so an authored change\npropagates within roughly a minute. `Runtime` is never cached: a counter read\nright after a script incremented it is already correct.\n\nValues are always **strings** — JSON-encode structured data yourself.\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 titleData = client.titleCustomData;\n```\n\nEvery method needs an authenticated session; without one they return\n`{ ok: false, reason: \"unauthorized\" }` rather than throwing.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>` — branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args),\n`\"unauthorized\"`, `\"throttled\"`, `\"connection\"`, `\"validation\"`, or `\"server\"`.\n\n| Method | Purpose | `data` on success |\n| -------------------------------------- | ---------------------------------------------------- | ---------------------------- |\n| `getPublicTitleData(useKnownVersion?)` | Read all public title data (both scopes). | `GetPublicTitleDataResponse` |\n| `getPublicTitleDataKeys(keyIDs)` | Read only the listed keys (max 50 per call). | `GetPublicTitleDataResponse` |\n| `getTitleCustomDataDefinitions()` | Load the schema of public keys + the title's limits. | `TitleCustomDataDefinitions` |\n\n`GetPublicTitleDataResponse`:\n\n| Field | Meaning |\n| ---------------- | ----------------------------------------------------------------------------------------- |\n| `Static` | `Record<string, TitleCustomDataRecord>` — authored values. |\n| `Runtime` | `Record<string, TitleCustomDataRecord>` — live values written by scripts. |\n| `StaticVersion` | Change counter of the authored part. |\n| `RuntimeVersion` | Change counter of the live part — the cheap way to detect \"did anything change\". |\n| `NotModified` | `true` when the server skipped the Runtime payload because nothing changed (see polling). |\n\nEach record carries `Value`, `UpdatedAt`, `Version` (per-key write counter),\n`LastWriter` and `ExpiresAt`.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n- `titleCustomData:definitionsLoaded` → `TitleCustomDataDefinitions`\n- `titleCustomData:publicDataLoaded` → `GetPublicTitleDataResponse`\n\n## Recipes\n\n### Read the current event state at startup\n\n```ts\nconst res = await client.titleCustomData.getPublicTitleData();\nif (!res.ok) return showError(res.error);\n\nconst phase = res.data.Runtime?.[\"event_phase\"]?.Value ?? \"idle\";\nconst endsAt = res.data.Static?.[\"event_ends_at\"]?.Value; // ISO string you authored\n```\n\n### Poll a global progress bar cheaply\n\n```ts\n// The service remembers the last RuntimeVersion and sends it along; when nothing\n// changed the server answers NotModified and skips the payload. The service still\n// fills `Runtime` from its own cache, so this branch needs no special handling.\nsetInterval(async () => {\n const res = await client.titleCustomData.getPublicTitleData();\n if (!res.ok) return;\n const done = Number(res.data.Runtime?.[\"global_kills\"]?.Value ?? 0);\n renderProgress(done, GOAL);\n}, 15_000);\n```\n\n### Fetch just the two keys a screen needs\n\n```ts\nconst res = await client.titleCustomData.getPublicTitleDataKeys([\n \"event_phase\",\n \"event_multiplier\",\n]);\n```\n\n### Contribute to a global counter (needs a script)\n\nThe client cannot write, so contribution goes through CloudCode:\n\n```ts\n// server side (published with the CloudCode tooling):\n// handlers.contributeKills = function (args, context) {\n// var res = server.IncrementTitleCustomData(\"Public\", \"global_kills\", args.count | 0);\n// if (!res.Success) throw new Error(res.Error);\n// return { total: res.Data.Value };\n// };\n\nconst res = await client.cloudCode.execute(\"contributeKills\", { count: 3 });\nif (res.ok && !res.data.Error) {\n const total = (res.data.FunctionResult as { total: string }).total;\n}\n```\n\n## Gotchas\n\n- **There is no `set…` here, and that is deliberate.** If you find yourself\n wanting one, the value either belongs to the player (`user-custom-data`) or\n must be written by a CloudCode handler.\n- **`Runtime` values change under you.** They are live, shared, and written by\n scripts while the player is looking at them — render from the last read and\n re-read on a cadence; don't cache one at login and treat it as stable.\n- **`NotModified` is a success, not an error.** The service refills `Runtime`\n from its cache, so `res.data.Runtime` is always populated. Pass\n `getPublicTitleData(false)` to force a full payload.\n- **`Private` never reaches the client.** Not filtered out — never read from the\n database on this path. If a value you expect is missing, it is probably in the\n private bucket and only a script can see it.\n- **Registered keys behave better.** A key registered in the title's\n `TitleCustomData` config section gets its scope/bucket pinned, its value type\n validated, a size limit, an optional TTL, and a `DefaultValue` that the server\n materializes on read while the record does not exist yet. Unregistered keys\n work but nothing protects them.\n- **`getPublicTitleDataKeys` caps at 50 keys** and rejects the whole call past\n that — chunk larger reads yourself.\n- **Expired records simply stop appearing.** A key with a TTL is filtered on\n read once `ExpiresAt` passes; the record is purged on the next write.\n- **`config.TitleCustomData` is the schema, not the data.** The title config\n bundle carries the key definitions (scope, bucket, type, limits); the values\n only come from this service.\n",
5
+ "references": []
6
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "title-system",
3
- "description": "Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap config via client.title (TitleService): the full title public configuration bundle, title-wide public custom data, server time, and the standalone currency/item definitions endpoints. Also documents the config-section registry (`client.data.config.getSection<T>(\"Section\")`) that every other module's skill depends on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app boot/init sequences, server time sync, title-wide custom data, or otherwise touches client.title, TitleService, TitlePublicConfigurationModel, getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions / GetItemDefinitions calls — even if they don't name the module explicitly.",
4
- "content": "---\nname: title-system\ndescription: >-\n Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap\n config via client.title (TitleService): the full title public configuration\n bundle, title-wide public custom data, server time, and the standalone\n currency/item definitions endpoints. Also documents the config-section\n registry (`client.data.config.getSection<T>(\"Section\")`) that every other\n module's skill depends on. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app\n boot/init sequences, server time sync, title-wide custom data, or otherwise\n touches client.title, TitleService, TitlePublicConfigurationModel,\n getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions /\n GetItemDefinitions calls — even if they don't name the module explicitly.\n---\n\n# Title system (iDosGames TS SDK)\n\nThe Title module is **not a gameplay feature** — it's title-level bootstrap\nconfig, config only, no per-player state. It's the place you'd fetch server\ntime, title-wide custom data, and a bundled snapshot of most other modules'\n_config_ (`Currency`, `Item`, `Store`, `Quest`, …) in one call. Most screens\ndon't call `client.title` directly for gameplay data; they call the owning\nmodule's own definitions method instead (e.g.\n`client.character.getCharacterDefinitions()`).\n\nDon't confuse this with the real login bootstrap: `client.auth.loginWithDeviceID()`\n(and every other login method) already calls `UserService.getClientStateExcept(...)`\ninternally, which fetches **both** the title config bundle **and** every\nmodule's per-player `User.*` state in one shot — see the user-profile skill.\n`client.title.getTitlePublicConfiguration()` only gets you the config half of\nthat (no `User.*` state), so you rarely need to call it yourself right after\nlogin; it's more useful for an explicit \"refresh config only\" action, or for\nthe couple of things no other module owns (server time, title custom data).\n\nThis skill is for **using** the production `TitleService`, not for porting or\nextending it.\n\n## The config-section registry (read this even if you're here for another module)\n\nEvery module's `Definitions` (config) getter — `getCharacterDefinitions()`,\n`getStoreDefinitions()` inside StoreService, etc. — follows the same pattern:\non a successful fetch, the owning service calls\n`client.data.config.patchSection(\"<SectionKey>\", result.data)`, storing the\nblob in a single `Map<string, unknown>` keyed by a plain string\n(`TitleConfig.sections`). Your UI code reads it back with a type parameter:\n\n```ts\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\n`getSection<T>(key)` is just `sections.get(key) as T | undefined` — **the cast\nis not runtime-validated**, it only recovers the compile-time type; if a\nmodule hasn't fetched its definitions yet, you get `undefined`, not a runtime\nerror. Each module's own skill documents its section key and payload type —\nthis skill only documents the mechanism itself, not any section's contents.\n\nTwo things live outside that generic map, with their own dedicated getters:\n\n- `client.data.config.titlePublicConfiguration` — the full bundle fetched by\n `getTitlePublicConfiguration()` here in Title (see caveat below on which\n fields it actually contains).\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n — dedicated getters (not `getSection`) that fall back from a standalone\n fetch to the bundled value; see below.\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 title = client.title; // the TitleService\n```\n\nEvery method requires an authenticated session — confirmed server-side, not\njust a client-side gate: the backend's shared `ClientRun.Execute` pipeline\nrequires a Bearer `ClientSessionTicket` and runs `ValidateUserSession` for\nevery Title action with no per-action exception, including `GetServerTime`.\nWithout a session, SDK methods return `{ ok: false, reason: \"unauthorized\" }`\nlocally before any network call — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is\none of `\"client\"`, `\"unauthorized\"`, `\"throttled\"` (600 ms default client-side\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`, or\n`\"server\"`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- |\n| `getTitlePublicConfiguration()` | Fetch the title config bundle (see field-subset caveat below). | `TitlePublicConfigurationModel` |\n| `getPublicTitleCustomData()` | Fetch title-wide public custom data (arbitrary tenant data, not tied to any feature module). | `TitleCustomDataResponse` (`PublicData`) |\n| `getCurrencyDefinitions()` | Fetch just the currency catalog, standalone. | `CurrencyDefinitions` |\n| `getItemDefinitions()` | Fetch just the item catalog, standalone. | `ItemDefinitions` |\n| `getServerTime()` | Fetch authoritative server time. | `SuccessResponse` (`ServerTime`, `IsCompleted`) |\n\nNone of these take parameters — every one builds its request from\n`buildAuthedBaseRequest()` only (`TitleService.ts`'s private `baseRequest()`\nnever fills in any extra field). On success, each method mirrors its result\ninto the cache and emits an event — you don't apply anything by hand.\n\n### `getTitlePublicConfiguration()` returns a fixed field subset, not everything\n\nThe backend's `TitleRequest` supports `Fields`/`ExcludeFields` (and a second\naction, `GetTitlePublicConfigurationExcept`, for the exclude-list variant),\nbut the TS `TitleService.getTitlePublicConfiguration()` never populates\neither — it always calls the plain `GetTitlePublicConfiguration` action with\nan empty field list, which the backend then defaults to its own hard-coded\nsubset (`_defaultFields` in `Title.cs`). As of this writing that subset is:\n`ImageData`, `AssetBundle`, `Currency`, `Item`, `Premium`, `Reward`,\n`TimedEvent`, `CoopEvent`, `Leaderboard`, `Season`, `Collection`, `Craft`,\n`Lootbox`, `Store`, `DealOffer`, `Quest`, `Referral`, `Blockchain`,\n`Multiplayer`.\n\n**`UserCustomData`, `GameLoop`, and `Character` are deliberately left out** of\nthat default list (commented out in the backend source) — calling\n`getTitlePublicConfiguration()` will **not** populate\n`titlePublicConfiguration.Character` or `.GameLoop`, even though those fields\nexist on the `TitlePublicConfigurationModel` TypeScript type. For those three,\ncall the owning module's own `getXDefinitions()` instead (e.g.\n`client.character.getCharacterDefinitions()`), which patches its own section\nindependently of this bundle. `Match` is absent from the backend's field\nlist entirely — it is never returned by this endpoint under any field\nselection, standalone or bundled; use `client.match`'s own definitions call.\nThere is currently no TS-level way to request a different field subset or the\n\"except\" variant; if you need that, it would require extending\n`TitleService`/`TitleApi` to pass `Fields`/`ExcludeFields` through.\n\n## Currency/Item definitions: this module overlaps with currency-system and item-system\n\n`getCurrencyDefinitions()` and `getItemDefinitions()` live on `TitleService`,\nnot on `CurrencyService` or `ItemService` — as of this writing neither of\nthose modules exposes its own definitions-fetch method. If you need the\ncurrency or item catalog, this is currently the only place to get it\nstandalone (or via the full `getTitlePublicConfiguration()` bundle, which\nembeds both under `.Currency` / `.Item` — these two, unlike Character/GameLoop,\n_are_ in the default field subset). For everything else about currencies and\nitems (balances, granting, converting, upgrading item instances, equipping),\nsee the currency-system and item-system skills — this skill only covers\n_fetching the catalog_, not consuming it.\n\nCaching nuance: `getTitlePublicConfiguration()` stores the full bundle\nseparately from the standalone fetches. Reading order:\n\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n return the standalone-fetched value if you've called\n `getCurrencyDefinitions()` / `getItemDefinitions()`, falling back to the\n value embedded in the full bundle (`titlePublicConfiguration.Currency` /\n `.Item`) otherwise. A standalone fetch **overrides** the bundled value in\n this getter, it doesn't merge with it.\n- The full bundle itself is read via\n `client.data.config.titlePublicConfiguration` (not `getSection`).\n\n## Reading state and reacting to changes\n\n```ts\n// Full bundle (only present after getTitlePublicConfiguration()):\nconst cfg = client.data.config.titlePublicConfiguration;\ncfg?.Currency; // CurrencyDefinitions — in the default field subset\ncfg?.TitleCustomData; // { PublicData, PrivateData }\n// cfg?.Character / cfg?.GameLoop are NOT populated by this call — see above.\n\n// Title-wide custom data (only present after getPublicTitleCustomData()):\nconst customData =\n client.data.config.getSection<TitleCustomDataResponse>(\"TitleCustomData\");\n\n// Standalone currency/item catalogs (prefer these getters over reading the bundle directly):\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `title:publicConfigurationReceived` → `TitlePublicConfigurationModel`\n- `title:publicCustomDataReceived` → `TitleCustomDataResponse`\n- `title:currencyDefinitionsReceived` → `CurrencyDefinitions`\n- `title:itemDefinitionsReceived` → `ItemDefinitions`\n- `title:serverTimeReceived` → `SuccessResponse`\n\nThere is no coarse `title:*Updated` / `user:anyUpdated`-style event for this\nmodule — Title carries no per-player state, so none of the `user:*Updated`\nevents fire from it either.\n\n```ts\nconst off = client.on(\"title:serverTimeReceived\", (r) => {\n console.log(\"server time:\", r.ServerTime);\n});\n// later: off();\n```\n\n## Recipes\n\n### App boot: load config alongside login\n\n```ts\nawait client.auth.loginWithDeviceID();\n// Login already fetched Title + User state via getClientStateExcept internally.\n// Call this again only when you explicitly want a config-only refresh:\nconst res = await client.title.getTitlePublicConfiguration();\nif (!res.ok) return showError(res.error);\n\nconst cfg = client.data.config.titlePublicConfiguration;\n// cfg.Currency, cfg.Item, cfg.Store, cfg.Quest, ... are populated.\n// cfg.Character / cfg.GameLoop are NOT — fetch those from their own modules.\n```\n\nUse this when you want a fresh, config-only round-trip after boot (e.g. a\n\"refresh config\" debug action, or recovering from a stale cache) — not as\nyour primary boot path, since login already populated the same cache slot.\n\n### Fetch just the currency/item catalog\n\n```ts\nconst currencies = await client.title.getCurrencyDefinitions();\nconst items = await client.title.getItemDefinitions();\nif (!currencies.ok || !items.ok) return; // handle each independently\n\n// Prefer these getters — they resolve standalone-fetch-overrides-bundle for you:\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nUse this when you only need currencies/items and don't want the full bundle\n— e.g. a store screen that boots faster by skipping Quest/Reward/etc.\n\n### Title-wide custom data\n\n```ts\nconst res = await client.title.getPublicTitleCustomData();\nif (!res.ok) return showError(res.error);\nres.data.PublicData; // Record<string, TitlePublicData>, each { Data, SchemaVersion, UpdatedAt }\n```\n\nThis is free-form tenant-level data (announcements, feature flags, remote\nconfig-style values) — not tied to any single gameplay module. `Data` is a\nraw string; parse it yourself (e.g. JSON) per your title's convention.\n\n### Sync server time\n\n```ts\nconst res = await client.title.getServerTime();\nif (!res.ok) return showError(res.error);\nconst offsetMs = new Date(res.data.ServerTime).getTime() - Date.now();\n// Apply offsetMs when rendering countdowns driven by server-stamped\n// ExpiresAtUtc/StartUtc fields (timed boosts, timed events, offers, etc.),\n// so a skewed device clock doesn't show a wrong countdown.\n```\n\n`res.data.ServerTime` is the backend's UTC clock read at the moment the\nrequest was handled — it is not cached or memoized server-side, each call\nreflects \"now.\"\n\n### Reading any other module's config once it's loaded\n\n```ts\nimport type { CharacterDefinitions } from \"@idosgames/core\";\n\nawait client.character.getCharacterDefinitions(); // fetch + patchSection(\"Character\", ...)\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nThis is the pattern every other module's skill in this repo uses without\nre-explaining it: fetch via that module's own service method, then read back\nthrough `getSection<T>(\"<ThatModule'sSectionKey>\")`. The section key string is\ndocumented per-module (usually the module's own PascalCase name, e.g.\n`\"Character\"`, `\"Quest\"`, `\"Store\"`) — check that module's skill for the exact\nkey and payload shape.\n\n## Gotchas\n\n- **This is config, not gameplay state.** Nothing here is per-player\n progression — there's no \"upgrade\" or \"grant\" method in this module.\n Render from the cache; there's no user-state mirror to reconcile, and no\n `user:*Updated` event fires from Title.\n- **`getTitlePublicConfiguration()` silently omits `Character`, `GameLoop`,\n and `UserCustomData`**, and can never return `Match` at all — see the\n dedicated section above. Don't assume the bundle is a complete snapshot of\n every module; check the field list before relying on a section being\n present in `titlePublicConfiguration`.\n- **Two ways to get currency/item definitions, one source of truth.** The\n standalone `getCurrencyDefinitions()`/`getItemDefinitions()` calls and the\n bundled `getTitlePublicConfiguration()` both hit the same backend catalog —\n don't treat them as independently-versioned. Calling both is harmless but\n redundant; the standalone fetch simply overrides the getter's fallback.\n- **`getPublicTitleCustomData()` is cached under its own section key**\n (`\"TitleCustomData\"`), separate from `TitleCustomData` embedded in the full\n bundle — read it via `getSection`, not off `titlePublicConfiguration`, to\n get the freshest standalone fetch.\n- **Config can be up to ~60 seconds stale.** The backend serves this bundle\n from a process-wide in-memory cache, invalidated by a Redis version counter\n that's itself re-checked at most once every 60 seconds per title. A config\n change made in a title's admin panel isn't guaranteed to be visible to a\n running client instantly — don't build a \"config just changed, refresh now\"\n UX that assumes sub-second propagation.\n- **`getServerTime()` isn't a lightweight unauthenticated ping.** It goes\n through the same full pipeline as every other v2 endpoint: Bearer session\n validation and the title-active/BuildKey check both run before the handler\n executes. A banned/inactive title or an expired session rejects it exactly\n like any other Title call — it will not quietly succeed as a health check\n when the title itself is down.\n- **No batch methods, no write methods.** Every method here is a read; there\n is nothing to guard against double-submit charges for.\n",
3
+ "description": "Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap config via client.title (TitleService): the full title public configuration bundle, server time, and the standalone currency/item definitions endpoints. Also documents the config-section registry (`client.data.config.getSection<T>(\"Section\")`) that every other module's skill depends on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app boot/init sequences or server time sync, or otherwise touches client.title, TitleService, TitlePublicConfigurationModel, getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions / GetItemDefinitions calls — even if they don't name the module explicitly.",
4
+ "content": "---\nname: title-system\ndescription: >-\n Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap\n config via client.title (TitleService): the full title public configuration\n bundle, server time, and the standalone currency/item definitions endpoints.\n Also documents the config-section registry (`client.data.config.getSection<T>(\"Section\")`) that every other\n module's skill depends on. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app\n boot/init sequences or server time sync, or otherwise touches client.title,\n TitleService, TitlePublicConfigurationModel, getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions /\n GetItemDefinitions calls — even if they don't name the module explicitly.\n---\n\n# Title system (iDosGames TS SDK)\n\nThe Title module is **not a gameplay feature** — it's title-level bootstrap\nconfig, config only, no per-player state. It's the place you'd fetch server\ntime and a bundled snapshot of most other modules'\n_config_ (`Currency`, `Item`, `Store`, `Quest`, …) in one call. Most screens\ndon't call `client.title` directly for gameplay data; they call the owning\nmodule's own definitions method instead (e.g.\n`client.character.getCharacterDefinitions()`).\n\nDon't confuse this with the real login bootstrap: `client.auth.loginWithDeviceID()`\n(and every other login method) already calls `UserService.getClientStateExcept(...)`\ninternally, which fetches **both** the title config bundle **and** every\nmodule's per-player `User.*` state in one shot — see the user-profile skill.\n`client.title.getTitlePublicConfiguration()` only gets you the config half of\nthat (no `User.*` state), so you rarely need to call it yourself right after\nlogin; it's more useful for an explicit \"refresh config only\" action, or for\nthe couple of things no other module owns (server time, the config bundle).\n\nThis skill is for **using** the production `TitleService`, not for porting or\nextending it.\n\n## The config-section registry (read this even if you're here for another module)\n\nEvery module's `Definitions` (config) getter — `getCharacterDefinitions()`,\n`getStoreDefinitions()` inside StoreService, etc. — follows the same pattern:\non a successful fetch, the owning service calls\n`client.data.config.patchSection(\"<SectionKey>\", result.data)`, storing the\nblob in a single `Map<string, unknown>` keyed by a plain string\n(`TitleConfig.sections`). Your UI code reads it back with a type parameter:\n\n```ts\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\n`getSection<T>(key)` is just `sections.get(key) as T | undefined` — **the cast\nis not runtime-validated**, it only recovers the compile-time type; if a\nmodule hasn't fetched its definitions yet, you get `undefined`, not a runtime\nerror. Each module's own skill documents its section key and payload type —\nthis skill only documents the mechanism itself, not any section's contents.\n\nTwo things live outside that generic map, with their own dedicated getters:\n\n- `client.data.config.titlePublicConfiguration` — the full bundle fetched by\n `getTitlePublicConfiguration()` here in Title (see caveat below on which\n fields it actually contains).\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n — dedicated getters (not `getSection`) that fall back from a standalone\n fetch to the bundled value; see below.\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 title = client.title; // the TitleService\n```\n\nEvery method requires an authenticated session — confirmed server-side, not\njust a client-side gate: the backend's shared `ClientRun.Execute` pipeline\nrequires a Bearer `ClientSessionTicket` and runs `ValidateUserSession` for\nevery Title action with no per-action exception, including `GetServerTime`.\nWithout a session, SDK methods return `{ ok: false, reason: \"unauthorized\" }`\nlocally before any network call — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is\none of `\"client\"`, `\"unauthorized\"`, `\"throttled\"` (600 ms default client-side\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`, or\n`\"server\"`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------- | -------------------------------------------------------------- | ----------------------------------------------- |\n| `getTitlePublicConfiguration()` | Fetch the title config bundle (see field-subset caveat below). | `TitlePublicConfigurationModel` |\n| `getCurrencyDefinitions()` | Fetch just the currency catalog, standalone. | `CurrencyDefinitions` |\n| `getItemDefinitions()` | Fetch just the item catalog, standalone. | `ItemDefinitions` |\n| `getServerTime()` | Fetch authoritative server time. | `SuccessResponse` (`ServerTime`, `IsCompleted`) |\n\nNone of these take parameters — every one builds its request from\n`buildAuthedBaseRequest()` only (`TitleService.ts`'s private `baseRequest()`\nnever fills in any extra field). On success, each method mirrors its result\ninto the cache and emits an event — you don't apply anything by hand.\n\n### `getTitlePublicConfiguration()` returns a fixed field subset, not everything\n\nThe backend's `TitleRequest` supports `Fields`/`ExcludeFields` (and a second\naction, `GetTitlePublicConfigurationExcept`, for the exclude-list variant),\nbut the TS `TitleService.getTitlePublicConfiguration()` never populates\neither — it always calls the plain `GetTitlePublicConfiguration` action with\nan empty field list, which the backend then defaults to its own hard-coded\nsubset (`_defaultFields` in `Title.cs`). As of this writing that subset is:\n`ImageData`, `AssetBundle`, `Currency`, `Item`, `Premium`, `Reward`,\n`TimedEvent`, `CoopEvent`, `Leaderboard`, `Season`, `Collection`, `Craft`,\n`Lootbox`, `Store`, `DealOffer`, `Quest`, `Referral`, `Blockchain`,\n`Multiplayer`.\n\n**`UserCustomData`, `GameLoop`, and `Character` are deliberately left out** of\nthat default list (commented out in the backend source) — calling\n`getTitlePublicConfiguration()` will **not** populate\n`titlePublicConfiguration.Character` or `.GameLoop`, even though those fields\nexist on the `TitlePublicConfigurationModel` TypeScript type. For those three,\ncall the owning module's own `getXDefinitions()` instead (e.g.\n`client.character.getCharacterDefinitions()`), which patches its own section\nindependently of this bundle. `Match` is absent from the backend's field\nlist entirely — it is never returned by this endpoint under any field\nselection, standalone or bundled; use `client.match`'s own definitions call.\nThere is currently no TS-level way to request a different field subset or the\n\"except\" variant; if you need that, it would require extending\n`TitleService`/`TitleApi` to pass `Fields`/`ExcludeFields` through.\n\n## Currency/Item definitions: this module overlaps with currency-system and item-system\n\n`getCurrencyDefinitions()` and `getItemDefinitions()` live on `TitleService`,\nnot on `CurrencyService` or `ItemService` — as of this writing neither of\nthose modules exposes its own definitions-fetch method. If you need the\ncurrency or item catalog, this is currently the only place to get it\nstandalone (or via the full `getTitlePublicConfiguration()` bundle, which\nembeds both under `.Currency` / `.Item` — these two, unlike Character/GameLoop,\n_are_ in the default field subset). For everything else about currencies and\nitems (balances, granting, converting, upgrading item instances, equipping),\nsee the currency-system and item-system skills — this skill only covers\n_fetching the catalog_, not consuming it.\n\nCaching nuance: `getTitlePublicConfiguration()` stores the full bundle\nseparately from the standalone fetches. Reading order:\n\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n return the standalone-fetched value if you've called\n `getCurrencyDefinitions()` / `getItemDefinitions()`, falling back to the\n value embedded in the full bundle (`titlePublicConfiguration.Currency` /\n `.Item`) otherwise. A standalone fetch **overrides** the bundled value in\n this getter, it doesn't merge with it.\n- The full bundle itself is read via\n `client.data.config.titlePublicConfiguration` (not `getSection`).\n\n## Reading state and reacting to changes\n\n```ts\n// Full bundle (only present after getTitlePublicConfiguration()):\nconst cfg = client.data.config.titlePublicConfiguration;\ncfg?.Currency; // CurrencyDefinitions — in the default field subset\ncfg?.TitleCustomData; // TitleCustomDataDefinitions — the title-data key SCHEMA, not its values\n// cfg?.Character / cfg?.GameLoop are NOT populated by this call — see above.\n\n// Standalone currency/item catalogs (prefer these getters over reading the bundle directly):\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `title:publicConfigurationReceived` → `TitlePublicConfigurationModel`\n- `title:currencyDefinitionsReceived` → `CurrencyDefinitions`\n- `title:itemDefinitionsReceived` → `ItemDefinitions`\n- `title:serverTimeReceived` → `SuccessResponse`\n\nThere is no coarse `title:*Updated` / `user:anyUpdated`-style event for this\nmodule — Title carries no per-player state, so none of the `user:*Updated`\nevents fire from it either.\n\n```ts\nconst off = client.on(\"title:serverTimeReceived\", (r) => {\n console.log(\"server time:\", r.ServerTime);\n});\n// later: off();\n```\n\n## Recipes\n\n### App boot: load config alongside login\n\n```ts\nawait client.auth.loginWithDeviceID();\n// Login already fetched Title + User state via getClientStateExcept internally.\n// Call this again only when you explicitly want a config-only refresh:\nconst res = await client.title.getTitlePublicConfiguration();\nif (!res.ok) return showError(res.error);\n\nconst cfg = client.data.config.titlePublicConfiguration;\n// cfg.Currency, cfg.Item, cfg.Store, cfg.Quest, ... are populated.\n// cfg.Character / cfg.GameLoop are NOT — fetch those from their own modules.\n```\n\nUse this when you want a fresh, config-only round-trip after boot (e.g. a\n\"refresh config\" debug action, or recovering from a stale cache) — not as\nyour primary boot path, since login already populated the same cache slot.\n\n### Fetch just the currency/item catalog\n\n```ts\nconst currencies = await client.title.getCurrencyDefinitions();\nconst items = await client.title.getItemDefinitions();\nif (!currencies.ok || !items.ok) return; // handle each independently\n\n// Prefer these getters — they resolve standalone-fetch-overrides-bundle for you:\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nUse this when you only need currencies/items and don't want the full bundle\n— e.g. a store screen that boots faster by skipping Quest/Reward/etc.\n\n### Title-wide custom data lives in its own module\n\nValues shared by all players (announcements, feature flags, event state, global\ncounters) are **not** part of this bundle: `cfg.TitleCustomData` is only the key\nschema. Read the values with `client.titleCustomData` — see **title-custom-data**.\n\n### Sync server time\n\n```ts\nconst res = await client.title.getServerTime();\nif (!res.ok) return showError(res.error);\nconst offsetMs = new Date(res.data.ServerTime).getTime() - Date.now();\n// Apply offsetMs when rendering countdowns driven by server-stamped\n// ExpiresAtUtc/StartUtc fields (timed boosts, timed events, offers, etc.),\n// so a skewed device clock doesn't show a wrong countdown.\n```\n\n`res.data.ServerTime` is the backend's UTC clock read at the moment the\nrequest was handled — it is not cached or memoized server-side, each call\nreflects \"now.\"\n\n### Reading any other module's config once it's loaded\n\n```ts\nimport type { CharacterDefinitions } from \"@idosgames/core\";\n\nawait client.character.getCharacterDefinitions(); // fetch + patchSection(\"Character\", ...)\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nThis is the pattern every other module's skill in this repo uses without\nre-explaining it: fetch via that module's own service method, then read back\nthrough `getSection<T>(\"<ThatModule'sSectionKey>\")`. The section key string is\ndocumented per-module (usually the module's own PascalCase name, e.g.\n`\"Character\"`, `\"Quest\"`, `\"Store\"`) — check that module's skill for the exact\nkey and payload shape.\n\n## Gotchas\n\n- **This is config, not gameplay state.** Nothing here is per-player\n progression — there's no \"upgrade\" or \"grant\" method in this module.\n Render from the cache; there's no user-state mirror to reconcile, and no\n `user:*Updated` event fires from Title.\n- **`getTitlePublicConfiguration()` silently omits `Character`, `GameLoop`,\n and `UserCustomData`**, and can never return `Match` at all — see the\n dedicated section above. Don't assume the bundle is a complete snapshot of\n every module; check the field list before relying on a section being\n present in `titlePublicConfiguration`.\n- **Two ways to get currency/item definitions, one source of truth.** The\n standalone `getCurrencyDefinitions()`/`getItemDefinitions()` calls and the\n bundled `getTitlePublicConfiguration()` both hit the same backend catalog —\n don't treat them as independently-versioned. Calling both is harmless but\n redundant; the standalone fetch simply overrides the getter's fallback.\n- **Config can be up to ~60 seconds stale.** The backend serves this bundle\n from a process-wide in-memory cache, invalidated by a Redis version counter\n that's itself re-checked at most once every 60 seconds per title. A config\n change made in a title's admin panel isn't guaranteed to be visible to a\n running client instantly — don't build a \"config just changed, refresh now\"\n UX that assumes sub-second propagation.\n- **`getServerTime()` isn't a lightweight unauthenticated ping.** It goes\n through the same full pipeline as every other v2 endpoint: Bearer session\n validation and the title-active/BuildKey check both run before the handler\n executes. A banned/inactive title or an expired session rejects it exactly\n like any other Title call — it will not quietly succeed as a health check\n when the title itself is down.\n- **No batch methods, no write methods.** Every method here is a read; there\n is nothing to guard against double-submit charges for.\n",
5
5
  "references": []
6
6
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "user-custom-data",
3
3
  "description": "Build a generic per-player key-value data store in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.userCustomData (UserCustomDataService): set/get/delete private (only-you-readable) and public (readable-by-others) string keys, batch set/delete many keys atomically, batch-read public data for many players at once, and load the title's schema-managed key registry. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants player settings/preferences storage, profile flair or badges visible to other players, arbitrary save-data slots, or otherwise touches client.userCustomData, UserCustomDataService, UserCustomDataModels, CustomDataBucket, or UserCustomDataRecord — even if they don't name the module explicitly.",
4
- "content": "---\nname: user-custom-data\ndescription: >-\n Build a generic per-player key-value data store in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.userCustomData\n (UserCustomDataService): set/get/delete private (only-you-readable) and\n public (readable-by-others) string keys, batch set/delete many keys\n atomically, batch-read public data for many players at once, and load the\n title's schema-managed key registry. Use this whenever the user is working\n in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and\n wants player settings/preferences storage, profile flair or badges visible\n to other players, arbitrary save-data slots, or otherwise touches\n client.userCustomData, UserCustomDataService, UserCustomDataModels,\n CustomDataBucket, or UserCustomDataRecord — even if they don't name the\n module explicitly.\n---\n\n# User custom data (iDosGames TS SDK)\n\nUserCustomData is a generic per-player key-value store: arbitrary string\nvalues under string keys, split into buckets by visibility. It's\nself-contained — no coupling to currencies, items, or any other economy\nmodule, and no resource cost is ever charged for using it. Use it for\nanything that doesn't fit a purpose-built module: player settings, UI\npreferences, cosmetic flair shown on a profile, small save-data blobs,\nfeature flags per player, etc.\n\nEverything is **server-authoritative**: the backend owns bucket assignment,\nsize/count limits, and (for schema-registered keys) value-format validation.\nThis skill is for **using** the production `UserCustomDataService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (key not registered, value too large/wrong format, bucket full) —\nsurface the error, don't try to reproduce the check client-side.\n\n## Buckets\n\nEvery key lives in exactly one bucket (`CustomDataBucket`), and the bucket\ndecides both who can read it and who can write it:\n\n- **`Private`** — readable only by the owning player. Set with\n `setPrivateData`.\n- **`Public`** — readable by other players (e.g. via\n `getPublicUserCustomDataOf`). Set with `setPublicData`.\n- **`ReadOnly`** — server-written only (e.g. another backend action marking a\n tutorial step done). The client can read it — it shows up in\n `getMyUserCustomData()`'s `ReadOnly` map and in the cached\n `CustomData.ReadOnly` — but the client SDK exposes no write method for it.\n- **`Internal`** — never returned to the client in any form, by any endpoint\n (including cross-player reads). Backend jobs/admin/analytics only; treat it\n as fully invisible.\n\nOnly `Private` and `Public` are writable/deletable from the client\n(`setPrivateData`/`setPublicData`/`deleteKey`/batch variants all reject other\nbuckets with a client-side validation error before hitting the network — and\nthe backend independently enforces the same restriction).\n\nValues are always **strings**. If you need structured data, JSON-encode it\nyourself. The config registry has a `Json` `ValueType` hint — for\nschema-registered keys the **backend validates** that the string parses as\nthe declared type (`Int`/`Bool`/`Json`) on every write — but the SDK itself\ndoes not parse, validate, or decode it for you; see\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 customData = client.userCustomData; // the UserCustomDataService\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 — empty/malformed key, empty batch), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the client-side throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. unregistered key, value type/size limit,\nbucket full, too many batch items).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- |\n| `getUserCustomDataDefinitions()` | Load the title's key registry/config. | `UserCustomDataDefinitions` |\n| `getMyUserCustomData()` | Load all of this player's data (all buckets). | `GetMyUserCustomDataResponse` |\n| `getPublicUserCustomDataOf(targetUserID)` | Read another player's `Public` bucket only. | `GetPublicUserCustomDataResponse` |\n| `setPrivateData(keyID, value)` | Set/overwrite one key in `Private`. | `SetUserCustomDataResponse` |\n| `setPublicData(keyID, value)` | Set/overwrite one key in `Public`. | `SetUserCustomDataResponse` |\n| `deleteKey(keyID, bucket)` | Delete one key (`Private` or `Public` only). | `SuccessResponse` |\n| `batchSet(items)` | Set many keys (any mix of buckets) atomically. | `BatchSetUserCustomDataResponse` (`Results`) |\n| `batchDelete(items)` | Delete many keys atomically. | `BatchDeleteUserCustomDataResponse` (`DeletedCount`) |\n| `batchGetPublicUserCustomDataOf(targetUserIDs)` | Read `Public` bucket for many players in one call. | `BatchGetPublicUserCustomDataResponse` |\n\nKey rules (checked client-side before any request, and re-checked\nserver-side): a `KeyID` must be non-empty and must not contain `\".\"` or `\"$\"`\n(MongoDB path-safety rule, same convention as Character/Item IDs elsewhere in\nthe SDK).\n\nOn success, the single-key and batch **write/delete** methods mirror the\nconfirmed change into the cache and emit an event. `getPublicUserCustomDataOf`\nand `batchGetPublicUserCustomDataOf` (reading _another_ player's data) do\n**not** touch the cache — there's nothing local to patch since it's someone\nelse's data; treat their results as transient render data.\n\n## Reading state and reacting to changes\n\n```ts\nconst cd = client.data.user.state?.CustomData;\ncd?.Version; // increments on every local write/delete (client-side change counter)\ncd?.Private?.[\"settings\"]?.Value; // string | undefined\ncd?.Public?.[\"title\"]?.Value;\ncd?.ReadOnly?.[\"serverFlag\"]?.Value; // written server-side only\n\n// Config (registry of known keys, if the title schema-manages them):\nimport type { UserCustomDataDefinitions } from \"@idosgames/core\";\nconst defs =\n client.data.config.getSection<UserCustomDataDefinitions>(\"UserCustomData\");\n```\n\nEach `UserCustomDataRecord` also carries `UpdatedAt`, `Version` (a per-key\ncounter that increments every time that specific key is overwritten —\nunrelated to the bucket-wide `CustomData.Version` change counter),\n`LastWriter` (`\"Client\" | \"Server\" | \"System\"`), and `ExpiresAt` (if the key\nhas a TTL).\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `userCustomData:definitionsLoaded` → `UserCustomDataDefinitions`\n- `userCustomData:myDataLoaded` → `GetMyUserCustomDataResponse`\n- `userCustomData:publicDataLoaded` → `GetPublicUserCustomDataResponse` (cache untouched)\n- `userCustomData:privateDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:publicDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:keyDeleted` → `void`\n- `userCustomData:batchSet` → `BatchSetUserCustomDataResponse`\n- `userCustomData:batchDeleted` → `BatchDeleteUserCustomDataResponse`\n- `userCustomData:batchPublicDataLoaded` → `BatchGetPublicUserCustomDataResponse` (cache untouched)\n\nThe coarse `user:customDataUpdated` (and `user:anyUpdated`) also fire on every\nlocal write/delete — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"userCustomData:privateDataSet\", (r) => {\n console.log(`${r.KeyID} saved at version ${r.Version}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Save and read a player setting privately\n\n```ts\nconst res = await client.userCustomData.setPrivateData(\"theme\", \"dark\");\nif (!res.ok) return showError(res.error);\n\n// later, anywhere in the app:\nconst theme = client.data.user.state?.CustomData?.Private?.[\"theme\"]?.Value;\n```\n\n### Bootstrap all of the player's own data at login\n\n```ts\nawait client.userCustomData.getMyUserCustomData();\nconst cd = client.data.user.state?.CustomData;\n// cd.Private / cd.Public / cd.ReadOnly are now populated from the server.\n// If a schema-registered key has a DefaultValue and the player has never set\n// it, the server materializes that default in the response (Version: 0,\n// LastWriter: \"System\") without writing it to the DB — treat it as a real\n// display value, just don't expect a subsequent read to differ before you Set it.\n```\n\n### Expose a profile badge publicly\n\n```ts\nconst res = await client.userCustomData.setPublicData(\"title\", \"Dragon Slayer\");\nif (!res.ok) return showError(res.error);\n\n// anyone can now read it:\nconst other =\n await client.userCustomData.getPublicUserCustomDataOf(\"player-42\");\nif (other.ok) {\n const badge = other.data.Public?.[\"title\"]?.Value;\n}\n```\n\n### Batch-set several keys in one atomic call\n\n```ts\nimport { CustomDataBucket } from \"@idosgames/core\";\n\nconst res = await client.userCustomData.batchSet([\n { Bucket: CustomDataBucket.Private, KeyID: \"theme\", Value: \"dark\" },\n { Bucket: CustomDataBucket.Public, KeyID: \"title\", Value: \"Dragon Slayer\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Results ?? []) {\n // item.Bucket, item.KeyID, item.Version, item.ExpiresAt\n}\n```\n\nThis is genuinely all-or-nothing: every item is validated (schema bucket\nmatch, value type/size, no `(Bucket, KeyID)` duplicates) **before** anything\ntouches the database, and a single invalid item fails the whole call with no\npartial writes — unlike some other modules' batch APIs, there is no\nper-item `Success`/`Error` result array here to sift through.\n\n### Batch-delete, and batch-read public data for a leaderboard/friends screen\n\n```ts\nawait client.userCustomData.batchDelete([\n { Bucket: CustomDataBucket.Private, KeyID: \"tempFlag\" },\n]);\n\nconst res = await client.userCustomData.batchGetPublicUserCustomDataOf([\n \"player-1\",\n \"player-2\",\n]);\nif (res.ok) {\n for (const profile of res.data.Results ?? []) {\n // profile.UserID, profile.Public\n }\n res.data.NotFoundUserIDs; // ids that don't exist / had nothing public\n}\n```\n\n`batchDelete` is idempotent per item — deleting a key that's already gone (or\nnever existed) is not an error; it's simply excluded from `DeletedCount`. If\nnone of the requested keys exist, the call still succeeds with\n`DeletedCount: 0` and never touches the database.\n\n## Gotchas\n\n- **Values are strings only.** JSON-encode/decode structured data yourself;\n the SDK does not serialize for you. Note that for a schema-registered key\n with `ValueType: \"Json\"`, the **backend** does validate that your string\n parses as JSON (and similarly `Int`→`long.TryParse`, `Bool`→`\"true\"`/`\"false\"`)\n — a malformed value is rejected server-side with a `\"server\"` reason before\n it reaches the database.\n- **`KeyID` can't contain `\".\"` or `\"$\"`.** Same MongoDB path-safety rule as\n other modules' IDs — validated client-side before any network call, and\n re-validated server-side.\n- **`ReadOnly` and `Internal` have no client write path.** `deleteKey` and\n `batchSet`/`batchDelete` explicitly reject buckets other than `Private`/\n `Public` with `reason: \"client\"`. `Internal` is never returned to the client\n at all, by any endpoint — not even your own.\n- **Reading another player's data never touches your cache.** Only your own\n reads/writes (`getMyUserCustomData`, `setPrivateData`, `setPublicData`,\n `deleteKey`, `batchSet`, `batchDelete`) patch `client.data.user.state.CustomData`.\n- **Guard against double-submit.** Each call is a real write with no\n idempotency key — writes are last-write-wins with no compare-and-swap on the\n client API, so a double-clicked \"Save\" can silently overwrite itself twice\n in a row (harmless for a plain overwrite, but a race if two different\n values are in flight). Disable the control while a call is in flight.\n Firing the same endpoint again within the client-side throttle window\n (600 ms) is rejected with `reason: \"throttled\"` rather than sent twice.\n- **Schema enforcement is permissive by default.** Whether an unregistered\n `KeyID` is accepted depends entirely on the title's\n `RejectUnregisteredKeys` flag (default `false`, i.e. permissive/free-form).\n When `true`, only keys listed in the registry's `Keys` map can be written at\n all — check [references/data-model.md](references/data-model.md) and load\n `getUserCustomDataDefinitions()` before assuming a string key will be\n accepted.\n- **Real server-side caps exist, not just per-key size.** Per-title limits\n gate every write: a max value size per key, a max key _count_ per bucket,\n and a max _total_ size across all four buckets combined — see\n [references/data-model.md](references/data-model.md) for the exact fields\n and default values. Hitting any of them fails the write (or the whole batch)\n with a `\"server\"` error naming the limit; nothing is silently dropped or\n truncated.\n- **Batches are capped at 50 items and reject outright past that**, unlike\n some other modules' batches which silently drop overflow entries — sending\n 51+ items in one `batchSet`/`batchDelete`/`batchGetPublicUserCustomDataOf`\n call fails the entire call with a `\"server\"` error; chunk larger sets into\n multiple calls yourself.\n- **TTL is enforced, not cosmetic.** A key with a TTL (from the registry's\n `TtlSeconds` or the title's `DefaultTtlSeconds` for free-form keys) simply\n stops being returned once expired — reads filter it out as if it were never\n set. The expired record itself is only actually purged from storage on the\n _next_ write to that bucket, so don't rely on `ExpiresAt` for anything other\n than \"will this still be readable.\"\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\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every request/response\ntype, the key-registry config shape, and the exact size/count/TTL limit\nfields and their defaults. Read it when building config-driven UI (e.g.\nshowing max length or TTL before the player types) or when a server error\npoints at a registry rule you need to understand.\n",
4
+ "content": "---\nname: user-custom-data\ndescription: >-\n Build a generic per-player key-value data store in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.userCustomData\n (UserCustomDataService): set/get/delete private (only-you-readable) and\n public (readable-by-others) string keys, batch set/delete many keys\n atomically, batch-read public data for many players at once, and load the\n title's schema-managed key registry. Use this whenever the user is working\n in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and\n wants player settings/preferences storage, profile flair or badges visible\n to other players, arbitrary save-data slots, or otherwise touches\n client.userCustomData, UserCustomDataService, UserCustomDataModels,\n CustomDataBucket, or UserCustomDataRecord — even if they don't name the\n module explicitly.\n---\n\n# User custom data (iDosGames TS SDK)\n\nUserCustomData is a generic per-player key-value store: arbitrary string\nvalues under string keys, split into buckets by visibility. It's\nself-contained — no coupling to currencies, items, or any other economy\nmodule, and no resource cost is ever charged for using it. Use it for\nanything that doesn't fit a purpose-built module: player settings, UI\npreferences, cosmetic flair shown on a profile, small save-data blobs,\nfeature flags per player, etc.\n\n**This is where player data goes.** The project is client-side code in the\nplayer's browser: `localStorage`, module fields and React state survive neither\na device change nor a cleared cache, and the server trusts none of them. If a\ndedicated module owns the data (currency, inventory, quests, characters,\nleaderboards) use that module — it enforces the rules. Everything else that must\npersist goes here. Don't invent a save system.\n\nEverything is **server-authoritative**: the backend owns bucket assignment,\nsize/count limits, and (for schema-registered keys) value-format validation.\nThis skill is for **using** the production `UserCustomDataService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (key not registered, value too large/wrong format, bucket full) —\nsurface the error, don't try to reproduce the check client-side.\n\n## Buckets\n\nEvery key lives in exactly one bucket (`CustomDataBucket`), and the bucket\ndecides both who can read it and who can write it:\n\n- **`Private`** — readable only by the owning player. Set with\n `setPrivateData`.\n- **`Public`** — readable by other players (e.g. via\n `getPublicUserCustomDataOf`). Set with `setPublicData`.\n- **`ReadOnly`** — server-written only (e.g. a script marking a tutorial step\n done). The client can read it — it shows up in `getMyUserCustomData()`'s\n `ReadOnly` map and in the cached `CustomData.ReadOnly` — but the client SDK\n exposes no write method for it.\n- **`Internal`** — never returned to the client in any form, by any endpoint\n (including cross-player reads). Backend jobs/admin/analytics only; treat it\n as fully invisible.\n\nOnly `Private` and `Public` are writable/deletable from the client\n(`setPrivateData`/`setPublicData`/`deleteKey`/batch variants all reject other\nbuckets with a client-side validation error before hitting the network — and\nthe backend independently enforces the same restriction).\n\n**Choosing a bucket is a security decision, not a taste one.** A client-written\nvalue is a value the player can set to anything: they own the browser. Put\npreferences and cosmetics in `Private`/`Public`; put anything worth cheating for\n— progress, unlocks, earned rewards, reward cooldowns, anti-abuse flags — in\n`ReadOnly` or `Internal`.\n\nThose two buckets are written **only from a CloudCode script**\n(`server.SetUserCustomData(\"ReadOnly\", key, value)` and friends). That is the\nwhole point of the split: the write happens on the server, inside logic the\nplayer cannot edit. See **cloud-code** for writing and publishing the handler,\nand call it with `client.cloudCode.execute(...)`. Title-wide values shared by\nevery player belong in **title-custom-data** instead.\n\nValues are always **strings**. If you need structured data, JSON-encode it\nyourself. The config registry has a `Json` `ValueType` hint — for\nschema-registered keys the **backend validates** that the string parses as\nthe declared type (`Int`/`Bool`/`Json`) on every write — but the SDK itself\ndoes not parse, validate, or decode it for you; see\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 customData = client.userCustomData; // the UserCustomDataService\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 — empty/malformed key, empty batch), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the client-side throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. unregistered key, value type/size limit,\nbucket full, too many batch items).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- |\n| `getUserCustomDataDefinitions()` | Load the title's key registry/config. | `UserCustomDataDefinitions` |\n| `getMyUserCustomData()` | Load all of this player's data (all buckets). | `GetMyUserCustomDataResponse` |\n| `getPublicUserCustomDataOf(targetUserID)` | Read another player's `Public` bucket only. | `GetPublicUserCustomDataResponse` |\n| `setPrivateData(keyID, value)` | Set/overwrite one key in `Private`. | `SetUserCustomDataResponse` |\n| `setPublicData(keyID, value)` | Set/overwrite one key in `Public`. | `SetUserCustomDataResponse` |\n| `deleteKey(keyID, bucket)` | Delete one key (`Private` or `Public` only). | `SuccessResponse` |\n| `batchSet(items)` | Set many keys (any mix of buckets) atomically. | `BatchSetUserCustomDataResponse` (`Results`) |\n| `batchDelete(items)` | Delete many keys atomically. | `BatchDeleteUserCustomDataResponse` (`DeletedCount`) |\n| `batchGetPublicUserCustomDataOf(targetUserIDs)` | Read `Public` bucket for many players in one call. | `BatchGetPublicUserCustomDataResponse` |\n\nKey rules (checked client-side before any request, and re-checked\nserver-side): a `KeyID` must be non-empty and must not contain `\".\"` or `\"$\"`\n(MongoDB path-safety rule, same convention as Character/Item IDs elsewhere in\nthe SDK).\n\nOn success, the single-key and batch **write/delete** methods mirror the\nconfirmed change into the cache and emit an event. `getPublicUserCustomDataOf`\nand `batchGetPublicUserCustomDataOf` (reading _another_ player's data) do\n**not** touch the cache — there's nothing local to patch since it's someone\nelse's data; treat their results as transient render data.\n\n## Reading state and reacting to changes\n\n```ts\nconst cd = client.data.user.state?.CustomData;\ncd?.Version; // increments on every local write/delete (client-side change counter)\ncd?.Private?.[\"settings\"]?.Value; // string | undefined\ncd?.Public?.[\"title\"]?.Value;\ncd?.ReadOnly?.[\"serverFlag\"]?.Value; // written server-side only\n\n// Config (registry of known keys, if the title schema-manages them):\nimport type { UserCustomDataDefinitions } from \"@idosgames/core\";\nconst defs =\n client.data.config.getSection<UserCustomDataDefinitions>(\"UserCustomData\");\n```\n\nEach `UserCustomDataRecord` also carries `UpdatedAt`, `Version` (a per-key\ncounter that increments every time that specific key is overwritten —\nunrelated to the bucket-wide `CustomData.Version` change counter),\n`LastWriter` (`\"Client\" | \"Server\" | \"System\"`), and `ExpiresAt` (if the key\nhas a TTL).\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `userCustomData:definitionsLoaded` → `UserCustomDataDefinitions`\n- `userCustomData:myDataLoaded` → `GetMyUserCustomDataResponse`\n- `userCustomData:publicDataLoaded` → `GetPublicUserCustomDataResponse` (cache untouched)\n- `userCustomData:privateDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:publicDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:keyDeleted` → `void`\n- `userCustomData:batchSet` → `BatchSetUserCustomDataResponse`\n- `userCustomData:batchDeleted` → `BatchDeleteUserCustomDataResponse`\n- `userCustomData:batchPublicDataLoaded` → `BatchGetPublicUserCustomDataResponse` (cache untouched)\n\nThe coarse `user:customDataUpdated` (and `user:anyUpdated`) also fire on every\nlocal write/delete — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"userCustomData:privateDataSet\", (r) => {\n console.log(`${r.KeyID} saved at version ${r.Version}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Save and read a player setting privately\n\n```ts\nconst res = await client.userCustomData.setPrivateData(\"theme\", \"dark\");\nif (!res.ok) return showError(res.error);\n\n// later, anywhere in the app:\nconst theme = client.data.user.state?.CustomData?.Private?.[\"theme\"]?.Value;\n```\n\n### Bootstrap all of the player's own data at login\n\n```ts\nawait client.userCustomData.getMyUserCustomData();\nconst cd = client.data.user.state?.CustomData;\n// cd.Private / cd.Public / cd.ReadOnly are now populated from the server.\n// If a schema-registered key has a DefaultValue and the player has never set\n// it, the server materializes that default in the response (Version: 0,\n// LastWriter: \"System\") without writing it to the DB — treat it as a real\n// display value, just don't expect a subsequent read to differ before you Set it.\n```\n\n### Expose a profile badge publicly\n\n```ts\nconst res = await client.userCustomData.setPublicData(\"title\", \"Dragon Slayer\");\nif (!res.ok) return showError(res.error);\n\n// anyone can now read it:\nconst other =\n await client.userCustomData.getPublicUserCustomDataOf(\"player-42\");\nif (other.ok) {\n const badge = other.data.Public?.[\"title\"]?.Value;\n}\n```\n\n### Batch-set several keys in one atomic call\n\n```ts\nimport { CustomDataBucket } from \"@idosgames/core\";\n\nconst res = await client.userCustomData.batchSet([\n { Bucket: CustomDataBucket.Private, KeyID: \"theme\", Value: \"dark\" },\n { Bucket: CustomDataBucket.Public, KeyID: \"title\", Value: \"Dragon Slayer\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Results ?? []) {\n // item.Bucket, item.KeyID, item.Version, item.ExpiresAt\n}\n```\n\nThis is genuinely all-or-nothing: every item is validated (schema bucket\nmatch, value type/size, no `(Bucket, KeyID)` duplicates) **before** anything\ntouches the database, and a single invalid item fails the whole call with no\npartial writes — unlike some other modules' batch APIs, there is no\nper-item `Success`/`Error` result array here to sift through.\n\n### Batch-delete, and batch-read public data for a leaderboard/friends screen\n\n```ts\nawait client.userCustomData.batchDelete([\n { Bucket: CustomDataBucket.Private, KeyID: \"tempFlag\" },\n]);\n\nconst res = await client.userCustomData.batchGetPublicUserCustomDataOf([\n \"player-1\",\n \"player-2\",\n]);\nif (res.ok) {\n for (const profile of res.data.Results ?? []) {\n // profile.UserID, profile.Public\n }\n res.data.NotFoundUserIDs; // ids that don't exist / had nothing public\n}\n```\n\n`batchDelete` is idempotent per item — deleting a key that's already gone (or\nnever existed) is not an error; it's simply excluded from `DeletedCount`. If\nnone of the requested keys exist, the call still succeeds with\n`DeletedCount: 0` and never touches the database.\n\n## Gotchas\n\n- **Values are strings only.** JSON-encode/decode structured data yourself;\n the SDK does not serialize for you. Note that for a schema-registered key\n with `ValueType: \"Json\"`, the **backend** does validate that your string\n parses as JSON (and similarly `Int`→`long.TryParse`, `Bool`→`\"true\"`/`\"false\"`)\n — a malformed value is rejected server-side with a `\"server\"` reason before\n it reaches the database.\n- **`KeyID` can't contain `\".\"` or `\"$\"`.** Same MongoDB path-safety rule as\n other modules' IDs — validated client-side before any network call, and\n re-validated server-side.\n- **`ReadOnly` and `Internal` have no client write path.** `deleteKey` and\n `batchSet`/`batchDelete` explicitly reject buckets other than `Private`/\n `Public` with `reason: \"client\"`. `Internal` is never returned to the client\n at all, by any endpoint — not even your own. Writing either one means writing\n a CloudCode handler; there is no SDK method that will do it, now or later.\n- **Register the keys a feature depends on.** A key listed in the title's\n `UserCustomData` config section has its bucket pinned, its value type checked\n (`String`/`Int`/`Bool`/`Json`), a size cap, an optional TTL and a default\n value returned on read. Unregistered keys are accepted (unless the title sets\n `RejectUnregisteredKeys`) but nothing then catches a typo or a wrong bucket.\n Add the definition in the same change that adds the code using it.\n- **Reading another player's data never touches your cache.** Only your own\n reads/writes (`getMyUserCustomData`, `setPrivateData`, `setPublicData`,\n `deleteKey`, `batchSet`, `batchDelete`) patch `client.data.user.state.CustomData`.\n- **Guard against double-submit.** Each call is a real write with no\n idempotency key — writes are last-write-wins with no compare-and-swap on the\n client API, so a double-clicked \"Save\" can silently overwrite itself twice\n in a row (harmless for a plain overwrite, but a race if two different\n values are in flight). Disable the control while a call is in flight.\n Firing the same endpoint again within the client-side throttle window\n (600 ms) is rejected with `reason: \"throttled\"` rather than sent twice.\n- **Schema enforcement is permissive by default.** Whether an unregistered\n `KeyID` is accepted depends entirely on the title's\n `RejectUnregisteredKeys` flag (default `false`, i.e. permissive/free-form).\n When `true`, only keys listed in the registry's `Keys` map can be written at\n all — check [references/data-model.md](references/data-model.md) and load\n `getUserCustomDataDefinitions()` before assuming a string key will be\n accepted.\n- **Real server-side caps exist, not just per-key size.** Per-title limits\n gate every write: a max value size per key, a max key _count_ per bucket,\n and a max _total_ size across all four buckets combined — see\n [references/data-model.md](references/data-model.md) for the exact fields\n and default values. Hitting any of them fails the write (or the whole batch)\n with a `\"server\"` error naming the limit; nothing is silently dropped or\n truncated.\n- **Batches are capped at 50 items and reject outright past that**, unlike\n some other modules' batches which silently drop overflow entries — sending\n 51+ items in one `batchSet`/`batchDelete`/`batchGetPublicUserCustomDataOf`\n call fails the entire call with a `\"server\"` error; chunk larger sets into\n multiple calls yourself.\n- **TTL is enforced, not cosmetic.** A key with a TTL (from the registry's\n `TtlSeconds` or the title's `DefaultTtlSeconds` for free-form keys) simply\n stops being returned once expired — reads filter it out as if it were never\n set. The expired record itself is only actually purged from storage on the\n _next_ write to that bucket, so don't rely on `ExpiresAt` for anything other\n than \"will this still be readable.\"\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\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every request/response\ntype, the key-registry config shape, and the exact size/count/TTL limit\nfields and their defaults. Read it when building config-driven UI (e.g.\nshowing max length or TTL before the player types) or when a server error\npoints at a registry rule you need to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -1,7 +1,7 @@
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## 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### 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",