@idosgames/mcp 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "referral-system",
3
3
  "description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (who they're subscribed to, follower count, claimed invite rewards), activate someone else's referral code, and claim a staged invite reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants invite-friend / referral-code / refer-a-friend UIs, follower-milestone reward screens, or otherwise touches client.referral, ReferralService, ReferralDefinitions, UserReferralState, or referral codes — even if they don't name the module explicitly.",
4
- "content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount` and `FollowerIDs`, and which `InviteRewards` they've\n claimed (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------ | ------------------------------------------------------------------- | ------------------------------ |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount`/`FollowerIDs` are decremented/pulled, the new\n one incremented, and `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.FollowerIDs;\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\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) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
4
+ "content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount` and `FollowerIDs`, and which `InviteRewards` they've\n claimed (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------ | ------------------------------------------------------------------- | ------------------------------ |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount`/`FollowerIDs` are decremented/pulled, the new\n one incremented, and `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.FollowerIDs;\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\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) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "season-system",
3
3
  "description": "Build a season / battle-pass-style meta-progression system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService): load season chain definitions, fetch the currently active season in a chain, load the player's per-chain season state, grant status tokens (season XP/points) that advance a tier track, and claim a reached tier's reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a season pass, battle pass, status track, tier-reward system, seasonal meta-progression, or otherwise touches client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition, SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they don't name the module explicitly.",
