@idosgames/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/cli.js +204 -0
- package/package.json +46 -0
- package/registry/host.json +41 -0
- package/registry/index.json +215 -0
- package/registry/modules/board-game.json +121 -0
- package/registry/modules/currency-hud.json +28 -0
- package/registry/modules/idle-rpg.json +89 -0
- package/registry/modules/voxelcraft.json +163 -0
- package/registry/skills/authentication.json +11 -0
- package/registry/skills/blockchain-system.json +11 -0
- package/registry/skills/character-system.json +11 -0
- package/registry/skills/cloud-code.json +6 -0
- package/registry/skills/collection-system.json +11 -0
- package/registry/skills/coop-event-system.json +11 -0
- package/registry/skills/craft-system.json +11 -0
- package/registry/skills/currency-system.json +11 -0
- package/registry/skills/deal-offer-system.json +11 -0
- package/registry/skills/dev-test-loop.json +6 -0
- package/registry/skills/game-loop-system.json +11 -0
- package/registry/skills/idosgames-compose-modules.json +6 -0
- package/registry/skills/idosgames-getting-started.json +6 -0
- package/registry/skills/idosgames-module-contract.json +6 -0
- package/registry/skills/item-system.json +11 -0
- package/registry/skills/leaderboard-system.json +11 -0
- package/registry/skills/lootbox-system.json +11 -0
- package/registry/skills/marketplace-system.json +11 -0
- package/registry/skills/match-system.json +11 -0
- package/registry/skills/multiplayer-realtime.json +11 -0
- package/registry/skills/premium-system.json +11 -0
- package/registry/skills/quest-system.json +11 -0
- package/registry/skills/referral-system.json +11 -0
- package/registry/skills/reward-system.json +11 -0
- package/registry/skills/season-system.json +11 -0
- package/registry/skills/social-system.json +6 -0
- package/registry/skills/store-system.json +11 -0
- package/registry/skills/timed-boost-system.json +11 -0
- package/registry/skills/timed-event-system.json +11 -0
- package/registry/skills/title-system.json +6 -0
- package/registry/skills/user-custom-data.json +11 -0
- package/registry/skills/user-profile.json +11 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "timed-boost-system",
|
|
3
|
+
"description": "Build temporary player boosts (XP/resource/stat multipliers) in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.timedBoost (TimedBoostService): load boost definitions (manual, scheduled \"happy hour\", chained, and auto-triggered), activate a manual boost, read the player's currently-active boost instances, read currently-active global boost windows, and clean up expired boosts. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants temporary multiplier/buff systems, double-XP or double-reward events, happy-hour style scheduled bonuses, boost stacking rules, or otherwise touches client.timedBoost, TimedBoostService, TimedBoostDefinitions, ActiveTimedBoost, or TimedBoostStackingPolicy — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: timed-boost-system\ndescription: >-\n Build temporary player boosts (XP/resource/stat multipliers) in a game on\n the iDosGames TypeScript SDK (@idosgames/core) via client.timedBoost\n (TimedBoostService): load boost definitions (manual, scheduled \"happy\n hour\", chained, and auto-triggered), activate a manual boost, read the\n player's currently-active boost instances, read currently-active global\n boost windows, and clean up expired boosts. Use this whenever the user is\n working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants temporary multiplier/buff systems, double-XP or\n double-reward events, happy-hour style scheduled bonuses, boost stacking\n rules, or otherwise touches client.timedBoost, TimedBoostService,\n TimedBoostDefinitions, ActiveTimedBoost, or TimedBoostStackingPolicy — even\n if they don't name the module explicitly.\n---\n\n# Timed boost system (iDosGames TS SDK)\n\nThe TimedBoost module grants players temporary numeric modifiers (XP\nmultipliers, resource-drop bonuses, stat buffs, …) that expire after a\nduration or run out of charges. Everything is **server-authoritative**: the\nclient asks the backend to activate a boost, the backend validates cost and\nstacking rules and stamps the expiry, and the SDK mirrors the confirmed\nresult into a local cache your UI reads.\n\nThis skill is for **using** the production `TimedBoostService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (cost, stacking cap) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Four kinds of boost, one effect shape\n\nAll boost effects share the same building block, `TimedBoostEffectSpec`\n(`{ Target, Operation, Value }` — `Operation` is `Multiply` | `AddPercent` |\n`AddFlat`, `Target` is a free-form modifier target string). What differs is\n_how_ the boost turns on:\n\n1. **Manual boosts** (`Definitions`) — player-activated via `activate(boostID)`.\n Have an `ActivationCost`, a single `Effect`, a duration and/or `Charges`,\n and a `StackingPolicy`. This is the only kind you activate yourself; the\n other three are server-driven and read-only from the client.\n2. **Scheduled boosts** (`ScheduledBoosts`) — global fixed windows (\"happy\n hour 6-7pm\"), driven by a `Schedule` (`ScheduleSpec`), no per-player state.\n Everyone online during the window gets the effect.\n3. **Boost chains** (`BoostChains`) — a cyclic sequence of `Phases`, each its\n own window with its own effects; also schedule-driven, global.\n4. **Triggered boosts** (`TriggeredBoosts`) — auto-granted to a player when a\n configured `Sources` event fires (e.g. completing a quest), becoming a\n per-player active boost identical in shape to a manual activation. The\n grant happens server-side, inside whichever module's action fired the\n trigger (e.g. a GameLoop roll) — there is no TimedBoost endpoint to invoke\n one, and no dedicated event for the grant itself. You only observe it by\n re-fetching `getActive()` after an action that could plausibly trigger one.\n\nKinds 2 and 3 are **global** and resolved server-side into \"what's active\nright now\" — read them with `getActiveWindows()`, not `getActive()`. Kinds 1\nand 4 are **per-player instances** with an `InstanceID` — read them with\n`getActive()`. See [references/data-model.md](references/data-model.md) for\nthe full config shape of all four, the stacking-policy resolution rules, and\nworked examples of overlapping boosts.\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 timedBoost = client.timedBoost; // the TimedBoostService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — 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\"` (bad local args, e.g. an empty/invalid `BoostID`),\n`\"unauthorized\"`, `\"throttled\"` (600 ms default window), `\"connection\"`\n(transient, offer Retry), `\"validation\"`, or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. insufficient funds, unknown\nboost id, stacking cap reached).\n\n| Method | Purpose | `data` on success |\n| -------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------- |\n| `getDefinitions()` | Load the title's boost catalog (config: manual/scheduled/chained/triggered/settings). | `TimedBoostDefinitions` |\n| `getActive()` | Load this player's currently-active manual/triggered boost instances. | `GetActiveTimedBoostsResponse` (`Active`) |\n| `getActiveWindows()` | Load currently-active global scheduled/chain windows, resolved for \"now\". | `GetActiveBoostWindowsResponse` (`Windows`) |\n| `activate(boostID)` | Activate a manual boost (charges its `ActivationCost`). | `ActivateTimedBoostResponse` |\n| `cleanupExpired()` | Ask the server to purge expired active-boost entries, then refreshes `getActive()`. | `SuccessResponse` |\n\n`activate` trims and validates `boostID` client-side first (non-empty, no\n`.` or `$`) before making the request, returning `reason: \"client\"` locally\nif that fails — no round-trip wasted on an obviously bad id.\n\nOn success, each method mirrors the confirmed change into the cache and\nemits an event. `activate`'s consumed resources ride along in\n`data.Resources` and are already applied to cached balances.\n\n## Reading state and reacting to changes\n\n```ts\n// Currently-active per-player boost instances (present after getActive() or activate()):\nconst active = client.data.user.state?.TimedBoost?.Active ?? {};\nfor (const [instanceID, boost] of Object.entries(active)) {\n boost.BoostID;\n boost.ExpiresAtUtc;\n boost.RemainingCharges;\n boost.EffectSnapshot; // { Target, Operation, Value } captured at activation time\n}\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedBoostDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedBoost:definitionsLoaded` → `TimedBoostDefinitions`\n- `timedBoost:activeLoaded` → `GetActiveTimedBoostsResponse`\n- `timedBoost:activeWindowsLoaded` → `GetActiveBoostWindowsResponse`\n- `timedBoost:activated` → `ActivateTimedBoostResponse`\n- `timedBoost:expiredCleaned` → `void`\n\nThe coarse `user:timedBoostUpdated` (and umbrella `user:anyUpdated`) also\nfire on any TimedBoost cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedBoost:activated\", (r) => {\n console.log(`${r.BoostID} active until`, r.ActivatedBoost?.ExpiresAtUtc);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show available boosts and what's currently active\n\n```ts\nawait client.timedBoost.getDefinitions();\nawait client.timedBoost.getActive();\n\nconst defs = client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\");\nconst active = client.data.user.state?.TimedBoost?.Active ?? {};\n\nfor (const [boostID, def] of Object.entries(defs?.Definitions ?? {})) {\n const running = Object.values(active).find((b) => b.BoostID === boostID);\n // running present → show remaining time/charges + a \"already active\" state;\n // absent → show def.ActivationCost and an Activate button.\n}\n```\n\n### Activate a boost\n\n```ts\nconst res = await client.timedBoost.activate(\"double-xp-1h\");\nif (!res.ok) return showError(res.error); // e.g. can't afford, unknown boost id\nres.data.ActivatedBoost?.ExpiresAtUtc; // when it runs out\nres.data.StackingPolicy; // how it combined with any existing instance of this boost\n// cache now has the active instance; balances already debited.\n```\n\n### Show global \"happy hour\" / chain windows\n\n```ts\nconst res = await client.timedBoost.getActiveWindows();\nif (!res.ok) return showError(res.error);\nfor (const w of res.data.Windows ?? []) {\n w.Kind; // \"Scheduled\" | \"Chain\"\n w.DisplayName;\n w.EndUtc; // countdown target\n w.Effects; // effects live for everyone while this window is open\n}\n```\n\nThese are global — there's nothing to \"activate\"; just poll/refresh\nperiodically (or on screen focus) to reflect whether a window is currently\nopen, and use `EndUtc` to drive a countdown.\n\n### Clean up expired boosts\n\n```ts\nconst res = await client.timedBoost.cleanupExpired();\nif (!res.ok) return;\n// getActive() has already been re-run internally; client.data.user.state\n// ?.TimedBoost?.Active reflects the purge.\n```\n\nCall this on screen entry or session resume so a `RemainingCharges: 0` or\npast-`ExpiresAtUtc` entry doesn't linger in the UI. `getActive()` alone\ndoesn't purge server-side state — it can still return an expired-looking\nentry until `cleanupExpired()` (or the backend's own lazy cleanup) runs.\n\n## Gotchas\n\n- **`EffectSnapshot` is frozen at activation time.** If the title later\n edits a boost's definition, already-active instances keep whatever\n `Effect` was live when they were activated — don't re-derive the running\n effect from the current `Definitions` entry.\n- **Stacking is resolved by the server, mirrored simply on the client.** The\n SDK's local cache write (`patchActiveTimedBoost`) only special-cases\n `Replace`/`KeepBest` by deleting other instances of the _same_ `BoostID`\n before inserting the new one; `Refresh`/`Stack` just insert. The actual\n cost/cap enforcement (e.g. `Settings.StackingCaps` per target) is entirely\n server-side — don't assume the client cache alone tells you the effective\n combined modifier. See\n [references/data-model.md](references/data-model.md).\n- **Guard against double-submit.** Each `activate` call mints a fresh\n `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" can charge twice. Disable the control while a\n call is in flight.\n- **`getActive()` and `getActiveWindows()` are different data.** Manual/\n triggered boosts (per-player, `InstanceID`-keyed) never appear in\n `getActiveWindows()`'s `Windows` array, and scheduled/chain windows never\n appear in `getActive()`'s `Active` map. Query both if your UI needs to show\n \"everything boosting me right now.\"\n- **`cleanupExpired` re-triggers `getActive()` internally** — you don't need\n to call `getActive()` again right after; just read the cache once\n `cleanupExpired()` resolves.\n- **Never derive the boosted number yourself.** The server folds every live\n effect for a target (your active instances + open windows, already capped\n per `Settings.StackingCaps`) into one calculation in a fixed order —\n flat adds, then percent, then multiplies — inside the endpoint that performs\n the boosted action (e.g. GameLoop's roll resolution), not inside TimedBoost.\n Use `EffectSnapshot`/`Effects` only to describe a boost in a tooltip; read\n the actual outcome (reward amount, cost) from that action's own response.\n See [references/data-model.md](references/data-model.md) if you need the\n exact formula for a preview estimate.\n- **Triggered-boost grants have no client hook to react to precisely when they\n happen** — they ride inside another module's atomic write. If your UI wants\n to celebrate \"you got a bonus boost,\" refresh `getActive()` after actions\n that can plausibly grant one and diff against what you had before.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config shape for\nall four boost kinds, the stacking-policy semantics, how scheduled/chain\nwindows get resolved into \"active now,\" and the server's effect-blend formula.\nRead it when building a boost catalog screen, a countdown UI driven by chain\nphases, a numeric preview of what a boost will do, or anything that needs to\nreason about how multiple active boosts combine.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# TimedBoost data model — reference\n\nFull config shape for all four boost kinds, the runtime/state shape, and how\nstacking and global windows resolve. All types are **strictly typed in the\nSDK** — `TimedBoostDefinitions` and every nested block are exported from\n`@idosgames/core` with `.passthrough()` schemas, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Config: TimedBoostDefinitions](#config-timedboostdefinitions)\n- [TimedBoostEffectSpec (shared effect shape)](#timedboosteffectspec)\n- [Manual boosts (Definitions)](#manual-boosts-definitions)\n- [Scheduled boosts & boost chains (global windows)](#scheduled-boosts--boost-chains-global-windows)\n- [Triggered boosts](#triggered-boosts)\n- [Global settings & stacking caps](#global-settings--stacking-caps)\n- [Runtime state & responses](#runtime-state--responses)\n- [Stacking policy semantics](#stacking-policy-semantics)\n- [How effects resolve into a number (server-side)](#how-effects-resolve-into-a-number-server-side)\n- [Triggered boosts are granted by other modules, not TimedBoost itself](#triggered-boosts-are-granted-by-other-modules-not-timedboost-itself)\n\n---\n\n## Config: TimedBoostDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\")`.\n\n```ts\ninterface TimedBoostDefinitions {\n Definitions?: Record<string, TimedBoostDefinition>; // manual, key = BoostID\n ScheduledBoosts?: Record<string, ScheduledBoostDefinition>; // global fixed windows\n BoostChains?: Record<string, BoostChainDefinition>; // global cyclic chains\n TriggeredBoosts?: Record<string, TriggeredBoostDefinition>; // auto-granted\n Settings?: TimedBoostGlobalSettings; // per-target stacking caps\n}\n```\n\nAll four catalogs live side by side; a title can mix manual, scheduled,\nchained, and triggered boosts freely — they don't share IDs or interact\nexcept through the shared `Settings.StackingCaps`.\n\n---\n\n## TimedBoostEffectSpec\n\nThe one effect shape every boost kind uses, alone (`Effect`) or in a list\n(`Effects`).\n\n```ts\ninterface TimedBoostEffectSpec {\n Target?: string; // free-form modifier target (EventModifierTarget on the backend)\n Operation?: string; // \"Multiply\" | \"AddPercent\" | \"AddFlat\"\n Value?: number; // meaning depends on Operation\n}\n```\n\n`Operation` semantics: `Multiply` scales the target value by `Value` (e.g.\n`2` = double), `AddPercent` adds `Value` percent, `AddFlat` adds a flat\n`Value`. Multiple effects on the same `Target` combine per the stacking rules\nbelow and the title's `Settings.StackingCaps` for that target.\n\n---\n\n## Manual boosts (Definitions)\n\nThe only kind activated by the player, via `activate(boostID)`.\n\n```ts\ninterface TimedBoostDefinition {\n BoostID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n ActivationCost?: ResourceConsume; // charged by activate()\n Effect?: TimedBoostEffectSpec; // single effect (not a list, unlike the other 3 kinds)\n DurationSeconds?: number; // time-based expiry\n Charges?: number; // use-count-based expiry (independent of/alongside duration)\n StackingPolicy?: string; // \"Replace\" | \"Refresh\" | \"KeepBest\" | \"Stack\"\n MaxActiveInstances?: number; // cap on simultaneous instances of this BoostID\n Tags?: string[];\n}\n```\n\nA manual boost can expire by time (`DurationSeconds` → `ExpiresAtUtc`), by\nuse (`Charges` → `RemainingCharges` ticking down), or both — whichever runs\nout first ends it. `ActivationCost` follows the same `ResourceConsume` shape\nused across the SDK (see `_shared/ResourceModels.ts`), including\n`PremiumDiscounts` — the charged amount can be less than the displayed base\nif the player has a subscription tier.\n\n---\n\n## Scheduled boosts & boost chains (global windows)\n\nBoth are **global** — no per-player state, no `activate()` call. They're\nresolved server-side into \"what's open right now\" and read via\n`getActiveWindows()`.\n\n```ts\ninterface ScheduledBoostDefinition {\n ScheduledBoostID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tags?: string[];\n Schedule?: ScheduleSpec; // Mode = Scheduled — fixed windows, e.g. \"happy hour\"\n Effects?: TimedBoostEffectSpec[];\n Gate?: SegmentGate; // optional player-segment restriction\n CustomParams?: Record<string, string>;\n}\n\ninterface BoostChainDefinition {\n ChainID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode = Chained — cyclic sequence of phases\n Phases?: ChainedBoostDefinition[];\n Gate?: SegmentGate;\n CustomParams?: Record<string, string>;\n}\n\ninterface ChainedBoostDefinition {\n ChainedBoostID?: string;\n Order?: number; // position within the cycle\n DurationSec?: number; // how long this phase stays active\n Effects?: TimedBoostEffectSpec[];\n CustomParams?: Record<string, string>;\n}\n```\n\nA `BoostChainDefinition` cycles through its `Phases` in `Order`, each active\nfor its own `DurationSec`, then loops. `getActiveWindows()` tells you which\nphase (if any) is currently open, plus `CycleIndex`/`PhaseOrder` to locate it\nwithin the cycle. `Gate` (a `SegmentGate`) can restrict a scheduled boost or\nchain to specific player segments — a window can be \"open\" globally but not\napply to every player.\n\n---\n\n## Triggered boosts\n\nAuto-granted per-player when a configured source event fires — no manual\nactivation, but otherwise becomes a normal `ActiveTimedBoost` instance (same\nshape as a manual activation, appears in `getActive()`).\n\n```ts\ninterface TriggeredBoostDefinition {\n TriggeredBoostID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Tags?: string[];\n Sources?: TriggerSource[]; // what fires this (e.g. quest completion)\n Effects?: TimedBoostEffectSpec[];\n DurationSeconds?: number;\n Charges?: number;\n StackingPolicy?: string;\n MaxActiveInstances?: number;\n Gate?: SegmentGate;\n}\n```\n\nThere's no client method to invoke a triggered boost — it's granted\nserver-side as a consequence of another action (per `TriggerSource`). The\nclient only ever observes it appearing in `getActive()`'s `Active` map, with\n`SourceType: \"EventReward\"`-ish provenance recorded on `ActiveTimedBoost`\n(see below).\n\n---\n\n## Global settings & stacking caps\n\n```ts\ninterface TimedBoostGlobalSettings {\n StackingCaps?: Record<string, BoostStackCap>; // key = modifier Target\n}\n\ninterface BoostStackCap {\n MaxAddPercent?: number;\n MaxMultiply?: number;\n MaxAddFlat?: number;\n}\n```\n\nPer-`Target` ceilings on the _combined_ contribution across every\nsimultaneously-active effect touching that target (manual + triggered +\nscheduled + chain, all of it) — e.g. even if five boosts each add +50%\nsomewhere, the server clamps the effective total per `MaxAddPercent`. This is\nenforced entirely server-side; the client never computes the combined\nmodifier itself.\n\n---\n\n## Runtime state & responses\n\nPer-player active instances, cached at `client.data.user.state?.TimedBoost`:\n\n```ts\ninterface UserTimedBoostsState {\n Active?: Record<string, ActiveTimedBoost>; // key = InstanceID\n Version?: number;\n Triggers?: Record<string, BoostTriggerCounter>; // per-trigger daily counters (server-side bookkeeping)\n}\n\ninterface ActiveTimedBoost {\n InstanceID: string;\n BoostID: string;\n ActivatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RemainingCharges?: number;\n EffectSnapshot?: TimedBoostEffectSpec; // frozen copy of Effect at activation time\n SourceType?: string; // \"Activation\" | \"Admin\" | \"EventReward\"\n SourceRef?: string;\n}\n```\n\n`SourceType` tells you how an instance came to exist: `\"Activation\"` (player\ncalled `activate`), `\"Admin\"` (ops-granted), `\"EventReward\"` (a\n`TriggeredBoostDefinition` fired). All three share the same `Active` map and\n`ActiveTimedBoost` shape — the UI doesn't need to special-case triggered\nboosts once they're active.\n\nGlobal window read, not cached in `user.state` (returned directly by\n`getActiveWindows()`, re-fetch to refresh):\n\n```ts\ninterface ActiveBoostWindowInfo {\n Kind?: string; // \"Scheduled\" | \"Chain\"\n SourceID?: string; // ScheduledBoostID or ChainID\n PhaseID?: string; // set only for Kind = \"Chain\"\n CycleIndex?: number; // which cycle iteration, Chain only\n PhaseOrder?: number; // Order of the active phase, Chain only\n StartUtc?: string;\n EndUtc?: string;\n Effects?: TimedBoostEffectSpec[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n}\n```\n\n---\n\n## Stacking policy semantics\n\n`StackingPolicy` (on `TimedBoostDefinition`/`TriggeredBoostDefinition`)\ngoverns what happens when a **new instance of the same `BoostID`** would\nbecome active while one already is. It does **not** govern interaction\n_between different_ `BoostID`s targeting the same modifier — that's what\n`Settings.StackingCaps` is for.\n\n| Policy | Behavior when re-activated/re-triggered while already active |\n| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Replace` | Existing instance(s) of this `BoostID` are removed; the new one becomes the only one. |\n| `KeepBest` | Same removal behavior as `Replace` on the client cache — existing same-`BoostID` instances are dropped in favor of the new one. (Which one counts as \"best\" when cost/duration differ is a server decision; the client mirrors whatever the server returns as `ActivatedBoost`.) |\n| `Refresh` | The new instance is simply inserted alongside — in practice this is how a \"refresh the timer\" boost re-stamps its expiry, since the server is expected to reuse the same effective slot server-side; the client only ever inserts, it never predicts what the server did to existing entries. |\n| `Stack` | The new instance is inserted alongside existing ones with no removal — multiple concurrent instances of the same `BoostID`, each with its own `InstanceID`, `ExpiresAtUtc`, and `RemainingCharges`. |\n\nClient cache mechanics (`UserData.patchActiveTimedBoost`, exact behavior):\n\n- For `Replace` and `KeepBest`: every existing entry in `Active` whose\n `BoostID` matches the newly-activated boost's `BoostID` (and whose\n `InstanceID` differs from the new one) is deleted, then the new instance is\n inserted.\n- For `Refresh` and `Stack` (anything else): no deletion — the new instance\n is inserted directly, alongside whatever was already there.\n\nBecause this logic runs **only on `activate()`'s own response** (matching by\nthe just-activated boost's own `BoostID`), it never touches instances of a\n_different_ `BoostID`, and it never touches triggered-boost instances unless\nyou happen to activate a manual boost with the same `BoostID` (unlikely by\nconvention, but not enforced client-side). Always treat `ActivatedBoost` and\n`StackingPolicy` from the `activate()` response as authoritative for what the\nserver actually did — the client cache write is a straightforward mirror of\nthat decision, not an independent computation.\n\n### Worked example\n\nPlayer has `Active = { \"i1\": { BoostID: \"double-xp\", ExpiresAtUtc: T+1h } }`\nand calls `activate(\"double-xp\")` again:\n\n- `StackingPolicy: \"Replace\"` → `i1` is deleted, `Active` ends up with only\n the new instance (`i2`).\n- `StackingPolicy: \"Stack\"` → `Active` ends up with **both** `i1` and `i2`,\n each independently expiring; UI showing \"time remaining\" should sum or\n list them, not assume a single instance per `BoostID`.\n\nDesign UI around \"one boost can have N concurrent instances\" rather than\nassuming `BoostID` is unique in `Active` — only `Replace`/`KeepBest`-policy\nboosts are guaranteed unique.\n\n`KeepBest`'s \"better\" comparison is **magnitude-first**: it compares\n`|EffectSnapshot.Value|` between the candidate and the current best live\ninstance, and only falls back to comparing `ExpiresAtUtc` (longer TTL wins)\nwhen the magnitudes are equal. A `Replace`-style boost re-activated while\nalready active always produces a fresh `InstanceID` (the old one is deleted,\nnot reused) — don't key long-lived UI state off `InstanceID` surviving a\nreactivation.\n\n---\n\n## How effects resolve into a number (server-side)\n\nYou never compute this — it's documented here only so boost-preview UI\n(\"this will make your next roll worth X\") can explain what a multiplier does\nwithout inventing its own math. The blend of `AddFlat` / `AddPercent` /\n`Multiply` entries collected for a target (per-player boosts + active windows,\nalready through the `Settings.StackingCaps` clamp above) is applied by the\nshared `ModifierService` in a fixed order:\n\n1. `step1 = base + sum(AddFlat)`\n2. `step2 = step1 * max(0, 1 + sum(AddPercent))`\n3. `step3 = step2 * product(Multiply, Multiply, ...)`\n4. `final = Ceiling(step3)`, clamped to `[0, long.MaxValue]`\n\nE.g. `base=100` with one `AddFlat(10)`, one `AddPercent(0.5)`, one\n`Multiply(2.0)` → `(100+10) * 1.5 * 2.0 = 330`. Each individual `AddPercent`/\n`Multiply` entry is also clamped before entering the sum/product\n(`AddPercent` to `[-100%, +9900%]`, `Multiply` to `[0.01, 100]`) — a title\ncan't accidentally zero out or blow up a calculation with one bad config\nvalue. This whole pipeline runs inside the endpoint that actually performs\nthe boosted action (e.g. GameLoop's roll/attack resolution) — TimedBoost only\nsupplies the raw effect entries via `BuildModifierEntries`; it never runs the\nmath itself for a gameplay call, and neither should the client.\n\n---\n\n## Triggered boosts are granted by other modules, not TimedBoost itself\n\n`TimedBoostV2` (the HTTP surface this SDK talks to) only implements\n`GetDefinitions` / `GetActive` / `GetActiveWindows` / `Activate` /\n`CleanupExpired` — there is no endpoint to \"fire\" a trigger. The actual grant\nhappens inside whichever module's action produced the triggering event: that\nmodule's handler calls the shared `TimedBoostService.BuildTriggeredGrants(...)`\n(backend domain helper, not the client-facing `TimedBoostService.ts`) with the\nevent's `TriggerSource` context, folds the resulting patches into its own\natomic write, and — if anything was granted — consumes charges off any\nalready-active charge-based boosts that applied to the same action via\n`BuildChargeConsume`. The client's only visibility into any of this is the\n`Active` map changing between calls to `getActive()`; there's nothing to\nsubscribe to at the moment of the trigger itself, so poll/refresh\n`getActive()` after actions that plausibly grant a triggered boost (a quest\ncompletion, a board-loop roll, etc.) if your UI wants to surface \"you got a\nbonus boost!\" promptly.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "timed-event-system",
|
|
3
|
+
"description": "Build a time-boxed live-ops event system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService): load active events and their title-wide definitions, read the player's per-event-instance token progress, spend event tokens (single + batch), grant event tokens (single + batch, server/trigger-driven), and claim milestone rewards as token balances cross thresholds (single, batch, and claim-all). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants limited-time events, seasonal/live-ops event screens, event-token or event-currency progress bars, milestone/reward-track UIs, chain/rotation events, Coin-Master-style bonus windows, or otherwise touches client.timedEvent, TimedEventService, TimedEventDefinitions, ActiveEventInfo, EventTokenProgress, or MilestoneDefinition — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: timed-event-system\ndescription: >-\n Build a time-boxed live-ops event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService):\n load active events and their title-wide definitions, read the player's\n per-event-instance token progress, spend event tokens (single + batch),\n grant event tokens (single + batch, server/trigger-driven), and claim\n milestone rewards as token balances cross thresholds (single, batch, and\n claim-all). Use this whenever the user is working in the iDosGames TS SDK\n or its game templates (board-game, idle-rpg) and wants limited-time\n events, seasonal/live-ops event screens, event-token or event-currency\n progress bars, milestone/reward-track UIs, chain/rotation events,\n Coin-Master-style bonus windows, or otherwise touches client.timedEvent,\n TimedEventService, TimedEventDefinitions, ActiveEventInfo,\n EventTokenProgress, or MilestoneDefinition — even if they don't name the\n module explicitly.\n---\n\n# Timed event system (iDosGames TS SDK)\n\nThe TimedEvent module runs live-ops \"limited time events\" (LTEs): a title\nschedules an event (a single window, or a repeating chain of phases), players\nearn an **event token** by doing things the event tracks (`TokenSources`), and\nas their token balance crosses configured thresholds they unlock and claim\n**milestone** rewards. A single event can also carry a cyclic \"bonus window\"\n(Coin-Master-style boosted-reward periods). Everything is\n**server-authoritative**: the client asks to spend/grant tokens or claim a\nmilestone, the backend validates and applies it, and the SDK mirrors the\nconfirmed result into a local cache your UI reads. You never mutate event\nstate yourself — you call a method, check the result, and render from the\ncache.\n\nThis skill is for **using** the production `TimedEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (event not active, threshold not reached, already claimed, claim window\nexpired) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n every event that can run: schedule (`ScheduleSpec`, `Mode` = `Scheduled` |\n `Chained`), display content, the event's token definition, `TokenSources`\n (what earns it), milestones, and any bonus window. A `Chained` event also\n carries `Events`: an ordered list of phases, each with its own duration and\n `Content`. Fetched with `getDefinitions()`.\n2. **Active events + user state** (state, per player) — which event\n **instances** are currently live or still claimable for this player\n (`getActiveEvents()`) and this player's raw per-instance token progress\n (`getUserLteState()`). Both cache separately; the milestone UI should read\n from the **active-events cache**, not the raw token cache (see Gotchas).\n\nAn event is identified by `Type` (`ScheduleMode`: `\"Scheduled\"` |\n`\"Chained\"`) + `LteID` (the `TimedEventID`). A `Scheduled` event has exactly\none instance (its window). A `Chained` event repeats as a sequence of\nphase-instances, each keyed by `(CycleIndex, ChainedEventID)` — the same\n`LteID` produces a new token bucket, a new milestone-claim state, and a new\nbonus-window timeline **every time the chain advances**. Batch refs\n(`TimedEventInstanceRef`) carry all four fields (`Type`, `LteID`,\n`CycleIndex`, `ChainedEventID`) so you can address one specific instance, e.g.\nto claim a just-ended chain phase during its grace window while the next\nphase is already active.\n\nFor the full field-by-field shape of Definitions, milestones, the\nper-instance token progress cache, the composite-key scheme, and the\nself-heal behavior on stale milestone state, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config or to reason\nabout chain/cycle edge cases.\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 timedEvents = client.timedEvent; // the TimedEventService\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\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. `\"Event not\nfound or not active.\"`, `\"Milestone already claimed.\"`, `\"Claim window has\nexpired.\"`).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------- |\n| `getActiveEvents()` | Load the event instances currently live/claimable for this player. | `GetActiveEventsResponse` (`ActiveEvents[]`) |\n| `getDefinitions()` | Load the title's full event catalog (config). | `TimedEventDefinitions` |\n| `getUserLteState()` | Load this player's raw per-instance token balances (state). | `UserTimedEventStateResponse` (`Tokens`) |\n| `spendTokens(type, lteID, spendAmount)` | Spend tokens on the current (or an addressed) event instance. | `EventTokenSpendResponse` (`Resources`) |\n| `spendTokensBatch(spends)` | Spend tokens across several event instances in one atomic call. | `SpendTokensBatchResponse` |\n| `grantTokens(type, lteID, sourceType, outcome?, amountOverride?, sourceParams?, rollMultiplier?)` | Report a `TokenSources` trigger firing; server computes/validates the earn. | `ResourceOperation` |\n| `grantTokensBatch(grants)` | Grant tokens across several event instances in one atomic call. | `GrantTokensBatchResponse` |\n| `claimMilestone(type, lteID, milestoneID)` | Claim one reached milestone's reward. | `EventMilestoneClaimResponse` (`Rewards`) |\n| `claimMilestonesBatch(milestones)` | Claim several specific milestones in one atomic call. | `ClaimMilestonesBatchResponse` |\n| `claimAllMilestones(events?)` | Claim every reached-but-unclaimed milestone (all active events if omitted). | `ClaimMilestonesBatchResponse` |\n\n`spendTokens` requires `spendAmount > 0` (validated client-side before the\ncall, mirrored by the server). `grantTokens`/`grantTokensBatch` are\n**trigger-reporting** calls, not a raw \"give me N tokens\" endpoint — see\nGotchas below before wiring these up from arbitrary UI actions.\n\n`spendTokens`/`grantTokens` resolve to the event's **currently active\ninstance** by default (`Type` + `LteID` only). To target a specific\nalready-ended chain instance (e.g. claiming during its grace window while a\nnew phase is already active), pass `CycleIndex` + `ChainedEventID` — the\nsingle-call methods don't expose these directly, but the batch ref types\n(`TimedEventInstanceRef` and its `TimedEventSpendRef`/`TimedEventGrantRef`/\n`TimedEventMilestoneRef` subtypes) do. Read `CurrentCycleIndex` +\n`CurrentChainedEventID` off the relevant `ActiveEventInfo` (from\n`getActiveEvents()`) to get the right values — don't compute them yourself.\n\nOn success, each method mirrors the confirmed change into the cache and emits\nan event — you don't apply anything by hand. Rewards/consumed resources ride\nin `data.Resources` / `data.Rewards` as a `ResourceOperation` (see the\ncurrency-system skill for the shared `ResourceConsume`/`ResourceGrant`/\n`ResourceOperation` shapes) and are already applied to cached balances, so\nread updated balances straight from the cache.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Active event instances (only present after getActiveEvents()); this is the\n// milestone-UI source of truth — its Progress.Milestone.ClaimedIDs is kept\n// in sync by every claim method, batch or not, and is self-healed on read\n// (see Gotchas / references/data-model.md).\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst evt = active.find((e) => e.TimedEventID === \"summer_sale\");\nevt?.Progress; // UserEventTokenProgress: Balance / Milestone / Daily / Meta\nevt?.CanEarn; // server's \"tokens can still be earned\" flag\nevt?.CanClaim; // server's \"still inside the claim/grace window\" flag\nevt?.NextMilestone; // next unclaimed MilestoneDefinition, ordered by RequiredProgress\nevt?.BonusWindow; // computed BonusWindowState, if the event has one\nevt?.CurrentCycleIndex; // Chained only\nevt?.CurrentChainedEventID; // Chained only — which phase is active/ended-in-grace\n\n// Raw per-instance token state (only present after getUserLteState()):\nconst tokens = client.data.user.state?.EventToken?.TimedEvent ?? {};\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedEvent:activeEventsLoaded` → `GetActiveEventsResponse`\n- `timedEvent:definitionsLoaded` → `TimedEventDefinitions`\n- `timedEvent:userStateLoaded` → `UserTimedEventStateResponse`\n- `timedEvent:tokensSpent` → `EventTokenSpendResponse`\n- `timedEvent:tokensSpentBatch` → `SpendTokensBatchResponse`\n- `timedEvent:tokensGranted` → `ResourceOperation`\n- `timedEvent:tokensGrantedBatch` → `GrantTokensBatchResponse`\n- `timedEvent:milestoneClaimed` → `EventMilestoneClaimResponse`\n- `timedEvent:milestonesClaimedBatch` → `ClaimMilestonesBatchResponse`\n- `timedEvent:allMilestonesClaimed` → `ClaimMilestonesBatchResponse`\n\nThe coarse `user:timedEventUpdated` (and umbrella `user:anyUpdated`) also fire\non any timed-event cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedEvent:milestoneClaimed\", (r) => {\n console.log(`${r.LteID} milestone ${r.MilestoneID} claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show active events with a progress bar and milestone track\n\n```ts\nawait client.timedEvent.getDefinitions();\nawait client.timedEvent.getActiveEvents();\n\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\n\nfor (const evt of active) {\n const def = defs?.Definitions?.[evt.TimedEventID ?? \"\"];\n const earned = evt.Progress?.Balance?.TotalEarned ?? 0; // milestone math uses TotalEarned, not Current\n const claimedIDs = evt.Progress?.Milestone?.ClaimedIDs ?? [];\n const milestones = evt.Content?.Milestones ?? def?.Content?.Milestones ?? {};\n // Render each milestone: reached = earned >= milestone.RequiredProgress,\n // claimed = claimedIDs.includes(milestoneID). evt.NextMilestone is the\n // cheapest way to highlight the very next unclaimed one.\n}\n```\n\n`ActiveEventInfo.Content` already carries the resolved display/token/\nmilestone content for the current (or current chained) instance — you\nusually don't need to fall back to `def.Content`, but it's there for\nreference. Milestone thresholds (`RequiredProgress`) are checked against\n`Balance.TotalEarned` (lifetime earned in this instance), **not**\n`Balance.Current` (the spendable balance) — spending tokens never un-reaches\na milestone already earned.\n\n### Spend tokens, then claim a milestone\n\n```ts\nconst spend = await client.timedEvent.spendTokens(\n \"Scheduled\",\n \"summer_sale\",\n 50,\n);\nif (!spend.ok) return showError(spend.error);\n// balances already debited/credited in the cache via spend.data.Resources.\n\nconst claim = await client.timedEvent.claimMilestone(\n \"Scheduled\",\n \"summer_sale\",\n \"milestone_3\",\n);\nif (!claim.ok) return showError(claim.error); // e.g. \"Milestone already claimed.\", \"Not enough earned...\"\n// claim.data.Rewards already applied; ActiveEventInfo.Progress.Milestone.ClaimedIDs updated.\n```\n\n### Claim every reached milestone in one tap (\"claim all\")\n\n```ts\n// Sweeps all active + grace-window-claimable events:\nconst res = await client.timedEvent.claimAllMilestones();\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Scheduled:summer_sale:s202607010000:milestone_2\"\n else showItemError(item.Id, item.Error); // not reached / already claimed\n}\n\n// Or scope it to specific event instances:\nawait client.timedEvent.claimAllMilestones([\n { Type: \"Scheduled\", LteID: \"summer_sale\" },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n },\n]);\n```\n\n`claimAllMilestones` and `claimMilestonesBatch` share the same response shape\n(`ClaimMilestonesBatchResponse`) and the same cache-patch logic — pick\n`claimAllMilestones` for a \"claim everything\" button and\n`claimMilestonesBatch` when the player picked specific milestones. Both also\nrespect `Content.ClaimMode` (`Instant` | `AfterEventEnd` | `FeaturedAfterEnd`)\n— a milestone whose mode isn't satisfied yet is simply not discovered/offered\nby `claimAllMilestones`, or rejected with an explicit error by the targeted\ncalls.\n\n### Batch spend across several events\n\n```ts\nconst res = await client.timedEvent.spendTokensBatch([\n { Type: \"Scheduled\", LteID: \"summer_sale\", SpendAmount: 20 },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n SpendAmount: 10,\n },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (!item.Success) showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that item applied.\nThe resource charge across the whole batch is merged into one atomic backend\noperation — see the character-system skill's batch recipe for the same\nall-or-nothing charge semantics, which apply here too. Up to 50 entries per\nbatch call (`ClaimMilestonesBatch`/`SpendTokensBatch`/`GrantTokensBatch`);\nentries past that are silently dropped before processing — chunk larger sets\nyourself.\n\n### Handle a chained event's cycle rollover\n\n```ts\nawait client.timedEvent.getActiveEvents();\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst raid = active.filter((e) => e.TimedEventID === \"raid_rotation\");\n\n// getActiveEvents() can return TWO entries for the same chained LteID at once:\n// the currently active phase (CanEarn true) and a just-ended phase still\n// inside its ClaimGraceHours window (CanEarn false, CanClaim true). Render\n// both: a \"claim before it's gone\" card for the grace one, the live progress\n// bar for the active one.\nfor (const phase of raid) {\n if (phase.CanClaim && !phase.CanEarn) {\n // Ended, in grace — claim using ITS OWN CycleIndex/ChainedEventID so you\n // don't accidentally resolve to the newly-active phase instead.\n await client.timedEvent.claimAllMilestones([\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: phase.CurrentCycleIndex,\n ChainedEventID: phase.CurrentChainedEventID,\n },\n ]);\n }\n}\n```\n\nEach chain instance (a given `(CycleIndex, ChainedEventID)` pair) has its own\ntoken bucket, so progress and milestone-claimed state reset to zero when the\nchain advances to a new phase or cycle — that's expected, not a bug. See\n[references/data-model.md](references/data-model.md) for the exact key\nformat and the grace-window math.\n\n### Reporting a gameplay trigger that earns tokens\n\n```ts\n// e.g. the player finished a board-loop tile that this event's TokenSources\n// lists as a SourceType. The server validates the trigger against the\n// event's TokenSources filters and computes the actual earned amount.\nconst res = await client.timedEvent.grantTokens(\n \"Scheduled\",\n \"summer_sale\",\n \"CustomAction\",\n /* outcome */ undefined,\n /* amountOverride */ undefined,\n { ActionName: \"daily_puzzle_solved\" },\n);\nif (!res.ok) return showError(res.error);\n```\n\nOnly a handful of `SourceType` values are callable from the client at all\n(`CustomAction`, `DailyLogin`, `ReferralInvite`) — see Gotchas. Gameplay\nsources like board tiles, quest completions, or store purchases are reported\nby the corresponding server module internally; you don't call `grantTokens`\nfor those from client code.\n\n## Gotchas\n\n- **`grantTokens`/`grantTokensBatch` are trigger reports, not a free-mint\n endpoint, and most `SourceType`s are server-only.** The backend rejects\n `BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`,\n `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`, `QuestComplete`,\n `StorePurchase`, `MilestoneReward`, `MarketplaceSell`, and `MarketplaceBuy`\n outright with `\"Source '<type>' cannot be called from client.\"` — only\n `CustomAction`, `DailyLogin`, and `ReferralInvite` are callable from the\n client. Even for those, the server matches your `sourceType`/`outcome`/\n `sourceParams` against the event's configured `TokenSources` and computes\n the actual earned amount itself (`amountOverride` only takes effect if\n present and positive, and is still subject to caps) — never assume the\n client-requested amount is what lands in the balance.\n- **Two separate caches, one source of truth for milestones.** `getActiveEvents()`\n populates `client.data.user.getCachedActiveEvents()`; `getUserLteState()`\n populates `client.data.user.state?.EventToken?.TimedEvent`. Every claim\n method patches **both** (matched by composite key, see\n [references/data-model.md](references/data-model.md)), but the\n active-events cache is the one with `CanEarn`/`CanClaim`/`NextMilestone`/\n `BonusWindow` — build milestone/progress UI off that one, and treat the raw\n token cache as a secondary/debug view.\n- **Milestone state self-heals against `TotalEarned`, not against wall-clock\n cycle boundaries.** On every `getActiveEvents()` call, the server strips any\n `ClaimedIDs`/`UnlockedIDs` entry from a bucket whose milestone's\n `RequiredProgress` **exceeds that bucket's own `Balance.TotalEarned`** —\n i.e. an id that shouldn't be possible given the bucket's own recorded\n earnings (leftover/inconsistent state, e.g. from before per-instance\n keying). Because `TotalEarned` only ever increases, a milestone you\n legitimately claimed (earned >= threshold at claim time) can never be\n swept by this — the self-heal only removes ids that are inconsistent with\n their own bucket's balance. It is best-effort and silently\n self-corrects the DB in the background (a failed cleanup just retries on\n the next `getActiveEvents()` poll) — you don't need to handle it, but don't\n be surprised if a `NextMilestone` you saw as claimed reappears as\n claimable after a refresh if the state was stale.\n- **Chain instances are independent buckets — progress does not carry over.**\n A `Chained` event's token balance, milestone-claimed list, and bonus-window\n timeline are all keyed by `(LteID, CycleIndex, ChainedEventID)`. Moving to\n the next phase or cycle starts that instance's progress at zero; this is by\n design, not a reset bug to work around client-side.\n- **Grace windows keep an ended instance claimable, not earnable.** Once a\n `Scheduled` event's `EndUtc` or a chain phase's end passes, `CanEarn`\n becomes `false` immediately, but `CanClaim` stays `true` until\n `EndUtc + ClaimGraceHours` (`ClaimGraceHours` comes from the event's/phase's\n own config; 0 = no post-end claiming at all). `getActiveEvents()` will\n surface an ended-but-in-grace chain instance as its own entry alongside the\n newly active one — always claim it via its own `CycleIndex` +\n `ChainedEventID`, not the currently-active phase's.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Claim\" can\n attempt to claim twice (the second will simply fail with \"Milestone already\n claimed.\", but a double-clicked spend can genuinely spend twice). Disable\n the control while a call is in flight. Firing the same endpoint again\n within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\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\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the composite event-token key scheme, the milestone self-heal\nrule, grace-window math, and the bonus-window model. Read it when building\nconfig-driven UI (progress bars, milestone tracks, bonus-window countdowns)\nor when you need to understand how per-instance token progress is keyed and\nmatched.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Timed-event data model — reference\n\nFull shape of the config (Definitions) and player state, the composite\nevent-token key scheme, the milestone self-heal rule, grace-window math, and\nthe bonus-window model. All of these are **strictly typed in the SDK** —\n`TimedEventDefinitions` and every nested block (`TimedEventDefinition`,\n`ChainedEventDefinition`, `EventContent`, `BonusWindowConfig`,\n`ActiveEventInfo`, …) are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<TimedEventDefinitions>(\"TimedEvent\")` give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\nEvery claim in this file traces to a specific backend source line — cited\ninline as `(file:line)` against the iDos_Games_Engine repo.\n\n## Contents\n\n- [Config: TimedEventDefinitions](#config-timedeventdefinitions)\n- [TimedEventDefinition (Scheduled vs Chained)](#timedeventdefinition-scheduled-vs-chained)\n- [EventContent](#eventcontent)\n- [Player state: UserEventTokenProgress](#player-state-usereventtokenprogress)\n- [ActiveEventInfo (getActiveEvents response)](#activeeventinfo-getactiveevents-response)\n- [The composite instance-key scheme](#the-composite-instance-key-scheme)\n- [Grace windows and claim-only instances](#grace-windows-and-claim-only-instances)\n- [Milestone claim rules and the self-heal on read](#milestone-claim-rules-and-the-self-heal-on-read)\n- [Bonus window (Coin-Master-style)](#bonus-window-coin-master-style)\n- [Token sources, matching, and grant math](#token-sources-matching-and-grant-math)\n- [Server-side limits, batching, and idempotency](#server-side-limits-batching-and-idempotency)\n\n---\n\n## Config: TimedEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\")`.\n\n```ts\ninterface TimedEventDefinitions {\n Definitions?: Record<string, TimedEventDefinition>; // key = TimedEventID\n Settings?: LimitedTimeEventsGlobalSettings;\n}\n\ninterface LimitedTimeEventsGlobalSettings {\n MaxConcurrentEvents?: number; // config-mistake guard; default 5\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/TimedEventDefinitions.cs:27-58`)\n\n---\n\n## TimedEventDefinition (Scheduled vs Chained)\n\nOne dictionary holds both kinds; the mode lives in `Schedule.Mode`.\n\n```ts\ninterface TimedEventDefinition {\n TimedEventID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode: \"Scheduled\" | \"Chained\"\n Content?: EventContent; // used when Mode = Scheduled\n Events?: ChainedEventDefinition[]; // used when Mode = Chained\n Gate?: SegmentGate; // audience gate; null = everyone\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:72-130`)\n\n- **Scheduled**: one fixed window (`Schedule.Scheduled: ScheduledWindow` —\n `StartUtc`, `EndUtc`, `AllowEarningAfterEnd`, `ClaimGraceHours`). Content\n lives directly on `Content`.\n- **Chained**: a repeating ordered list of phases (`Events`), timed by\n `Schedule.Chain: ScheduleChain` (`AnchorUtc`, `MaxCycles`,\n `PauseBetweenPhasesSec`, `PauseBetweenCyclesSec`). Each phase has its own\n `Content`. After the last phase, the whole cycle restarts from phase 0\n (unless `MaxCycles` caps the number of repeats).\n\n```ts\ninterface ChainedEventDefinition {\n ChainedEventID?: string; // unique within the chain\n Order?: number; // 0-based position; defines phase sequence\n DurationSec?: number;\n Content?: EventContent;\n ClaimGraceHours?: number; // 0 = no claiming once this phase ends\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:138-180`)\n\n`Gate` is the standard `SegmentGate` (Core/Segment) — `Segments`,\n`MinPremiumTier`, `RequiredPremiumIDs`, `MinLevel`/`MaxLevel`, `Countries`,\n`RegisteredWithinDays`, `ActiveWithinDays`, `Experiment`. A player failing the\ngate does not see the event in `getActiveEvents()` and cannot earn or spend\nits tokens — `GrantTokensInternal` re-checks the gate server-side even if a\nstale client tries to call it directly\n(`IDosGamesSDK/API/Client/v2/TimedEvent/TimedEvent.cs:325-329`).\n\n---\n\n## EventContent\n\nShared shape used by both a `Scheduled` event's `Content` and each\n`ChainedEventDefinition.Content`.\n\n```ts\ninterface EventContent {\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Category?: string; // free-form UI grouping tag\n Token?: EventTokenDefinition; // the event token's own config\n TokenSources?: TriggerSource[]; // whitelist of what earns this token\n ClaimMode?: \"Instant\" | \"AfterEventEnd\" | \"FeaturedAfterEnd\";\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID\n BonusWindow?: BonusWindowConfig; // null = disabled for this event\n}\n```\n\n(`TimedEventDefinitions.cs:192-280`, `Core/Milestone/Models/MilestoneClaimMode.cs:14-36`)\n\n`EventTokenDefinition` (`_shared/EventTokenDefinitionModels.ts`, port of\n`Core/Event/Models/EventTokenModels.cs:399-453`):\n\n```ts\ninterface EventTokenDefinition {\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxBalance?: number; // 0 = unlimited spendable balance cap\n MaxPerGrant?: number; // per-grant clamp; default 1000 server-side\n DailyEarnCap?: number; // 0 = unlimited daily earn total\n BurnOnEventEnd?: boolean; // default true — balance zeroed at event end\n BurnConversion?: EventTokenConversion; // optional leftover→currency conversion\n}\n```\n\n`MilestoneDefinition` is the shared Core/Milestone primitive (also used by\nLeaderboard/Quest/CommunityChest/DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against Balance.TotalEarned\n Rewards?: ResourceGrant; // base reward\n BonusRewards?: ResourceGrant; // added/scaled in during an active bonus window\n SeasonTierRewards?: SeasonTierRewardSet; // not used by TimedEvent\n SortOrder?: number;\n IsFeatured?: boolean; // gates FeaturedAfterEnd behavior\n}\n```\n\n(`Core/Milestone/Models/MilestoneDefinition.cs` via `_shared/MilestoneModels.ts:125-138`)\n\n`TriggerSource` (shared `_shared/ScheduleModels.ts:83-96`, port of\n`Core/Scheduling/Models/TriggerSource.cs`):\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // EventTokenSourceType, e.g. \"BoardTileLanding\"\n BaseWeight?: number; // tokens granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's roll multiplier\n TileTypeFilter?: string[]; // BoardTileLanding only; empty = any\n TileIndexFilter?: number[]; // BoardTileLanding only; empty = any\n ChanceOutcomeFilter?: string[]; // BoardTileLanding Chance tiles only; empty = any\n OutcomeFilter?: string[]; // checked for every source type; empty = any\n Params?: Record<string, string>; // CustomAction: ActionName; Marketplace*: CatalogID/ItemID/OfferType\n Limits?: LimitSpec; // DailyCap / DailyWeightCap / CooldownSeconds\n}\n```\n\n---\n\n## Player state: UserEventTokenProgress\n\nReturned inside `getUserLteState()`'s `Tokens` map and inside each\n`ActiveEventInfo.Progress`.\n\n```ts\ninterface UserEventTokenProgress {\n Balance?: {\n Current: number; // spendable balance; rises on grant, falls on spend\n TotalEarned: number; // lifetime earned in THIS instance; monotonic; milestone math uses this\n TotalSpent: number; // lifetime spent in this instance; analytics only\n };\n Daily?: {\n Date: string; // UTC date the counters below apply to; lazy-reset on next grant\n TotalEarned: number;\n EarnedBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyWeightCap\n TriggersBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyCap\n LastTriggerBySource?: Record<string, string>; // vs TriggerSource.Limits.CooldownSeconds; NOT reset daily\n };\n Meta?: {\n JoinedAtUtc?: string; // first grant into this instance's bucket\n LastEarnedAtUtc?: string;\n };\n Milestone?: {\n ClaimedIDs?: string[];\n UnlockedIDs?: string[]; // reached but not yet claimable under AfterEventEnd/FeaturedAfterEnd\n };\n}\n```\n\n(`Core/Event/Models/EventTokenModels.cs:51-179`, mirrored in SDK\n`_shared/EventTokenState.ts:8-38`)\n\nImportant: **spending tokens never affects `TotalEarned`**\n(`EventTokenService.ComputeSpend`, `EventTokenService.cs:311-337` only\ntouches `Balance.Current`/`Balance.TotalSpent`), so a milestone earned and\nthen \"un-afforded\" by spending remains claimable/claimed — milestones track\nlifetime earning, not current balance.\n\n---\n\n## ActiveEventInfo (getActiveEvents response)\n\n```ts\ninterface ActiveEventInfo {\n Type?: \"Scheduled\" | \"Chained\";\n TimedEventID?: string;\n CurrentChainedEventID?: string | null; // null for Scheduled\n Content?: EventContent | null; // resolved content for the current/ended instance\n Progress?: UserEventTokenProgress | null;\n ComputedStartUtc?: string | null;\n ComputedEndUtc?: string | null;\n CanEarn?: boolean | null; // tokens can still be granted for this instance\n CanClaim?: boolean | null; // still inside claim/grace window\n NextMilestone?: MilestoneDefinition | null; // lowest RequiredProgress not yet in ClaimedIDs\n BonusWindow?: BonusWindowState | null; // computed; null = no window / disabled\n CurrentCycleIndex?: number | null; // Chained only\n CurrentEventOrder?: number | null; // Chained only: 1-based position... (see note)\n TotalEventsInChain?: number | null; // Chained only\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/UserTimedEventState.cs:23-78`)\n\nNote: the backend populates `CurrentEventOrder` from\n`ChainedEventDefinition.Order`, which is documented as 0-based\n(`TimedEventDefinitions.cs:148-152`) — the SDK's own doc-comment calling it\n\"1-based\" is aspirational UI framing, not a code guarantee; treat it as \"the\nphase's configured `Order` value\" and don't assume it starts at 1.\n\n`getActiveEvents()` can return **more than one `ActiveEventInfo` for the same\n`Chained` `TimedEventID`** in a single response: the currently active phase,\nplus any phase(s) that already ended but are still inside their\n`ClaimGraceHours` window (`CanEarn: false`, `CanClaim: true`)\n(`TimedEvent.cs:149-169`, `EnumerateEndedInGraceChainInstances`,\n`TimedEvent.cs:801-836`). Disambiguate them by `CurrentCycleIndex` +\n`CurrentChainedEventID`.\n\n---\n\n## The composite instance-key scheme\n\nEvery event **instance** — not just every event — gets its own progress\nbucket, milestone-claimed list, and (for chains) bonus-window timeline. The\nbucket key (`EventTokenAddress.EntityID`, stored under\n`UserDataDocument.EventToken.TimedEvent[EntityID]`) is:\n\n```\nEntityID = \"{TimedEventID}:{InstanceKey}\"\n```\n\n(`TimedEvent.cs:1044-1057`, `BuildTokenAddress`)\n\nWhere `InstanceKey` depends on the resolved mode\n(`Core/Scheduling/Services/ScheduleInstanceKey.cs:14-23`):\n\n| Mode | `InstanceKey` format | Example |\n| ----------- | ------------------------------ | --------------- |\n| `AlwaysOn` | `\"all\"` | `all` |\n| `Scheduled` | `\"s{yyyyMMddHHmm}\"` (StartUtc) | `s202607010000` |\n| `Chained` | `\"{cycleIndex}:{phaseID}\"` | `4:boss_phase` |\n\nSo a `Scheduled` event's `EntityID` is effectively\n`\"summer_sale:s202607010000\"`, and a `Chained` event's is\n`\"raid_rotation:4:boss_phase\"`. This is why re-running the same\n`TimedEventID` (a new Scheduled window with a different `StartUtc`, or the\nnext chain cycle) starts every player at a fresh `Balance`/`Milestone`\nbucket — nothing carries over, by design.\n\nThe SDK's `UserTimedEventStateResponse.Tokens` map uses these same composite\nkeys. Cache helpers that need to find \"the bucket for this `LteID`, whatever\nits current instance suffix is\" use `matchesBase(key, lteID)`\n(`packages/core/src/util/eventTokenIds.ts:4-6`): a key belongs to a base id\nif it equals it exactly or starts with `\"{lteID}:\"`. `getUserLteState()` is a\nflat dump of every bucket the player has ever touched (including stale\nfinished instances) — don't assume one entry per `LteID`.\n\n---\n\n## Grace windows and claim-only instances\n\nOnce an instance's window ends, tokens can no longer be earned\n(`CanEarn` flips to `false`), but the milestone rewards already reached can\nstill be claimed until a grace deadline:\n\n```\nClaimDeadlineUtc = EndUtc + ClaimGraceHours\n```\n\n- `Scheduled`: `ClaimGraceHours` comes from `Schedule.Scheduled.ClaimGraceHours`\n (`TimedEvent.cs:728`). `AllowEarningAfterEnd` (also on `ScheduledWindow`)\n lets earning continue past `EndUtc` if set — independent of the grace\n window, which only governs _claiming_.\n- `Chained`: `ClaimGraceHours` comes from the specific\n `ChainedEventDefinition.ClaimGraceHours` (`TimedEvent.cs:718,773,826`) —\n each phase can have its own grace period. `AllowEarningAfterEnd` is always\n `false` for chain phases (`TimedEvent.cs:719`) — earning always stops the\n instant the phase ends.\n- `now > ClaimDeadlineUtc` ⇒ the instance is gone entirely: `ResolveScheduled`\n / `ScheduleResolver.ResolveChainInstance` return `null`\n (`Core/Scheduling/Services/ScheduleResolver.cs:127-145,361-406`), and any\n spend/grant/claim call against it fails with `\"Event not found or not\nactive.\"` / `\"...not in claim window.\"`.\n\n`EnumerateEndedInGraceChainInstances` walks backward through past chain\ncycles (hard-capped at 200 lookback instances,\n`ScheduleResolver.cs:414-484`) collecting every phase whose\n`now ∈ (EndUtc, EndUtc + ClaimGraceHours]`, **only for instances where the\nplayer has existing progress** (`TimedEvent.cs:156-159` — buckets with no\nprogress are skipped, so a phase the player never touched doesn't clutter\nthe active-events list). These are returned with `CanEarn: false,\nCanClaim: true` and must be addressed by their own `CycleIndex` +\n`ChainedEventID` when spending/claiming (`ResolveEventFromArgs`,\n`TimedEvent.cs:672-686`, only takes the explicit-instance path when **both**\n`CycleIndex` and `ChainedEventID` are supplied — omitting either resolves to\nwhatever instance is currently active instead).\n\n---\n\n## Milestone claim rules and the self-heal on read\n\n**Claim gate** (`ClaimMilestone`, `TimedEvent.cs:518-658`, and the batch\npaths mirror this via `CheckMilestoneClaimMode`, `TimedEvent.cs:1767-1775`):\n\n1. The resolved instance must have `CanClaim: true` (inside its window or\n grace), else `\"Claim window has expired.\"`.\n2. The milestone id must exist in the resolved content's `Milestones`, else\n `\"Milestone '<id>' not found.\"`.\n3. `Content.ClaimMode` gate:\n - `Instant` — always allowed once reached.\n - `AfterEventEnd` — rejected with `\"Milestone can only be claimed after\nevent ends.\"` until `now > EndUtc`.\n - `FeaturedAfterEnd` — same rejection (`\"Featured milestone can only be\nclaimed after event ends.\"`) but **only** when `MilestoneDefinition.IsFeatured\n=== true`; non-featured milestones under this mode behave like `Instant`.\n4. `EventTokenService.ComputeMilestoneClaim` (`EventTokenService.cs:343-366`):\n fails with `\"No progress for this event token.\"` if the bucket doesn't\n exist at all, `\"Not enough earned. Have: {X}, need: {Y}.\"` if\n `Balance.TotalEarned < RequiredProgress`, or `\"Milestone already\nclaimed.\"` if the id is already in `ClaimedIDs`.\n\n**Self-heal on `GetActiveEvents` read** (`SanitizeMilestoneState`,\n`TimedEvent.cs:1070-1131`, invoked from `BuildActiveEventInfo` at\n`TimedEvent.cs:1143` and staged as background `$pullAll` patches at\n`TimedEvent.cs:112-187`):\n\n- Trigger condition: for the **specific instance bucket being read**, any id\n present in that bucket's `Milestone.ClaimedIDs` or `Milestone.UnlockedIDs`\n whose corresponding `MilestoneDefinition.RequiredProgress` is **greater\n than that same bucket's own `Balance.TotalEarned`** is stale. An id with no\n matching entry in the resolved content's `Milestones` dictionary is also\n stripped (nothing to verify it against). The check is\n `totalEarned >= def.RequiredProgress` per id\n (`TimedEvent.cs:1076-1080`, local function `Reached`).\n- Why it's safe: `TotalEarned` is monotonically non-decreasing\n (`EventTokenService.ComputeGrant` only ever increments it,\n `EventTokenService.cs:226,271,283`), so a milestone legitimately claimed\n (earned had already reached the threshold _at claim time_) can never later\n have `TotalEarned` fall back below `RequiredProgress`. The only ids this\n can strip are ones inconsistent with their own bucket's recorded earnings\n — e.g. leftover data from before per-instance keying was introduced, not\n anything a normal claim flow can produce.\n- Effect: the returned `ActiveEventInfo.Progress.Milestone.ClaimedIDs`/\n `UnlockedIDs` (and therefore `NextMilestone`, which is computed from the\n sanitized `ClaimedIDs`) are already clean in the response you receive — you\n never see the stale ids. Separately, the same removals are persisted to\n the DB via `$pullAll` on `{entryPath}.Milestone.ClaimedIDs` /\n `...UnlockedIDs` (`TimedEvent.cs:1114-1131`) so the fix is permanent; this\n DB write is best-effort and wrapped in a swallowed try/catch\n (`TimedEvent.cs:177-187`) — a failed cleanup simply retries on the next\n `GetActiveEvents` call and never fails the read itself.\n- This only runs from `GetActiveEvents` (both the currently-active-instance\n path and the ended-in-grace path) — `GetUserLteState` returns the raw\n bucket as stored, unsanitized, which is one more reason to treat it as a\n secondary/debug view rather than the milestone UI's source of truth.\n\n---\n\n## Bonus window (Coin-Master-style)\n\n`EventContent.BonusWindow` (nullable) describes a repeating sequence of\nphases layered on top of the event's own timeline, used to scale milestone\nrewards during \"boosted\" windows:\n\n```ts\ninterface BonusWindowConfig {\n Schedule?: BonusWindowPhase[]; // ordered by Order; empty = disabled\n RepeatCycle?: boolean; // true: restart from phase 0 after the last phase\n MaxCycles?: number; // 0 = infinite (bounded only by the event's own end)\n}\n\ninterface BonusWindowPhase {\n Order: number; // 0-based, unique within Schedule\n Type: \"Cooldown\" | \"Bonus\" | \"MultipliedBonus\";\n DurationSec: number; // must be > 0\n BonusMultiplier?: number; // MultipliedBonus only; default 1.5\n}\n```\n\n(`TimedEventDefinitions.cs:315-395`)\n\nComputed per-request (never stored) by `BonusWindowHelpers.ComputePhase`\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Services/BonusWindowHelpers.cs:29-75`),\nanchored at the **event/phase's own start time** — so the phase schedule is\nidentical for every player and simply depends on wall-clock time since that\nstart:\n\n```ts\ninterface BonusWindowState {\n IsActive: boolean; // true only during Bonus/MultipliedBonus phases\n CurrentPhaseEndUtc: string;\n NextBonusStartUtc?: string; // null = no more bonus phases will occur\n CurrentCycleIndex: number; // 0-based pass through the whole Schedule\n CurrentPhaseIndex: number; // the active phase's Order\n ActiveBonusMultiplier: number; // Bonus=1.0, MultipliedBonus=phase.BonusMultiplier, else 0\n}\n```\n\n`ComputePhase` returns `null` when: `BonusWindow` is `null`/has an empty\n`Schedule`, the event hasn't started yet, or all cycles are exhausted\n(`RepeatCycle=false` and the one pass already completed, or `MaxCycles`\nreached) — treat a `null` `ActiveEventInfo.BonusWindow` as \"no boosted\nrewards available,\" not an error.\n\nAt claim time, the server independently recomputes the same\n`BonusWindowState` for the resolved instance's own `StartUtc`\n(`TimedEvent.cs:602-617`) — it is never trusted from a prior client read —\nand if `IsActive`, merges `MilestoneDefinition.BonusRewards` into the base\n`Rewards` via `MilestoneRewardResolver`/`BonusWindowHelpers.MergeRewards`,\nscaling the bonus part by `ActiveBonusMultiplier` when the phase type is\n`MultipliedBonus` (`BonusWindowHelpers.cs:136-176`, entries rounded via\n`Math.Round`). You cannot predict the exact reward amount client-side when a\n`MultipliedBonus` phase is active mid-window — read it from the claim\nresponse's `Rewards`.\n\n---\n\n## Token sources, matching, and grant math\n\nA grant (`grantTokens`/`grantTokensBatch`, and internally for board/quest/\nstore/marketplace triggers) resolves as follows\n(`GrantTokensInternal`, `TimedEvent.cs:273-435`):\n\n1. Resolve the target instance (current active, unless a chain ref supplies\n `CycleIndex`+`ChainedEventID`). Fails `\"Event not found or not active.\"`\n if unresolved, `\"Earning is not allowed.\"` if `CanEarn` is false.\n2. `TriggerMatcher.FindMatch` walks `Content.TokenSources` in order and\n returns the first `TriggerSource` whose `SourceType` matches and whose\n filters all pass (AND-ed) — see `TriggerMatcher.cs:25-59` for the exact\n per-`SourceType` filter rules (`BoardTileLanding` checks\n `TileTypeFilter`/`TileIndexFilter`/`ChanceOutcomeFilter`; `CustomAction`\n checks `Params[\"ActionName\"]`; `MarketplaceSell`/`MarketplaceBuy` check\n `Params[\"CatalogID\"]`/`[\"ItemID\"]`/`[\"OfferType\"]`; `OutcomeFilter` is\n checked for every source type). No match ⇒ `\"Source '<type>' is not\nallowed for this event.\"`.\n3. `baseAmount = amountOverride ?? source.BaseWeight`; must be `> 0` else\n `\"Base amount must be > 0.\"`.\n4. `adjustedAmount = ModifierService.Apply(baseAmount, ctx).FinalValue` where\n `ctx` only carries the roll multiplier, and only if\n `source.ScaleWithRollMultiplier` is true (`TimedEvent.cs:350-353`).\n5. `EventTokenService.ComputeGrant` (`EventTokenService.cs:156-305`) applies,\n **in order**: `DailyEarnCap` (global daily total) →\n `DailyCapFromSource`/`source.Limits.DailyWeightCap` (per-source daily\n amount) → `DailyTriggerCap`/`source.Limits.DailyCap` (per-source daily\n trigger _count_) → `CooldownSeconds` (per-source, not reset daily) →\n `MaxBalance` (spendable balance ceiling) — any of these can reject the\n grant outright (`EventTokenGrantFailure` reason string). If accepted, the\n amount is then **clamped** (not rejected) by `MaxPerGrant`, remaining\n daily headroom, and remaining balance headroom, in that order\n (`EventTokenService.cs:205-224`) — so a grant can silently apply for less\n than requested near a cap, rather than failing.\n\n`BuildBoardTokenOperations`/`BuildMarketplaceTokenOperations`\n(`TimedEvent.cs:900-995`) are the server-internal helpers other modules\n(GameLoop, Marketplace) use to fan a single gameplay action out to every\nmatching active event — not something client code calls directly, but useful\ncontext for why a single board roll can grant several different event\ntokens at once.\n\n---\n\n## Server-side limits, batching, and idempotency\n\n- **Max batch size: 50** entries per call (`BatchSupport.MaxBatchSize`,\n `IDosGamesSDK/API/Client/v2/_Shared/BatchSupport.cs:35`), enforced\n identically for `ClaimMilestonesBatch`, `SpendTokensBatch`, and\n `GrantTokensBatch` (`TimedEvent.cs:1200,1306,1475,1583`). Entries beyond 50\n are silently dropped during normalization — they never appear in the\n response at all, so chunk larger sets into multiple calls yourself.\n- **Dedup**: `ClaimMilestonesBatch` dedupes by `(instance key)|(MilestoneID)`\n (`TimedEvent.cs:1195-1201`); `SpendTokensBatch` dedupes by instance key\n (`TimedEvent.cs:1470-1477`); `GrantTokensBatch` dedupes by\n `(instance key)|SourceType|Outcome` on input, **and separately rejects a\n second grant to the same resolved token address** within one batch with\n `\"Duplicate event instance in grant batch — send it as a separate\nrequest.\"` (`TimedEvent.cs:1634-1637`) because two grants to one address\n in the same Mongo update would conflict.\n- **Atomicity**: each batch call resolves every entry, then applies **one**\n atomic `ResourceService.ApplyResourceOperationAtomicAsync` for the whole\n batch. For `SpendTokensBatch`/`GrantTokensBatch` this means the _entire_\n batch's resource change succeeds or fails together — a single\n insufficient-balance/over-cap item fails the whole apply and every\n successfully-resolved item in that batch reports the same `Error`\n (`TimedEvent.cs:1518-1552,1659-1695`). Items that failed to even _resolve_\n (bad instance ref, unknown milestone, claim-mode gate) are filtered out\n **before** the atomic apply and get their own independent preset error —\n those don't block the rest of the batch.\n `ClaimMilestonesBatch`/`ClaimAllMilestones` are slightly more granular:\n milestones are grouped **per resolved token address** so multiple\n milestones on the _same_ event instance share one `$push`, but the\n token-threshold/already-claimed check\n (`EventTokenService.ComputeMilestoneClaimBatch`) still runs per address\n before the shared atomic apply, so a milestone that fails its own\n threshold/already-claimed check is rejected independently of the others\n (`TimedEvent.cs:1372-1413`).\n- **Idempotency (`reason` / `RelatedEntityID`)**: every mutating call passes\n a `reason` string to `ApplyResourceOperationAtomicAsync` built from the\n action, the resolved `Type`+`EntityID` (and `MilestoneID`/`sourceKey`\n where relevant), and — for single-item calls — the caller's optional\n `RelatedEntityID` folded in via `ResourceService.ResolveRelatedEntityID`\n (e.g. `\"SpendTokens:spend_{Type}_{EntityID}_{RelatedEntityID}\"`,\n `TimedEvent.cs:484-489,619-624,405-413`). Including `Type` guards against a\n `Scheduled` and `Chained` event that happen to share an `LteID`; including\n the instance-keyed `EntityID` guards against collisions across chain\n instances or across unrelated events reusing the same `RelatedEntityID`\n string (e.g. `\"roll_42\"`). Batch calls build one shared reason from all\n included item keys (`BatchSupport.BuildBatchReason`) rather than one per\n item.\n- **Where `Resources` live in batch responses**: for `SpendTokensBatch`\n /`GrantTokensBatch`, the single merged `ResourceOperation` from the one\n atomic apply is attached to the **first successfully-applied item only**\n (`attached` flag, `TimedEvent.cs:1536-1552,1677-1693`) — every other\n successful item in that batch gets an **empty** `ResourceOperation` in its\n `Data.Resources`. The SDK's `spendTokensBatch`/`grantTokensBatch` already\n account for this: they scan for the first item with a non-empty\n `Resources` and apply that once to the cache\n (`TimedEventService.ts:209-221,238-250`) — don't assume every batch item\n carries its own independent `Resources`/`Rewards` payload; read cache\n balances after the call instead of summing per-item deltas.\n- **Rate limit**: the v2 pipeline's per-IP endpoint limit for\n `TimedEventV2` is 500 ms (`RateLimitMilliseconds`,\n `TimedEvent.cs:18`); per-user/action transaction lock is 10 s\n (`LockDurationMilliseconds`, `TimedEvent.cs:19`). The SDK's own client-side\n throttle is a separate, smaller 600 ms guard per endpoint\n (`packages/core/src/transport/throttle.ts:4`, `DEFAULT_THROTTLE_MS`).\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
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",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "user-custom-data",
|
|
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",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# User custom data model — reference\n\nFull shape of requests/responses and the key-registry config, plus the exact\nbackend rules (limits, validation, TTL) transcribed from `UserCustomData.cs`\nand its `Models/`. All TS shapes are **strictly typed in the SDK** — every\nmodel is exported from `@idosgames/core`. Schemas keep `.passthrough()` where\nnoted, so a field the backend adds later still round-trips. Field names are\nPascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getMyUserCustomData()` returns / cache shape\n- [UserCustomDataRecord](#usercustomdatarecord)\n- [Public reads (single + batch)](#public-reads)\n- [Write/delete responses](#writedelete-responses)\n- [Config: UserCustomDataDefinitions](#config-usercustomdatadefinitions)\n- [Backend validation order and rejections](#backend-validation-order-and-rejections)\n- [Limits reference table](#limits-reference-table)\n- [Batch semantics](#batch-semantics)\n- [TTL and defaults](#ttl-and-defaults)\n- [Buckets are structural, not a field](#buckets-are-structural-not-a-field)\n\n---\n\n## Player state\n\nReturned by `getMyUserCustomData()` and cached at\n`client.data.user.state?.CustomData`.\n\n```ts\ninterface UserCustomDataState {\n Version?: number; // bumped on every local write (client.data cache only)\n Private?: Record<string, UserCustomDataRecord>;\n Public?: Record<string, UserCustomDataRecord>;\n ReadOnly?: Record<string, UserCustomDataRecord>;\n}\n```\n\n`GetMyUserCustomDataResponse` (the wire response) has the same three bucket\nmaps plus its own `Version`, which is what seeds the cache's `Version` on\n`getMyUserCustomData()`. Subsequent local writes increment the cached\n`Version` client-side; it is a change counter, not synced back to the server\nper-write. Server-side, `UserCustomDataState.Version` is a `long` global\nmutation counter across **all four** buckets (including `Internal`, which\nnever reaches the client) — it is explicitly _not_ used for concurrency\ncontrol; per-key writes use the record's own `Version` instead\n(`UserCustomDataState.cs:29-35`).\n\n`Internal` is a fifth bucket that exists server-side\n(`UserCustomDataState.Internal`) but is **never** serialized into any client\nresponse, including `GetMyUserCustomDataResponse` — it's simply absent from\nthe type (`UserCustomData.cs:1377-1392`, `GetMyUserCustomDataResponse`\ndeclares only `Private`/`Public`/`ReadOnly`).\n\n---\n\n## UserCustomDataRecord\n\nOne key's value plus audit metadata. Same shape everywhere a record shows up\n(private/public/read-only maps, single or batch reads).\n\n```ts\ninterface UserCustomDataRecord {\n Value?: string;\n UpdatedAt?: string; // ISO timestamp\n Version?: number; // per-key write counter — NOT the bucket-wide Version above\n LastWriter?: \"Client\" | \"Server\" | \"System\";\n ExpiresAt?: string | null; // ISO timestamp; null/absent = no TTL\n}\n```\n\n`.passthrough()` — extra fields the backend adds later still round-trip.\n\n`Version` here is `UserCustomDataRecord.Version`, incremented by 1 on every\nwrite to _that specific key_ (`UserCustomData.cs:602-611`, `SetCore` step 8).\nThe doc comment on the C# field notes an `ExpectedVersion`/CAS mechanism is\narchitecturally possible via `PatchUserDataDocumentByIDAsync`'s `extraFilter`\n(`UserCustomDataState.cs:101-108`), but **no such field exists** on the\nclient-facing `UserCustomDataRequest` — the shipped client API is\nlast-write-wins with no compare-and-swap. Don't build UI that assumes\noptimistic-concurrency protection on writes.\n\n`LastWriter` is audit-only (\"who wrote this last\") and never affects\nvalidation or access — don't branch UI logic on it beyond display purposes.\n\n---\n\n## Public reads\n\n```ts\ninterface GetPublicUserCustomDataResponse {\n UserID?: string;\n Version?: number;\n Public?: Record<string, UserCustomDataRecord>;\n}\n\ninterface BatchGetPublicUserCustomDataResponse {\n Results?: GetPublicUserCustomDataResponse[];\n NotFoundUserIDs?: string[]; // requested ids with no data / that don't exist\n}\n```\n\nNeither of these touches `client.data` — they describe _other_ players, so\nthere's nothing local to cache. Render straight from the response.\n\nPrivacy here is **structural**, not a runtime filter: the backend's\nprojection for a cross-player read is typed to select only\n`CustomData.Public` off the target's document\n(`UserCustomData.cs:181-189` for the single read, `:333-341` for the batch) —\n`Private`/`ReadOnly`/`Internal` physically never leave the database for a\ntarget user, there's no filter step that could be bypassed.\n\n`batchGetPublicUserCustomDataOf` request-side: target IDs are trimmed,\ndeduped (`Distinct(StringComparer.Ordinal)`), and capped at **50**\n(`MaxBatchSize`) — over the cap, the entire call fails with\n`\"Too many target users ({n}/50).\"` (`UserCustomData.cs:325-326`). Reads for\nall requested IDs run in parallel (`Task.WhenAll`); a target that doesn't\nexist lands in `NotFoundUserIDs` rather than failing the whole call\n(`:355-357`).\n\n---\n\n## Write/delete responses\n\n```ts\ninterface SetUserCustomDataResponse {\n ServerTimeUtc?: string;\n Bucket?: string; // \"Private\" | \"Public\" (echo of what you set)\n KeyID: string;\n Version?: number;\n ExpiresAt?: string | null;\n}\n\ninterface UserCustomDataBatchSetResultItem {\n Bucket?: string;\n KeyID: string;\n Version?: number;\n ExpiresAt?: string | null;\n}\ninterface BatchSetUserCustomDataResponse {\n ServerTimeUtc?: string;\n Results?: UserCustomDataBatchSetResultItem[];\n}\n\ninterface BatchDeleteUserCustomDataResponse {\n ServerTimeUtc?: string;\n DeletedCount?: number;\n}\n```\n\n`batchSet`'s cache-patch logic matches each result item back to the request\nitem by `(Bucket, KeyID)` to recover the `Value` you sent (the response itself\ndoesn't echo the value) before writing it into the cache — so every item you\npass to `batchSet` must have a unique `(Bucket, KeyID)` pair, or the patch will\nmatch the wrong source item. The backend independently enforces this same\nuniqueness: a duplicate `(Bucket, KeyID)` pair within one `BatchSet` request\nfails the entire call server-side too (`UserCustomData.cs:991-992`,\n`\"Duplicate batch item ({Bucket}, '{KeyID}').\"`).\n\n### Request item shapes\n\n```ts\ninterface UserCustomDataBatchSetItem {\n Bucket: CustomDataBucket;\n KeyID: string;\n Value: string;\n}\ninterface UserCustomDataBatchDeleteItem {\n Bucket: CustomDataBucket;\n KeyID: string;\n}\n```\n\n---\n\n## Config: UserCustomDataDefinitions\n\nReturned by `getUserCustomDataDefinitions()`; cached via\n`client.data.config.getSection<UserCustomDataDefinitions>(\"UserCustomData\")`.\n\n```ts\ninterface UserCustomDataDefinitions {\n Keys?: Record<string, UserCustomDataKeyDefinition> | null;\n DefaultMaxValueLengthBytes?: number | null;\n DefaultTtlSeconds?: number | null;\n MaxKeysPerBucket?: number | null;\n MaxTotalSizeBytes?: number | null;\n RejectUnregisteredKeys?: boolean | null;\n}\n\ninterface UserCustomDataKeyDefinition {\n KeyID: string;\n Bucket?: \"Private\" | \"Public\" | \"ReadOnly\" | \"Internal\";\n ValueType?: \"String\" | \"Int\" | \"Bool\" | \"Json\";\n MaxValueLengthBytes?: number;\n TtlSeconds?: number;\n DefaultValue?: string;\n Description?: string; // dev-facing only; never returned by Get* to clients\n}\n```\n\nBackend default values for a title that hasn't overridden them\n(`UserCustomDataDefinitions.cs`):\n\n| Field | Default | Meaning |\n| ---------------------------- | --------------------- | ------------------------------------------------------------------------ |\n| `DefaultMaxValueLengthBytes` | `8192` (8 KB) | Per-value size cap for **unregistered** keys. |\n| `DefaultTtlSeconds` | `null` | No TTL for unregistered keys unless a title sets one. |\n| `MaxKeysPerBucket` | `128` | Applied identically to all 4 buckets (Private/Public/ReadOnly/Internal). |\n| `MaxTotalSizeBytes` | `1_500_000` (~1.5 MB) | Sum across all 4 buckets, per player — guards Mongo's 16 MB doc cap. |\n| `RejectUnregisteredKeys` | `false` | Permissive by default: unlisted `KeyID`s are accepted as free-form. |\n\nA key's own `MaxValueLengthBytes` (default `8192` if the key is registered at\nall) / `TtlSeconds` override the title-wide defaults **for that key only**;\neverything else (`MaxKeysPerBucket`, `MaxTotalSizeBytes`) is title-wide and\nnot overridable per key.\n\n---\n\n## Backend validation order and rejections\n\nEvery write (`SetPrivateData`/`SetPublicData`/`BatchSet`, and the\nper-item validation inside `BatchSet`) runs the same checks, in this order,\n**before** touching the database (`UserCustomData.cs:545-660` for single,\n`:952-1108` for batch). Any failure aborts with `reason: \"server\"` and no\npartial state change:\n\n1. `KeyID` non-empty after trim.\n2. `KeyID` contains neither `.` nor `$` → else\n `\"KeyID '{keyID}' contains invalid characters ('.' or '$').\"`\n3. `Value` is non-null (use `deleteKey`/`batchDelete` to remove, not an empty\n Set) → else `\"Value is required (use DeleteKey to remove).\"`\n4. Schema lookup: if the key **is** registered in `Keys`, resolve its\n `UserCustomDataKeyDefinition`; if not found and\n `RejectUnregisteredKeys == true` → fails with `\"Key '{keyID}' is not\nregistered and unregistered keys are rejected by configuration.\"`\n5. **Bucket lock-in**: if the key is registered, its `Bucket` is fixed by the\n schema — writing it through the \"wrong\" bucket method fails with\n `\"Key '{keyID}' is registered in bucket '{keyDef.Bucket}', cannot be\nwritten to bucket '{targetBucket}'.\"` (e.g. a key registered as `Public`\n cannot be set via `setPrivateData`, even though `setPrivateData` itself\n only ever targets `Private`).\n6. **Value-type validation** (only for registered keys, via\n `ValidateValueType`, `UserCustomData.cs:906-940`):\n - `String` — always passes (length-only check happens next).\n - `Int` — must parse via `long.TryParse`; else `\"expected integer\n(parseable as long)\"`.\n - `Bool` — must parse via `bool.TryParse` (`\"true\"`/`\"false\"`,\n case-insensitive); else `\"expected boolean ('true' or 'false')\"`.\n - `Json` — must parse via `JToken.Parse` (any valid JSON document); else\n `\"expected valid JSON: {exception message}\"`.\n - Failure message shape: `\"Key '{keyID}' value validation failed:\n{typeError}\"`.\n7. **Per-key size limit**: UTF-8 byte count of `Value` vs. the resolved max\n (`keyDef.MaxValueLengthBytes` if registered, else\n `defs.DefaultMaxValueLengthBytes`) → else `\"Value size {n} bytes exceeds\nlimit {max} bytes for key '{keyID}'.\"`.\n8. **Per-bucket key-count limit**: counts _currently alive_ (non-expired)\n keys in the target bucket, excluding the key being overwritten itself, and\n rejects if adding this key would exceed `MaxKeysPerBucket` → `\"Bucket\n'{bucket}' is full ({alive}/{max}). Delete some keys first.\"`.\n9. **Aggregate size limit**: sums live byte sizes across **all four** buckets\n (post-write, i.e. including the new/updated value and excluding anything\n about to be TTL-swept) and rejects if it would exceed `MaxTotalSizeBytes`\n → `\"Total custom data size would exceed limit {max} bytes (would become\n{n}).\"`.\n\nOnly after all of the above pass does the server build the Mongo patch\n(the new record `Set` + a `Version` increment + TTL-sweep `Unset`s for any\nexpired records) and apply it as one atomic update via\n`ResourceService.PatchUserDataDocumentByIDAsync` — a direct document patch,\nnot a `ResourceService` resource operation (no currency/item cost is ever\ninvolved in this module).\n\n`DeleteKey` validates only `KeyID` presence/format and that\n`Bucket ∈ {Private, Public}` (`\"Client can only delete keys from Private or\nPublic buckets.\"`) before going through the same idempotent core.\n\n---\n\n## Limits reference table\n\n| Limit | Where it's configured | Scope | Overflow behavior |\n| ----------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Per-value size | `keyDef.MaxValueLengthBytes` (registered) or `defs.DefaultMaxValueLengthBytes` (free-form) | One key's `Value`, UTF-8 bytes | Write rejected, `\"server\"` error naming the byte counts — no truncation. |\n| Keys per bucket | `defs.MaxKeysPerBucket` | Each of Private/Public/ReadOnly/Internal independently | Write rejected once the bucket would exceed the cap — delete something first. |\n| Total size across all buckets | `defs.MaxTotalSizeBytes` | Sum of all 4 buckets, per player | Write rejected — protects against MongoDB's 16 MB document limit. |\n| Batch item count | `MaxBatchSize` constant = **50** (not title-configurable) | `BatchSet` / `BatchDelete` / `BatchGetPublicUserCustomDataOf` items per call | **Entire call rejected** with `\"Too many items ({n}/50).\"` / `\"Too many target users ({n}/50).\"` — nothing is silently dropped, unlike some other modules' batch overflow behavior. |\n| Unregistered keys | `defs.RejectUnregisteredKeys` (default `false`) | Every write | When `true`, an unlisted `KeyID` fails outright rather than falling back to free-form defaults. |\n\nThere is no client- or server-side rate limit specific to \"how often you can\nwrite a key\" beyond the platform-wide per-endpoint IP rate limit\n(`RateLimitMilliseconds = 500` for the whole `UserCustomData` function,\nenforced by `ClientRun.Execute`) and the SDK's generic per-endpoint client\nthrottle (600 ms, `DEFAULT_THROTTLE_MS` in `throttle.ts`) — both are generic\nplumbing shared by every v2 module, not something this module tunes\nspecially.\n\n---\n\n## Batch semantics\n\n`BatchSet` and `BatchDelete` are **all-or-nothing at the validation stage**:\nevery item is checked (format, bucket permission, schema, type, size,\nduplicates) in a first pass with zero database writes; a single invalid item\nfails the whole call and nothing is persisted (`UserCustomData.cs:971-1022`\nfor `BatchSet`, `:1127-1147` for `BatchDelete`). This differs from batch APIs\nin some other modules that report a per-item `Success`/`Error` array while\nstill applying the valid subset — **UserCustomData has no partial-success\narray**; either `res.ok` is `true` and every item in `Results` succeeded, or\n`res.ok` is `false` and nothing changed.\n\nOnce validation passes, all writes for the batch are applied in a single\natomic Mongo patch (`PatchUserDataDocumentByIDAsync`) alongside one shared\n`CustomData.Version` increment and a TTL sweep of any other expired keys in\nthe touched buckets.\n\n`BatchSet` duplicate handling: a duplicate `(Bucket, KeyID)` pair **within the\nrequest** fails the whole call. `BatchDelete` duplicate handling is more\nlenient — duplicates are silently collapsed to one (`UserCustomData.cs:1142`,\n\"Дубликаты в delete безвредны — молча схлопываем\") since deleting the same\nkey twice is harmless.\n\n`BatchDelete` is idempotent per item: keys that don't currently exist in\ntheir target bucket are silently excluded from `toDelete` before the patch is\nbuilt (`:1160-1166`). If the effective set is empty after that filtering, the\ncall returns `Ok({ DeletedCount: 0 })` **without touching the database at\nall** (`:1168-1175`).\n\n---\n\n## TTL and defaults\n\n- A registered key's `TtlSeconds` (or, for free-form keys, the title's\n `DefaultTtlSeconds`) is resolved into a concrete `ExpiresAt` timestamp at\n write time: `now + TtlSeconds`. `null`/absent means the record never\n expires.\n- **On read**, any record whose `ExpiresAt <= now` is filtered out of the\n response entirely — it reads exactly as if the key were never set\n (`FilterAliveAndApplyDefaults`, `UserCustomData.cs:858-901`). This applies\n to `GetMyUserCustomData`, `GetPublicUserCustomDataOf`, and the batch public\n read.\n- **On write**, every write to a bucket atomically sweeps (unsets) any other\n _expired_ records across **all four** buckets as part of the same patch —\n there's no separate cleanup job; expiry is enforced lazily and\n opportunistically (`AddTtlCleanupPatches`, `:755-787`). This means an\n expired key can still physically exist in the database for a while after\n it \"expires\" if nothing else gets written — it just won't ever be returned.\n- **Default values**: if a registered key has a non-empty `DefaultValue` and\n the player has no live record for it, `GetMyUserCustomData` /\n `GetPublicUserCustomDataOf` synthesize one in the response with\n `Version: 0` and `LastWriter: \"System\"` (`:889-896`). This default is\n **never written to the database** — it only appears in read responses until\n the player performs an explicit `Set`. Don't treat `Version: 0` /\n `LastWriter: \"System\"` as \"this exists in storage.\"\n\n---\n\n## Buckets are structural, not a field\n\nThere is no `Visibility` field on `UserCustomDataRecord` — which bucket a\nrecord lives in **is** its privacy and writability rule, enforced by which\ndictionary it's stored in (`UserCustomDataState.Private` /`.Public`\n/`.ReadOnly` /`.Internal`) and by which code path is allowed to touch that\ndictionary:\n\n| Bucket | Client reads (own) | Client reads (others, via public endpoints) | Client writes | Client deletes |\n| ---------- | ------------------- | ------------------------------------------- | ---------------------------------------- | -------------- |\n| `Private` | Yes | No | Yes | Yes |\n| `Public` | Yes | Yes | Yes | Yes |\n| `ReadOnly` | Yes | No | No (server only, e.g. `SetReadOnlyData`) | No |\n| `Internal` | No — never returned | No | No | No |\n\nServer-only counterparts (`SetReadOnlyData`, `SetInternalData`,\n`DeleteKeyServer`, `BatchSetServer`, `BatchDeleteServer`) exist in the backend\nfor other backend actions/jobs to write flags like tutorial progress or A/B\nvariants into `ReadOnly`/`Internal` — these have no HTTP route and are not\nreachable from the SDK; they're mentioned here only so a \"why can't I write\nReadOnly\" question has a clear answer (another server-side feature owns that\nkey, not you).\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "user-profile",
|
|
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",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# User data model — reference\n\nFull shape of `ClientState`, `UserState`, and the other types owned by\n`packages/core/src/models/user/UserModels.ts`. Field names are PascalCase\n(straight from the backend JSON); schemas keep `.passthrough()` so a field the\nbackend adds later still round-trips. `UserState` is the single object every\nfeature module's cache hangs off of — this doc covers the trunk; each\nmodule's own skill/reference covers its own branch in depth.\n\n## Contents\n\n- [ClientState](#clientstate) — what `getClientState()` / `getClientStateExcept()` return\n- [UserState](#userstate) — the per-player state tree, `client.data.user.state`\n- [UserInventoryState](#userinventorystate) — what `getUserInventory()` returns\n- [UsageTimeStats](#usagetimestats) — what `getUsageTime()` returns\n- [UserUsageState](#userusagestate) — the persisted `UserState.Usage` shape\n- [Requests](#requests) — `UserRequest` fields\n- [Backend rules](#backend-rules-transcribed-from-usercs) — username validation, delete-account cascade\n\n---\n\n## ClientState\n\n```ts\ninterface ClientState {\n Title?: TitlePublicConfigurationModel | null; // -> client.data.config.titlePublicConfiguration\n User?: UserState | null; // -> client.data.user.state\n [k: string]: unknown;\n}\n```\n\nThe wire schema (`zClientState`) only validates that `Title`/`User` are present\n(or nullish) at the top level — it does not deep-validate their contents. This\nis intentional: `ClientState` carries the _entire_ title config and the\n_entire_ per-player state in one payload, and field-level schemas live with\neach owning module instead of being re-validated here.\n\n---\n\n## UserState\n\n```ts\ninterface UserState {\n UserID?: string;\n InventoryV2?: UserInventoryState;\n EventToken?: UserEventTokensState;\n PublicData?: UserPublicDataModel | null;\n EconomyTuning?: PlayerEconomyTuningState | null;\n Usage?: UserUsageState | null;\n\n // One key per feature module — each owned and written by that module's own\n // service, not by UserService. Present here only because they all live on\n // the same shared state object.\n Store?: UserStoreState | null;\n Lootbox?: UserLootboxState | null;\n Reward?: UserRewardState | null;\n Quest?: UserQuestState | null;\n Leaderboard?: UserLeaderboardsState | null;\n Season?: UserSeasonsState | null;\n Premium?: UserPremiumState | null;\n Character?: UserCharactersState | null;\n Match?: UserMatchState | null;\n Collection?: UserCollectionState | null;\n CoopEvent?: UserCoopEventState | null;\n DealOffer?: UserDealOffersState | null;\n Referral?: UserReferralState | null;\n Social?: UserSocialState | null;\n TimedBoost?: UserTimedBoostsState | null; // NOT populated by ClientState — see note below\n CustomData?: UserCustomDataState | null;\n GameLoop?: UserGameLoopsState | null;\n Blockchain?: UserBlockchainState | null;\n Marketplace?: UserMarketplaceState | null; // NOT populated by ClientState — see note below\n\n [k: string]: unknown; // future/unmodeled fields round-trip via passthrough\n}\n```\n\nFields **actually owned by UserService** (written by its own cache calls, not\nby another module): `InventoryV2` (via `getUserInventory()`, also kept current\nby other modules' resource operations), `EventToken` (via `getEventTokens()`),\nand the whole tree wholesale via `getClientState()`/`getClientStateExcept()`.\n`PublicData`, `EconomyTuning`, and `Usage` arrive as part of that wholesale\nfetch — there's no dedicated \"get just PublicData\" call.\n\n### `TimedBoost` and `Marketplace` are envelope-only — ClientState never fills them\n\nThe TS type declares `TimedBoost` and `Marketplace` because they're real keys\non the backend's `UserDataDocument` (`IDosGamesSDK/API/Client/v2/User/Models/UserDataDocument.cs:69,89`),\nbut the backend's `ClientState.User` builder does not copy either one. The\nresponse's `UserState` class\n(`IDosGamesSDK/API/Client/v2/User/Models/ClientState.cs:22-48`) has no\n`TimedBoost`/`Marketplace` property at all, and in\n`IDosGamesSDK/API/Client/v2/User/User.cs` neither name appears in\n`_defaultUserFields` (lines 279-304) nor in `_userStateFieldCopiers` (lines\n310-334) — every other module listed above (including `Match`, `Blockchain`,\n`Reward`, `Character`, which read as later additions) does appear in both.\nPractically: `getClientState()` / `getClientStateExcept()` will never\npopulate `state.TimedBoost` or `state.Marketplace`, regardless of\n`Fields`/`ExcludeFields` (those two names aren't in the copier table to\nselect in the first place).\n\nEach module owns and fetches its own per-player state instead, and the TS\ncache (`packages/core/src/cache/UserData.ts`) only ever writes these two keys\nfrom that module's own apply method:\n\n- `TimedBoost` ← `client.timedBoost.getActiveTimedBoosts()` →\n `UserData.applyTimedBoost({ Active })` — see the timed-boost-system skill.\n- `Marketplace` ← `client.marketplace.getMyState()` →\n `UserData.applyMarketplaceState(result.data.Limits)` — see the\n marketplace-system skill.\n\n### PublicData — denormalized public profile\n\n```ts\ninterface UserPublicDataModel {\n Username?: string | null;\n Country?: string | null;\n AvatarUrl?: string | null;\n Premium?: boolean | null;\n Level?: number | null;\n Power?: number | null;\n BoardRank?: number | null;\n [k: string]: unknown;\n}\n```\n\nThis same shape is embedded (as a snapshot, not a live reference) in several\nother modules' responses: leaderboard entries (`PublicProfile`), collection\ntrade offers (`SenderPublicData`), social timeline events (`ActorProfile`),\nGameLoop PvP/raid opponents (`TargetPublicData`/`PublicData`), and coop-event\ngroup members (`PublicData`). Treat any of those as a point-in-time copy, not\nas this player's live state.\n\n### EconomyTuning — personal balance multiplier\n\n```ts\ninterface PlayerEconomyTuningState {\n Segment: string;\n RewardMultiplier: number;\n CostMultiplier: number;\n MaxRollMultiplierOverride: number;\n ExpiresAtUtc: string;\n Version: number;\n}\n```\n\nServer-computed A/B or player-segment tuning (e.g. a new-player reward boost).\nIntended for server-side use in pricing/reward math; present on `ClientState.User`\nmainly so the client can display it (e.g. \"2x rewards active\") if the title\nchooses to.\n\n---\n\n## UserInventoryState\n\nReturned by `getUserInventory()`; cached at `client.data.user.state.InventoryV2`.\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable item totals, key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface UserVirtualCurrencyState {\n Amount: number;\n Recharge?: UserRechargeState | null; // energy-style regen, if configured\n Daily?: UserDailyCounters | null; // daily earn/spend caps tracking\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n\ninterface UserRechargeState {\n LastRechargeAt?: string;\n PendingSeconds?: number;\n}\n\ninterface UserDailyCounters {\n PeriodStartUtc: string;\n Earned: number;\n Spent: number;\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — use a decimal library, not native float\n Frozen: string; // decimal string\n DepositAddresses?: Record<string, UserDepositAddress>;\n Compliance?: UserCryptoComplianceCounters;\n CreatedAt?: string;\n UpdatedAt?: string;\n [k: string]: unknown;\n}\n\ninterface UserDepositAddress {\n Address: string;\n Memo?: string | null;\n AssignedAt: string;\n}\n\n/** AML spend-compliance window counters, checked against CryptoCurrencyDefinition.Limits. */\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc: string;\n DailyWithdrawnUsd: string; // decimal string\n MonthlyPeriodStartUtc: string;\n MonthlyWithdrawnUsd: string; // decimal string\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number;\n RemainingUses?: number;\n Level?: number; // per-instance upgrade level (Item module's UpgradeLevel)\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for equip state\n CustomData?: string | null;\n}\n\n/** Daily-window counter for one conversion pair (\"{srcType}:{srcID}->{tgtType}:{tgtID}\"). */\ninterface ConversionDailyCounter {\n PeriodStartUtc: string;\n AmountToday: string; // decimal string\n}\n```\n\n`EquippedSlot` on an unstackable item instance is the authoritative record of\nwhat's equipped where — the Character module's per-character `Equipment` map\nis a convenience cache view kept in sync with this by the SDK.\n\nThe wire schema for `UserInventoryState` (`zUserInventoryState`) is currently\nlenient (`z.object({}).passthrough()` cast to the type) — Phase 1 of the\nstrict-typing port validated `ClientState`'s top-level shape only; inventory's\nfield-level schema is expected to tighten in a later phase. Don't rely on\nruntime validation catching a malformed inventory field today; the TS types\nare still accurate for what the backend currently sends.\n\n---\n\n## UsageTimeStats\n\nReturned by `getUsageTime()` (a fetch/response type — distinct from the\npersisted `UserUsageState` below).\n\n```ts\ninterface UsageTimeStats {\n Today: number;\n Yesterday: number;\n CurrentWeek: number;\n CurrentMonth: number;\n Total: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt?: string | null;\n LongestSessionEverSeconds?: number | null;\n CurrentWeekActiveHoursMask?: number | null;\n CurrentMonthActiveHoursMask?: number | null;\n ReactivationCount?: number | null;\n History?: Record<string, unknown> | null;\n [k: string]: unknown;\n}\n```\n\nAll duration fields are seconds. `*ActiveHoursMask` is a bitmask (which hours\nof the day had activity) — decode per-bit if you need an activity heatmap.\n\n## UserUsageState\n\nThe persisted shape on `UserState.Usage` (`UserDataDocument.Usage` server-side)\n— foreground-activity seconds only, aggregated per day.\n\n```ts\ninterface UserUsageState {\n TotalSeconds: number;\n TotalSessions: number;\n FirstActiveAt?: string | null;\n LastActiveAt: string;\n Reactivations?: UsageReactivationEvent[];\n Daily?: Record<string, DailyUsageRecord>; // key = \"ddMMyyyy\" (UTC)\n}\n\n/** One recorded reactivation — a return after a silence gap of 7+ days. */\ninterface UsageReactivationEvent {\n ReactivatedAt: string;\n DaysSinceLastActive: number;\n}\n\ninterface DailyUsageRecord {\n Seconds: number;\n Sessions: number;\n LongestSessionSeconds: number;\n ActiveHoursMask: number;\n LastActiveAt: string;\n}\n```\n\n`UsageTimeStats` (from `getUsageTime()`) and `UserUsageState` (on `UserState.Usage`)\noverlap in intent but are separate types with separate field names — don't\nconflate `Today`/`CurrentWeek`/`CurrentMonth` (rolling windows in the fetch\nresponse) with `Daily` (a raw per-day map, keyed by date, in the persisted\nstate).\n\n---\n\n## Requests\n\n```ts\ninterface UserRequest extends BaseRequest {\n IsNewSession?: boolean; // addUsageTime\n SessionDurationSeconds?: number; // addUsageTime\n Fields?: string[]; // reserved — not currently populated by UserService\n TitleFields?: string[]; // reserved — not currently populated by UserService\n ExcludeFields?: string[]; // getClientStateExcept — User.* keys to preserve from cache\n ExcludeTitleFields?: string[]; // getClientStateExcept — Title.* keys to preserve from cache\n}\n```\n\n`BaseRequest` supplies `UserID`, `ClientSessionTicket`, `BuildKey`,\n`WebAppLink`, and a fresh `RelatedEntityID` per call (all filled in\nautomatically by `UserService`'s internal `baseRequest()` — you never build a\n`UserRequest` by hand). `Fields`/`TitleFields` exist on the request type but\n`UserService`'s methods never set them today — only `ExcludeFields` /\n`ExcludeTitleFields` are wired up, exclusively by `getClientStateExcept`.\n`BaseRequest` also carries `Username` and `UsageTime` (used by\n`changeUsername`/`addUsageTime` respectively) — those two live on the shared\n`BaseRequest` interface, not on `UserRequest` itself, since other modules'\nrequests (e.g. registration) also set `Username`.\n\n---\n\n## Backend rules (transcribed from `User.cs`)\n\n### Username validation (`ChangeUsername`)\n\nSource: `IDosGamesSDK/API/Client/v2/User/User.cs:245-267`.\n\n1. The server trims the incoming `Username` (`args.Username?.Trim()`).\n2. Rejects with the literal string `\"INVALID_USERNAME\"` if the trimmed value\n is null/whitespace, or its length is `< 3` or `> 24` characters. There is\n no character-set restriction beyond length — any non-whitespace string in\n range is accepted verbatim (no profanity filter, no uniqueness check).\n3. On success it does a single field patch\n (`Builders<UserDataDocument>.Update.Set(u => u.PublicData.Username, desired)`)\n directly via `ResourceService.PatchUserDataDocumentByIDAsync` — it does\n **not** go through `ResourceService`'s resource-operation/OCC path, so\n there's no idempotency-by-reason key for this call; a resend just\n overwrites the name again with the same (or a new) value.\n4. The response (`ChangeUsernameResponse.Username`) is the exact trimmed\n string that was stored — never a suffixed/deduplicated variant, since\n there's no uniqueness constraint to disambiguate against.\n\n### Delete-account cascade (`DeleteUserAccount`)\n\nSource: `IGSService.DeleteUserAccount` (`IDosGamesSDK/Core/CoreScripts/IGServer/Service/IGSService.cs:1275-1278`)\n→ `DataBaseService.DeleteAllDataUserAsync` (`IDosGamesSDK/Core/CoreScripts/IGServer/DataBase/DataBaseService.cs:498-519`).\n\n- The call is a single MongoDB `DeleteOneAsync` against the `USER_TYPE`\n collection, filtered by `UserID` **and** `TitleID` together. It deletes\n exactly one `UserDataDocument` — this title's per-player document only.\n- There is no fan-out to other collections in this code path: no explicit\n cleanup of Marketplace escrow/listings, leaderboard documents, coop-group\n membership, blockchain deposit-address records, or the cross-title\n `PlatformUserDocument`/`LinkedTitleAccounts` map. If a player is linked to a\n platform account, deleting one title's `UserDataDocument` does not unlink\n or delete the platform-level document or any other title's data.\n- The operation returns a plain `bool` (`DeletedCount > 0`); the endpoint\n turns a `false` into `OperationResult.Fail(\"Failed to Delete User Account\")`\n (`User.cs:269-275`). There is no soft-delete/undo flag anywhere in this\n path — a successful call is a permanent, synchronous hard delete of the row.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|