4
- "content": "---\nname: season-system\ndescription: >-\n Build a season / battle-pass-style meta-progression system in a game on the\n iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService):\n load season chain definitions, fetch the currently active season in a chain,\n load the player's per-chain season state, grant status tokens (season\n XP/points) that advance a tier track, and claim a reached tier's reward. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a season pass, battle pass, status\n track, tier-reward system, seasonal meta-progression, or otherwise touches\n client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition,\n SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they\n don't name the module explicitly.\n---\n\n# Season system (iDosGames TS SDK)\n\nThe Season module is a battle-pass-style meta-progression track: a title\ndefines one or more **season chains**, each chain runs a sequence of\n**seasons** back to back (and cycles again after the last one), and each\nseason has a ladder of **tiers** the player climbs by earning **status\ntokens** (season XP/points). Reaching a tier unlocks that tier's reward, which\nthe player then claims. Everything is **server-authoritative**: the client\nasks the backend to grant tokens or claim a reward, the backend validates and\napplies it, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate season state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `SeasonService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(already claimed, tier not reached, wrong access mode, not logged in) —\nsurface the error, don't try to reproduce the check client-side.\n\n## The three data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n season chains: `SeasonDefinitions.Chains`, keyed by `SeasonChainID`. Each\n chain (`SeasonChainDefinition`) has a `Schedule`, an optional segment\n `Gate`, and an ordered list of `Seasons` (`SeasonDefinition`), each with a\n `DurationSec` and its own `Tiers` (`SeasonTierDefinition[]`). Fetched with\n `getDefinitions()`.\n2. **Active season info** (config + a state slice, per chain) — which season\n in the chain is live _right now_, its computed start/end, seconds\n remaining, and the next tier the player hasn't reached. Fetched per chain\n with `getActiveSeason(seasonChainID)`.\n3. **User season state** (state, per player, per chain) — this player's\n progress in one chain: `CurrentTier`, `ClaimedTierRewards`, which season\n version they're on. Fetched with `getUserState(seasonChainID)`, and also\n embedded in `ActiveSeasonInfo.UserState`.\n\nA season chain is identified by a string `SeasonChainID`; a season inside it\nby `SeasonID`; a tier by its plain `Tier` number, where `1` is the always-on\nbase tier (reached with 0 tokens). There's a single reward track per tier\n(`SeasonTierDefinition.TierReachedReward`) — no separate free/premium track\nsplit in this module.\n\n**Status tokens** are the season's XP/points currency, tracked internally\nthrough the same Core/EventToken ledger every other event-token currency\nuses. Calling `grantStatusTokens` adds an amount and the backend recomputes\n`CurrentTier` from the new cumulative total against the season's `Tiers`\nladder (`RequiredTokens` per tier — highest tier whose threshold is met\nwins). Granting is a distinct step from claiming — advancing a tier does not\nauto-claim its reward; the player (or your UI) calls `claimTierReward`\nseparately for each tier they want to collect.\n\nOnly `SeasonDefinitions` (the root config type) is re-exported from the\npackage root; `SeasonChainDefinition` / `SeasonDefinition` /\n`SeasonTierDefinition` are not directly importable — read them off the\nresolved `SeasonDefinitions` tree instead. See\n[references/data-model.md](references/data-model.md) for the full shape, the\nexact tier-threshold algorithm, season-rollover (\"Wipe\") semantics, and how\nthe season-tier reward overlay used by _other_ modules (Leaderboard, Reward,\nReferral, Quest milestones) relates to (and is separate from) this module's\nown `TierReachedReward`. You do **not** need it to call the methods — only to\ndrive richer UI or understand a cross-module reward scaling feature.\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 seasons = client.season; // the SeasonService\n```\n\nEvery season method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `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, e.g. missing `seasonChainID` or a non-positive amount/tier\nnumber), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside\nthe throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"No active\nseason in this chain.\", \"Tier 3 not reached yet. Current tier: 2.\", \"Tier 3\nreward already claimed.\").\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- |\n| `getDefinitions()` | Load the title's season chain catalog (config). | `SeasonDefinitions` |\n| `getActiveSeason(seasonChainID)` | Load the chain's currently-live season + this player's state in it. | `ActiveSeasonInfo` |\n| `getUserState(seasonChainID)` | Load this player's progress in one chain (state only). | `UserSeasonStateResponse` (= `UserSeasonState`) |\n| `grantStatusTokens(seasonChainID, amount)` | Add status tokens (season XP/points); may bump `CurrentTier`. | `GrantStatusTokensResponse` (`NewTier`, `TierUp`) |\n| `claimTierReward(seasonChainID, tierNumber)` | Claim a reached tier's reward (one-time per tier). | `ClaimTierRewardResponse` (`Resources`) |\n\nThere are no batch methods on this module — each call operates on one season\nchain (and, for claims, one tier) at a time.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `claimTierReward`'s\ngranted resources ride along in `data.Resources` (a shared `ResourceOperation`\n— see `packages/core/src/models/_shared/ResourceModels.ts`) and are already\napplied to the cached currency/item balances, so read updated balances\nstraight from the cache.\n\n**`grantStatusTokens` is access-gated per chain**, not just by auth. Each\nchain's config sets `GrantTokensAccessMode` (`\"ServerOnly\"` | `\"ClientOnly\"` |\n`\"Both\"`, default `\"ServerOnly\"`). If a chain is `\"ServerOnly\"` — the typical\nproduction setup for tokens that should only come from tournament results,\nmatch wins, or quest completion — the client-facing call is rejected outright\nwith `\"GrantStatusTokens cannot be called from client for this chain.\"` before\nit even looks at your amount. A rejection here usually means \"wrong access\nmode for this chain's design,\" not a bug in your integration.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { SeasonDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<SeasonDefinitions>(\"Season\");\nconst chain = defs?.Chains?.[\"battle_pass_main\"];\nchain?.Seasons; // ordered SeasonDefinition[] for this chain\n\n// Per-chain user state (present after getUserState()/getActiveSeason()/a grant/claim):\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\nstate?.CurrentTier; // highest tier reached\nstate?.ClaimedTierRewards; // number[] of tier numbers already claimed\nstate?.CurrentSeasonID; // which season within the chain\n```\n\nThere's no separate cached \"active season\" slot — `ActiveSeasonInfo` (the\nlive season, its `Tiers`, computed dates, `NextTier`) is only available from\nthe `getActiveSeason` call's own return value; only its embedded `UserState`\ngets written into `client.data.user.state.Season`. Keep the last\n`ActiveSeasonInfo` you fetched in your own component/store if you need to\nrender the ladder alongside cached progress.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `season:definitionsLoaded` → `SeasonDefinitions`\n- `season:activeLoaded` → `ActiveSeasonInfo`\n- `season:userStateLoaded` → `UserSeasonStateResponse`\n- `season:statusTokensGranted` → `GrantStatusTokensResponse`\n- `season:tierRewardClaimed` → `ClaimTierRewardResponse`\n\nThe coarse `user:seasonUpdated` (and `user:anyUpdated`) also fire on any\nseason cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"season:statusTokensGranted\", (r) => {\n if (r.TierUp) console.log(`Reached tier ${r.NewTier}!`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load a chain, show the ladder, and claim a reached tier\n\n```ts\nawait client.season.getDefinitions();\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (!active.ok) return showError(active.error); // e.g. \"No active season in this chain.\"\n\nconst { Season, NextTier, SecondsRemaining } = active.data;\nconst currentTier =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]?.CurrentTier ??\n 0;\nconst claimed =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.ClaimedTierRewards ?? [];\n\nfor (const tier of Season?.Tiers ?? []) {\n const reached = (tier.Tier ?? 0) <= currentTier;\n const alreadyClaimed = claimed.includes(tier.Tier ?? -1);\n // reached && !alreadyClaimed -> show a \"Claim\" button for this tier.\n}\n\nif (currentTier >= 1 && !claimed.includes(1)) {\n const res = await client.season.claimTierReward(\"battle_pass_main\", 1);\n if (!res.ok) return showError(res.error); // e.g. \"Tier 1 reward already claimed.\"\n // res.data.Resources already applied to cached balances.\n}\n```\n\n`getActiveSeason` fails with `\"No active season in this chain.\"` both when the\nchain is fully inactive/misconfigured and when the chain is legitimately\n**paused** between two chained seasons (a configured gap) — treat both as\n\"nothing to show right now,\" not as an error worth retrying aggressively.\n\n### Grant status tokens (season XP/points)\n\n```ts\nconst res = await client.season.grantStatusTokens(\"battle_pass_main\", 250);\nif (!res.ok) return showError(res.error);\n\nres.data.NewTier; // tier after this grant\nres.data.TierUp; // true if this grant crossed into a new tier\nres.data.NewStatusTokens; // running cumulative token total for the current season\nres.data.OldTier; // tier before this grant, for a \"leveled up from X to Y\" toast\n```\n\nOnly wire this to a client button if the chain's `GrantTokensAccessMode` is\n`\"ClientOnly\"` or `\"Both\"` — see the Methods section above. For a title that\nawards status tokens purely from server-side triggers (match results,\ntournament placements), this call has nothing to do and should not be\nexposed in the UI at all for that chain.\n\n### Just show progress toward the next tier\n\n```ts\nawait client.season.getUserState(\"battle_pass_main\");\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\n\n// Combine with a previously-fetched ActiveSeasonInfo for the ladder:\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n active.data.NextTier?.RequiredTokens; // tokens needed for the next tier\n active.data.NextTier?.Tier;\n // NextTier is null once the player has reached the season's highest tier.\n}\n```\n\n### Handle claim edge cases\n\n```ts\nconst res = await client.season.claimTierReward(\"battle_pass_main\", 3);\nif (!res.ok) {\n switch (res.reason) {\n case \"server\":\n // e.g. \"Tier 3 not reached yet. Current tier: 2.\" or\n // \"Tier 3 reward already claimed.\" — read res.error and toast it\n showError(res.error);\n break;\n case \"unauthorized\":\n // session expired — re-auth then retry\n break;\n case \"connection\":\n // transient — offer a Retry button\n break;\n default:\n showError(res.error);\n }\n return;\n}\n```\n\n### Handle a season rollover on relaunch\n\n```ts\n// After a client relaunch or a long idle gap, don't trust a stale cached\n// CurrentTier/ClaimedTierRewards — the chain may have advanced to its next\n// season (or a new cycle) since the player last called in, which triggers a\n// server-side reset (see references/data-model.md#season-rollover-wipe).\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n // active.data.UserState now reflects the CURRENT season; the cache under\n // client.data.user.state.Season.States[\"battle_pass_main\"] was refreshed\n // as a side effect of this call.\n const seasonID = active.data.Season?.SeasonID;\n const stateSeasonID =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.CurrentSeasonID;\n // stateSeasonID === seasonID confirms you're looking at fresh progress.\n}\n```\n\n## Gotchas\n\n- **Granting tokens and claiming a reward are separate steps.**\n `grantStatusTokens` only advances `CurrentTier`; it does not claim anything.\n Your UI must call `claimTierReward` per tier — don't assume a `TierUp: true`\n response means the reward already landed in inventory.\n- **`grantStatusTokens` is access-gated per chain**, independent of whether\n the caller is logged in. `GrantTokensAccessMode: \"ServerOnly\"` (the default)\n rejects every client-initiated call for that chain outright — check which\n mode a given chain uses (via its `SeasonChainDefinition` in `Definitions`)\n before wiring a client button to this call.\n- **A season chain can be gated to a segment.** `SeasonChainDefinition.Gate`\n (Core/Segment `SegmentGate`) can restrict a chain to specific\n segments/levels/countries/premium tiers/experiment variants. A player\n failing the gate gets `\"This season is not available for you.\"` from both\n `getActiveSeason` and `grantStatusTokens` — this is audience targeting, not\n a bug.\n- **Season transitions silently reset progress server-side (\"Wipe\").** When\n the chain has moved on to its next season (or a new cycle) since the player\n last interacted with it, the very next call touching that chain resets\n `CurrentTier` to `1` and clears `ClaimedTierRewards` for the new season —\n this happens lazily on next access, not on a timer, so re-fetch\n (`getActiveSeason`/`getUserState`) rather than trusting a long-cached\n `CurrentTier` across relaunches. See\n [references/data-model.md](references/data-model.md#season-rollover-wipe).\n- **Claims are one-time per tier, tracked client-cache-side too.**\n `ClaimedTierRewards` is a de-duplicated list the SDK cache maintains\n locally (`patchSeasonClaimedTier` only pushes a tier number if it isn't\n already present) as well as the backend enforcing it server-side — expect a\n `reason: \"server\"` rejection (e.g. \"Tier N reward already claimed.\") on a\n repeat call, and use the cached list to gray out the button before the\n player even tries.\n- **`getActiveSeason`'s season/tier ladder isn't cached** — only its embedded\n `UserState` is written to `client.data.user.state.Season`. If you need the\n season's `Tiers`/dates/`NextTier` on a later screen, either refetch\n `getActiveSeason` or hold onto the last response yourself; don't expect it\n in `client.data`.\n- **A tier's reward is not the same thing as the season-tier reward overlay.**\n `SeasonTierDefinition.TierReachedReward` (what `claimTierReward` pays out)\n is a plain, unscaled `ResourceGrant`. The separate `SeasonTierRewardSet`\n overlay (used by Leaderboard/Reward/Quest-milestone rewards to scale _their\n own_ payout by the player's season tier) is not applied here and is not\n something you configure through this module — see\n [references/data-model.md](references/data-model.md#the-season-tier-reward-overlay-used-by-other-modules)\n if you run into it from another module's config.\n- **Only `SeasonDefinitions` is exported at the package root.**\n `SeasonChainDefinition` / `SeasonDefinition` / `SeasonTierDefinition` aren't\n directly importable from `@idosgames/core` — read them structurally off the\n resolved config tree instead of trying to import the type by name.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID` embeds a UUID), so two separate calls are two real\n operations — a double-clicked \"Claim\" can be rejected the second time as\n \"already claimed\" (harmless) but a double-clicked \"Grant\" really does grant\n twice. Disable the control while a call is in flight. Firing the same\n endpoint again within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\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- **Field names are PascalCase straight off the backend JSON**, and every\n schema keeps `.passthrough()`, so a field the backend adds later still\n round-trips even before the SDK's types are updated for it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the exact tier-threshold algorithm, chain/window resolution and pause\nsemantics, the season-rollover (\"Wipe\") rule, and the season-tier reward\noverlay mechanism other modules build on top of a player's season tier. Read\nit when building config-driven UI (a season selector, a tier ladder with\ncountdown) or when an error message points at a config rule you need to\nunderstand.\n",
4
+ "content": "---\nname: season-system\ndescription: >-\n Build a season / battle-pass-style meta-progression system in a game on the\n iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService):\n load season chain definitions, fetch the currently active season in a chain,\n load the player's per-chain season state, grant status tokens (season\n XP/points) that advance a tier track, and claim a reached tier's reward. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a season pass, battle pass, status\n track, tier-reward system, seasonal meta-progression, or otherwise touches\n client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition,\n SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they\n don't name the module explicitly.\n---\n\n# Season system (iDosGames TS SDK)\n\nThe Season module is a battle-pass-style meta-progression track: a title\ndefines one or more **season chains**, each chain runs a sequence of\n**seasons** back to back (and cycles again after the last one), and each\nseason has a ladder of **tiers** the player climbs by earning **status\ntokens** (season XP/points). Reaching a tier unlocks that tier's reward, which\nthe player then claims. Everything is **server-authoritative**: the client\nasks the backend to grant tokens or claim a reward, the backend validates and\napplies it, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate season state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `SeasonService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(already claimed, tier not reached, wrong access mode, not logged in) —\nsurface the error, don't try to reproduce the check client-side.\n\n## The three data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n season chains: `SeasonDefinitions.Chains`, keyed by `SeasonChainID`. Each\n chain (`SeasonChainDefinition`) has a `Schedule`, an optional segment\n `Gate`, and an ordered list of `Seasons` (`SeasonDefinition`), each with a\n `DurationSec` and its own `Tiers` (`SeasonTierDefinition[]`). Fetched with\n `getDefinitions()`.\n2. **Active season info** (config + a state slice, per chain) — which season\n in the chain is live _right now_, its computed start/end, seconds\n remaining, and the next tier the player hasn't reached. Fetched per chain\n with `getActiveSeason(seasonChainID)`.\n3. **User season state** (state, per player, per chain) — this player's\n progress in one chain: `CurrentTier`, `ClaimedTierRewards`, which season\n version they're on. Fetched with `getUserState(seasonChainID)`, and also\n embedded in `ActiveSeasonInfo.UserState`.\n\nA season chain is identified by a string `SeasonChainID`; a season inside it\nby `SeasonID`; a tier by its plain `Tier` number, where `1` is the always-on\nbase tier (reached with 0 tokens). There's a single reward track per tier\n(`SeasonTierDefinition.TierReachedReward`) — no separate free/premium track\nsplit in this module.\n\n**Status tokens** are the season's XP/points currency, tracked internally\nthrough the same Core/EventToken ledger every other event-token currency\nuses. Calling `grantStatusTokens` adds an amount and the backend recomputes\n`CurrentTier` from the new cumulative total against the season's `Tiers`\nladder (`RequiredTokens` per tier — highest tier whose threshold is met\nwins). Granting is a distinct step from claiming — advancing a tier does not\nauto-claim its reward; the player (or your UI) calls `claimTierReward`\nseparately for each tier they want to collect.\n\nOnly `SeasonDefinitions` (the root config type) is re-exported from the\npackage root; `SeasonChainDefinition` / `SeasonDefinition` /\n`SeasonTierDefinition` are not directly importable — read them off the\nresolved `SeasonDefinitions` tree instead. See\n[references/data-model.md](references/data-model.md) for the full shape, the\nexact tier-threshold algorithm, season-rollover (\"Wipe\") semantics, and how\nthe season-tier reward overlay used by _other_ modules (Leaderboard, Reward,\nReferral, Quest milestones) relates to (and is separate from) this module's\nown `TierReachedReward`. You do **not** need it to call the methods — only to\ndrive richer UI or understand a cross-module reward scaling feature.\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 seasons = client.season; // the SeasonService\n```\n\nEvery season method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `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, e.g. missing `seasonChainID` or a non-positive amount/tier\nnumber), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside\nthe throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"No active\nseason in this chain.\", \"Tier 3 not reached yet. Current tier: 2.\", \"Tier 3\nreward already claimed.\").\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- |\n| `getDefinitions()` | Load the title's season chain catalog (config). | `SeasonDefinitions` |\n| `getActiveSeason(seasonChainID)` | Load the chain's currently-live season + this player's state in it. | `ActiveSeasonInfo` |\n| `getUserState(seasonChainID)` | Load this player's progress in one chain (state only). | `UserSeasonStateResponse` (= `UserSeasonState`) |\n| `grantStatusTokens(seasonChainID, amount)` | Add status tokens (season XP/points); may bump `CurrentTier`. | `GrantStatusTokensResponse` (`NewTier`, `TierUp`) |\n| `claimTierReward(seasonChainID, tierNumber)` | Claim a reached tier's reward (one-time per tier). | `ClaimTierRewardResponse` (`Resources`) |\n| `claimTierRewardsBatch(seasonChainID, tierNumbers)` | Claim several tiers in ONE atomic call — one token grant can raise the player through several tiers at once, so more than one reward is often pending. Merged `Resources` at the TOP level; per-item `Data.Resources` is null. | `ClaimTierRewardsBatchResponse` |\n\nThere are no batch methods on this module — each call operates on one season\nchain (and, for claims, one tier) at a time.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `claimTierReward`'s\ngranted resources ride along in `data.Resources` (a shared `ResourceOperation`\n— see `packages/core/src/models/_shared/ResourceModels.ts`) and are already\napplied to the cached currency/item balances, so read updated balances\nstraight from the cache.\n\n**`grantStatusTokens` is access-gated per chain**, not just by auth. Each\nchain's config sets `GrantTokensAccessMode` (`\"ServerOnly\"` | `\"ClientOnly\"` |\n`\"Both\"`, default `\"ServerOnly\"`). If a chain is `\"ServerOnly\"` — the typical\nproduction setup for tokens that should only come from tournament results,\nmatch wins, or quest completion — the client-facing call is rejected outright\nwith `\"GrantStatusTokens cannot be called from client for this chain.\"` before\nit even looks at your amount. A rejection here usually means \"wrong access\nmode for this chain's design,\" not a bug in your integration.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { SeasonDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<SeasonDefinitions>(\"Season\");\nconst chain = defs?.Chains?.[\"battle_pass_main\"];\nchain?.Seasons; // ordered SeasonDefinition[] for this chain\n\n// Per-chain user state (present after getUserState()/getActiveSeason()/a grant/claim):\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\nstate?.CurrentTier; // highest tier reached\nstate?.ClaimedTierRewards; // number[] of tier numbers already claimed\nstate?.CurrentSeasonID; // which season within the chain\n```\n\nThere's no separate cached \"active season\" slot — `ActiveSeasonInfo` (the\nlive season, its `Tiers`, computed dates, `NextTier`) is only available from\nthe `getActiveSeason` call's own return value; only its embedded `UserState`\ngets written into `client.data.user.state.Season`. Keep the last\n`ActiveSeasonInfo` you fetched in your own component/store if you need to\nrender the ladder alongside cached progress.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `season:definitionsLoaded` → `SeasonDefinitions`\n- `season:activeLoaded` → `ActiveSeasonInfo`\n- `season:userStateLoaded` → `UserSeasonStateResponse`\n- `season:statusTokensGranted` → `GrantStatusTokensResponse`\n- `season:tierRewardClaimed` → `ClaimTierRewardResponse`\n\nThe coarse `user:seasonUpdated` (and `user:anyUpdated`) also fire on any\nseason cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"season:statusTokensGranted\", (r) => {\n if (r.TierUp) console.log(`Reached tier ${r.NewTier}!`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load a chain, show the ladder, and claim a reached tier\n\n```ts\nawait client.season.getDefinitions();\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (!active.ok) return showError(active.error); // e.g. \"No active season in this chain.\"\n\nconst { Season, NextTier, SecondsRemaining } = active.data;\nconst currentTier =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]?.CurrentTier ??\n 0;\nconst claimed =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.ClaimedTierRewards ?? [];\n\nfor (const tier of Season?.Tiers ?? []) {\n const reached = (tier.Tier ?? 0) <= currentTier;\n const alreadyClaimed = claimed.includes(tier.Tier ?? -1);\n // reached && !alreadyClaimed -> show a \"Claim\" button for this tier.\n}\n\nif (currentTier >= 1 && !claimed.includes(1)) {\n const res = await client.season.claimTierReward(\"battle_pass_main\", 1);\n if (!res.ok) return showError(res.error); // e.g. \"Tier 1 reward already claimed.\"\n // res.data.Resources already applied to cached balances.\n}\n```\n\n`getActiveSeason` fails with `\"No active season in this chain.\"` both when the\nchain is fully inactive/misconfigured and when the chain is legitimately\n**paused** between two chained seasons (a configured gap) — treat both as\n\"nothing to show right now,\" not as an error worth retrying aggressively.\n\n### Grant status tokens (season XP/points)\n\n```ts\nconst res = await client.season.grantStatusTokens(\"battle_pass_main\", 250);\nif (!res.ok) return showError(res.error);\n\nres.data.NewTier; // tier after this grant\nres.data.TierUp; // true if this grant crossed into a new tier\nres.data.NewStatusTokens; // running cumulative token total for the current season\nres.data.OldTier; // tier before this grant, for a \"leveled up from X to Y\" toast\n```\n\nOnly wire this to a client button if the chain's `GrantTokensAccessMode` is\n`\"ClientOnly\"` or `\"Both\"` — see the Methods section above. For a title that\nawards status tokens purely from server-side triggers (match results,\ntournament placements), this call has nothing to do and should not be\nexposed in the UI at all for that chain.\n\n### Just show progress toward the next tier\n\n```ts\nawait client.season.getUserState(\"battle_pass_main\");\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\n\n// Combine with a previously-fetched ActiveSeasonInfo for the ladder:\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n active.data.NextTier?.RequiredTokens; // tokens needed for the next tier\n active.data.NextTier?.Tier;\n // NextTier is null once the player has reached the season's highest tier.\n}\n```\n\n### Handle claim edge cases\n\n```ts\nconst res = await client.season.claimTierReward(\"battle_pass_main\", 3);\nif (!res.ok) {\n switch (res.reason) {\n case \"server\":\n // e.g. \"Tier 3 not reached yet. Current tier: 2.\" or\n // \"Tier 3 reward already claimed.\" — read res.error and toast it\n showError(res.error);\n break;\n case \"unauthorized\":\n // session expired — re-auth then retry\n break;\n case \"connection\":\n // transient — offer a Retry button\n break;\n default:\n showError(res.error);\n }\n return;\n}\n```\n\n### Handle a season rollover on relaunch\n\n```ts\n// After a client relaunch or a long idle gap, don't trust a stale cached\n// CurrentTier/ClaimedTierRewards — the chain may have advanced to its next\n// season (or a new cycle) since the player last called in, which triggers a\n// server-side reset (see references/data-model.md#season-rollover-wipe).\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n // active.data.UserState now reflects the CURRENT season; the cache under\n // client.data.user.state.Season.States[\"battle_pass_main\"] was refreshed\n // as a side effect of this call.\n const seasonID = active.data.Season?.SeasonID;\n const stateSeasonID =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.CurrentSeasonID;\n // stateSeasonID === seasonID confirms you're looking at fresh progress.\n}\n```\n\n## Gotchas\n\n- **Granting tokens and claiming a reward are separate steps.**\n `grantStatusTokens` only advances `CurrentTier`; it does not claim anything.\n Your UI must call `claimTierReward` per tier — don't assume a `TierUp: true`\n response means the reward already landed in inventory.\n- **`grantStatusTokens` is access-gated per chain**, independent of whether\n the caller is logged in. `GrantTokensAccessMode: \"ServerOnly\"` (the default)\n rejects every client-initiated call for that chain outright — check which\n mode a given chain uses (via its `SeasonChainDefinition` in `Definitions`)\n before wiring a client button to this call.\n- **A season chain can be gated to a segment.** `SeasonChainDefinition.Gate`\n (Core/Segment `SegmentGate`) can restrict a chain to specific\n segments/levels/countries/premium tiers/experiment variants. A player\n failing the gate gets `\"This season is not available for you.\"` from both\n `getActiveSeason` and `grantStatusTokens` — this is audience targeting, not\n a bug.\n- **Season transitions silently reset progress server-side (\"Wipe\").** When\n the chain has moved on to its next season (or a new cycle) since the player\n last interacted with it, the very next call touching that chain resets\n `CurrentTier` to `1` and clears `ClaimedTierRewards` for the new season —\n this happens lazily on next access, not on a timer, so re-fetch\n (`getActiveSeason`/`getUserState`) rather than trusting a long-cached\n `CurrentTier` across relaunches. See\n [references/data-model.md](references/data-model.md#season-rollover-wipe).\n- **Claims are one-time per tier, tracked client-cache-side too.**\n `ClaimedTierRewards` is a de-duplicated list the SDK cache maintains\n locally (`patchSeasonClaimedTier` only pushes a tier number if it isn't\n already present) as well as the backend enforcing it server-side — expect a\n `reason: \"server\"` rejection (e.g. \"Tier N reward already claimed.\") on a\n repeat call, and use the cached list to gray out the button before the\n player even tries.\n- **`getActiveSeason`'s season/tier ladder isn't cached** — only its embedded\n `UserState` is written to `client.data.user.state.Season`. If you need the\n season's `Tiers`/dates/`NextTier` on a later screen, either refetch\n `getActiveSeason` or hold onto the last response yourself; don't expect it\n in `client.data`.\n- **A tier's reward is not the same thing as the season-tier reward overlay.**\n `SeasonTierDefinition.TierReachedReward` (what `claimTierReward` pays out)\n is a plain, unscaled `ResourceGrant`. The separate `SeasonTierRewardSet`\n overlay (used by Leaderboard/Reward/Quest-milestone rewards to scale _their\n own_ payout by the player's season tier) is not applied here and is not\n something you configure through this module — see\n [references/data-model.md](references/data-model.md#the-season-tier-reward-overlay-used-by-other-modules)\n if you run into it from another module's config.\n- **Only `SeasonDefinitions` is exported at the package root.**\n `SeasonChainDefinition` / `SeasonDefinition` / `SeasonTierDefinition` aren't\n directly importable from `@idosgames/core` — read them structurally off the\n resolved config tree instead of trying to import the type by name.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID` embeds a UUID), so two separate calls are two real\n operations — a double-clicked \"Claim\" can be rejected the second time as\n \"already claimed\" (harmless) but a double-clicked \"Grant\" really does grant\n twice. Disable the control while a call is in flight. Firing the same\n endpoint again within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\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- **Field names are PascalCase straight off the backend JSON**, and every\n schema keeps `.passthrough()`, so a field the backend adds later still\n round-trips even before the SDK's types are updated for it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the exact tier-threshold algorithm, chain/window resolution and pause\nsemantics, the season-rollover (\"Wipe\") rule, and the season-tier reward\noverlay mechanism other modules build on top of a player's season tier. Read\nit when building config-driven UI (a season selector, a tier ladder with\ncountdown) or when an error message points at a config rule you need to\nunderstand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-system",
3
3
  "description": "Build a friends / social system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): load the friends list, incoming friend requests, and recommended friends, send/accept/decline friend requests, remove a friend, and read the social activity timeline (attacks, raids, friend-adds). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a friends list screen, friend-request inbox, add-friend flow, recommended friends / player search, or an activity feed, or otherwise touches client.social, SocialService, SocialModels, FriendPublicProfile, or the social timeline — even if they don't name the module explicitly.",
4
- "content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## The three lists + the feed\n\nPlayer social state (`UserSocialState`) has four independent arrays, all\nstring `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Only grown client-side by `sendFriendRequest`; there\n is no `getOutgoingRequests()` — track it from the cache after you send.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\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 social = client.social; // the SocialService\n```\n\nEvery social 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, e.g. missing target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\nsocial?.Accepted; // string[] of friend UserIDs\nsocial?.IncomingRequests; // string[] awaiting your accept/decline\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved\nsocial?.Timeline; // SocialTimelineEvent[]\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// There's no polling endpoint for the other side's decision — re-check via\n// getFriendsList()/getIncomingRequests() (e.g. on next screen focus) to see\n// if it was accepted (moves to Accepted) or the outgoing entry disappears.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for 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 on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
4
+ "content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## The three lists + the feed\n\nPlayer social state (`UserSocialState`) has four independent arrays, all\nstring `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Only grown client-side by `sendFriendRequest`; there\n is no `getOutgoingRequests()` — track it from the cache after you send.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\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 social = client.social; // the SocialService\n```\n\nEvery social 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, e.g. missing target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\nThe response is **self-sufficient**: `Counters` carries the sizes of YOUR lists\nafter the operation (`FriendsCount`, `IncomingRequestsCount`,\n`OutgoingRequestsCount`), and `Target` carries the other side's public profile\nwhere the UI needs it right now — sending and accepting a request. Apply your\nown edit locally and reconcile against `Counters`; do not re-issue\n`getFriendsList()` just to redraw. `Target` is absent for decline/remove: the\nentry disappears from the list anyway, so the server does not read the profile.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\nsocial?.Accepted; // string[] of friend UserIDs\nsocial?.IncomingRequests; // string[] awaiting your accept/decline\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved\nsocial?.Timeline; // SocialTimelineEvent[]\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// There's no polling endpoint for the other side's decision — re-check via\n// getFriendsList()/getIncomingRequests() (e.g. on next screen focus) to see\n// if it was accepted (moves to Accepted) or the outgoing entry disappears.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for 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 on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
5
5
  "references": []
6
6
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
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"
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\n### Addressing an event from title config (short form)\n\nThe composite key above is a **runtime** address — the cycle index and the\nwindow start are unknowable when a reward is authored. So a reward written in\ntitle config (a Special-mode choice on the board, a store offer, a quest\npayout…) addresses the event by name instead:\n\n| `Address.EntityID` in config | Meaning |\n| ---------------------------- | ------------------------------------------------------------ |\n| `\"raid_rotation\"` | whichever instance of that event is live at grant time |\n| `\"raid_rotation:boss_phase\"` | that chain phase, current cycle — skipped when it isn't live |\n\nThe backend expands it right before the grant (`EventTokenAddressResolver`,\ncalled from `ResourceService`), stamping the instance suffix that is active at\nthat moment. A grant whose event is paused, off, or currently in another phase\nis dropped rather than written to a bucket nobody reads; a _consume_ keeps the\nshort address so the price can never silently become free. Already-composite\naddresses pass through untouched, so this is safe to re-apply.\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
9
  }
10
10
  ]
11
11
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "tutorial-system",
3
+ "description": "Build an onboarding / tutorial system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.tutorial (TutorialService): load tutorial flow and step definitions, load the player's progress, start a flow, report a step as shown, complete or skip a step, skip a whole flow, claim the completion reward, and replay a flow. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a first-time user experience, onboarding, tutorial overlays, guided first session, coach marks, hint bubbles anchored to UI elements, a \"teach the player the board\" sequence, or otherwise touches client.tutorial, TutorialService, TutorialDefinitions, UserTutorialState, TutorialFlowView, or TutorialStepCompletionMode — even if they don't name the module explicitly.",
4
+ "content": "---\nname: tutorial-system\ndescription: >-\n Build an onboarding / tutorial system in a game on the iDosGames TypeScript\n SDK (@idosgames/core) via client.tutorial (TutorialService): load tutorial\n flow and step definitions, load the player's progress, start a flow, report a\n step as shown, complete or skip a step, skip a whole flow, claim the\n completion reward, and replay a flow. Use this whenever the user is working\n in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and\n wants a first-time user experience, onboarding, tutorial overlays, guided\n first session, coach marks, hint bubbles anchored to UI elements, a \"teach\n the player the board\" sequence, or otherwise touches client.tutorial,\n TutorialService, TutorialDefinitions, UserTutorialState, TutorialFlowView, or\n TutorialStepCompletionMode — even if they don't name the module explicitly.\n---\n\n# Tutorial system (iDosGames TS SDK)\n\nThe Tutorial module runs a title's onboarding: **flows** of ordered **steps**,\neach teaching one mechanic. Everything is **server-authoritative** — the\nbackend owns which step is current, when a step closes, what it unlocks and\nwhat it pays. The client asks, checks the result, and renders from the cache.\n\nThis skill is for **using** the production `TutorialService`, not for porting or\nextending it. A rejected call is the backend enforcing a rule (wrong step, flow\nnot running, step not skippable) — surface the error, don't reproduce the check\nclient-side.\n\n## The two things you must understand first\n\nAlmost every bug in tutorial UI comes from getting one of these wrong.\n\n### 1. Not every step is yours to close\n\nA step declares **how** it completes, in `CurrentStep.Completion.Mode`:\n\n| Mode | Who closes it | What your UI does |\n|---|---|---|\n| `ClientAck` | you, via `completeStep` | show a **Next** button |\n| `Auto` | closes on being shown | just call `reportStepShown` |\n| `SystemEvent` | the **backend**, off a real game event | show the hint, show **no** button, wait |\n| `Composite` | the backend, several events | same as `SystemEvent` |\n\nCalling `completeStep` on a `SystemEvent` step is **refused by the server**, and\nthat refusal is deliberate: the step's whole point is that the player actually\nrolled the dice / bought the thing. If you wire a Next button to every step,\nyour \"make a roll\" step becomes a button that hands out its reward for free —\nand the backend will stop you, so the player sees an error instead of a\ntutorial.\n\n```ts\nconst mode = view.CurrentStep?.Completion?.Mode ?? \"ClientAck\";\nconst canTapNext = mode === \"ClientAck\";\n```\n\n### 2. Progress can arrive on a call you didn't make\n\nWhen a `SystemEvent` step advances, the backend attaches the progress to the\nresponse of **whatever action caused it** — the board roll, the purchase. The\nSDK applies it to the cache and emits an event. So:\n\n```ts\nclient.on(\"tutorial:systemProgress\", (updates) => {\n // updates: [{ FlowID, StepID, Progress, Target, Completed }]\n // re-render the overlay; if Completed, ask for fresh state to get the next step\n});\n```\n\n**Do not poll** `getUserTutorialState` in a loop waiting for a step to close.\nSubscribe. Polling is how you get a tutorial that lags a second behind the\naction it just asked for.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — `client.tutorial\n .getTutorialDefinitions()` → `TutorialDefinitions.Flows[flowID]` with its\n `Steps`, each carrying `Identity` (title, body, `AnchorID`), `Completion`,\n `Effects`, `Policy`.\n2. **State + view** (per player) — `client.tutorial.getUserTutorialState()` →\n `{ State, Flows, UnlockedFeatures }`. `Flows` is a list of\n **`TutorialFlowView`**, and this is what your UI should render from: it\n already carries `CurrentStep` (the definition), `TotalSteps`,\n `CompletedSteps`, `CanSkip`, `RewardPending`. You do not have to join the\n two shapes yourself.\n\n## Recipes\n\n### Show the current step\n\n```ts\nconst res = await client.tutorial.getUserTutorialState();\nif (!res.ok) return; // surface res.error\n\nconst active = res.data.Flows?.find((f) => f.Status === \"InProgress\");\nif (!active?.CurrentStep) return; // nothing to teach right now\n\nconst id = active.CurrentStep.Identity;\nshowHint({\n title: id?.TitleKey ? t(id.TitleKey) : (id?.Title ?? \"\"),\n body: id?.BodyKey ? t(id.BodyKey) : (id?.Body ?? \"\"),\n anchor: id?.AnchorID, // your UI element id\n highlight: id?.HighlightTarget ?? id?.AnchorID,\n showNext: (active.CurrentStep.Completion?.Mode ?? \"ClientAck\") === \"ClientAck\",\n canSkip: active.CanSkip,\n});\n\nawait client.tutorial.reportStepShown(active.FlowID, active.CurrentStepID!);\n```\n\n`reportStepShown` is worth calling for **every** step, not only `Auto` ones: it\nis what the funnel measures time-on-step from, and that is the number that tells\nthe publisher which step is losing players.\n\n### Advance\n\n```ts\n// Only for ClientAck steps.\nconst r = await client.tutorial.completeStep(flowID, stepID);\nif (r.ok) render(r.data.Flow); // the view already has the NEXT step\n```\n\nThe response carries the updated `TutorialFlowView`, so you do not need a state\nround-trip after a step. When `Flow.Status` becomes `Completed`, the flow is\ndone.\n\n### Skip\n\n```ts\nif (view.CanSkip) await client.tutorial.skipFlow(flowID); // whole flow\nawait client.tutorial.skipStep(flowID, stepID); // one optional step\n```\n\n`CanSkip` already accounts for both the flow policy and the current step's\norder — don't recompute it. Skipping pays nothing: a skipped step grants no\nreward, by design.\n\n### Claim the reward\n\n```ts\nif (view.RewardPending) {\n const r = await client.tutorial.claimFlowReward(flowID);\n if (r.ok) showPayout(r.data.Granted); // ResourceOperation\n}\n```\n\nA flow configured with `Reward.AutoClaim` pays out on its last step and never\nreports `RewardPending` — so gating your payout screen on that flag is correct\nfor both configurations.\n\n### Replay from a settings screen\n\n```ts\n// Listing tutorials must NOT start one. That is what the flag is for.\nconst res = await client.tutorial.getUserTutorialState(false);\n\nawait client.tutorial.resetFlow(flowID); // only if RestartPolicy allows it\n```\n\n`resetFlow` never re-grants the reward — the server keeps the claimed flag\nthrough the reset. Don't build a UI that promises otherwise.\n\n## Things the server does that you should not duplicate\n\n- **Ordering.** Steps are ordered by `Order`, then `StepID` — but you never\n need that: read `CurrentStep`. Trying to close step 3 while 2 is open is\n refused.\n- **Auto-start.** Flows marked for it begin on the player's first request. You\n do not call `startFlow` for them. Use `startFlow` only for a flow the player\n chose (a replay, a \"show me again\" button), or one with `AutoStart` off.\n- **Gating.** Which flow a player may see (audience, A/B variant,\n prerequisites, schedule) is decided server-side. If a flow is not in the\n `Flows` list, it is not for this player right now.\n- **Feature unlocks.** `UnlockedFeatures` is **advisory** — a list of labels\n the game may use to decide what to show. It is not enforcement. Anything the\n publisher truly gates is gated on the backend and will be refused there.\n- **Scripted outcomes.** A step may predetermine a game outcome (e.g. the board\n roll lands on a Raid tile so the hint isn't lying). This is invisible to you:\n the roll comes back as a normal result. Do not try to detect or replicate it.\n\n## A/B testing onboarding\n\nTwo flows, each bound in the dashboard to a different experiment variant. From\nthe client there is nothing to do — the player simply receives the flow for\ntheir variant. `TutorialFlowView.VariantID` tells you which one they got, which\nis useful for your own analytics events but must not change your rendering.\n\n## Failure handling\n\nEvery method returns `OperationResult<T>`. On `!ok`, `result.reason` is one of\n`client` / `unauthorized` / `connection` / `server` / `validation` /\n`throttled`, and `result.error` is the message. The messages that matter:\n\n- *\"is completed by a game event, not by the client\"* — you wired a Next button\n to a `SystemEvent` step. See the table at the top.\n- *\"is not the current step\"* — your cached view is stale; re-read state.\n- *\"Flow is not in progress\"* — it finished, was skipped, or expired.\n- *\"Finish the '<flow>' tutorial first\"* — a **different** module refused\n because a mandatory tutorial is unfinished. Send the player to the tutorial,\n don't show a generic error.\n",
5
+ "references": []
6
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "user-profile",
3
3
  "description": "Work with the player's own account/session state in the iDosGames TS SDK (@idosgames/core) via client.user (UserService): bootstrap the whole per-player cache at login (ClientState — title config + every module's user state), load the raw inventory snapshot (currencies, items, unstackable instances), read usage-time / session stats, change the username, and delete the account. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants login/session bootstrapping, a profile or account screen, usage-time / playtime tracking, username changes, account deletion, raw inventory reads, or otherwise touches client.user, UserService, ClientState, UserState, UserInventoryState, UsageTimeStats, or client.data.user.state — even if they don't name the module explicitly.",
4
- "content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
4
+ "content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\n\n### `ClientState.Title` is often absent on the wire — and that is not an error\n\nThe title config is identical for every player and changes rarely, so the SDK\ncaches it across sessions. Each response carries `ClientState.TitleConfigVersion`;\nthe SDK stores it next to the config and sends it back as\n`KnownTitleConfigVersion` on the next call. When it still matches, the backend\n**omits the `Title` key entirely** and only the player state travels.\n\n`UserService` resolves this for you — it re-fills `result.data.Title` from local\nstorage before applying it, so `client.data.config.titlePublicConfiguration` is\nalways populated and nothing in game code changes. What you must **not** do is\nread `Title` straight off a raw envelope you captured yourself (a network log, a\nhand-rolled fetch) and conclude the config is gone.\n\nStorage is `localStorage` with a memory fallback; pass `configStorage` to\n`createIDosGamesClient` to supply your own (React Native, a native shell). Any\nstorage failure degrades to the previous behaviour — a full config download —\nnever to a broken launch. The cached config is public title data, not player\ndata, so it deliberately survives logout.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",