@idosgames/mcp 0.1.5 → 0.1.7

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,11 +1,11 @@
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| `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",
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",
8
- "content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n}\n```\n\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\nsupposed to call after a successful deduction to compute and grant the\nkickback — but grepping the entire backend turns up **zero** definitions or\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\nevent tied to a follower's purchase — there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\n FollowerIDs?: string[]; // UserIDs of those followers — kept in sync so a follower switching away can be pulled out correctly\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\nA player's referral code **is their own `UserID`** — the module has no\nseparate generated/short code. To let a player share \"their\" code, show them\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\nor endpoint for a display-friendly code.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`):\n - `Linear`: `mult = BaseMultiplier + PerUnit * max(0, progress - Anchor)`.\n - `Tiered` (default): walk `Tiers` sorted by `AtProgress`; below the\n first breakpoint → `BaseMultiplier`; at/above the last → that tier's\n `Multiplier`; between two breakpoints → the lower tier's `Multiplier`\n (`TierMode: \"Step\"`) or a linear interpolation between the two\n (`TierMode: \"Linear\"`).\n - Clamp to `[MinMultiplier, MaxMultiplier]` (`MaxMultiplier <= 0` means\n \"no upper clamp\"); `NaN`/`Infinity` collapses to `1.0`.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\n uppercased): `\"Cannot activate your own referral code\"`.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n the caller's id is pulled from their `FollowerIDs`.\n - The new referrer's `FollowersCount` is atomically incremented and the\n caller's id added to `FollowerIDs` (`$addToSet`, so re-adding is a\n no-op).\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
8
+ "content": "# Referral data model — reference\r\n\r\nFull shape of the config (`ReferralDefinitions`) and player state\r\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\r\nshared Core/Milestone progression-multiplier math that scales\r\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\r\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\r\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\r\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\r\nbackend adds later still round-trips. Field names are PascalCase (straight\r\nfrom the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\r\n- [SpendRewardDefinition](#spendrewarddefinition)\r\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\r\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\r\n- [Activation flow — server rules](#activation-flow--server-rules)\r\n- [Claim flow — server rules](#claim-flow--server-rules)\r\n\r\n---\r\n\r\n## Config: ReferralDefinitions\r\n\r\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\r\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\r\n\r\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\r\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\r\n\r\n```ts\r\ninterface ReferralDefinitions {\r\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\r\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\r\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\r\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\r\n}\r\n```\r\n\r\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\r\nthe same shared type used across every module (currencies, items, event\r\ntokens, premium-tier bundles). See the `currency-system` skill for its full\r\nshape if you need it.\r\n\r\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\r\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\r\n\r\n```ts\r\ninterface MilestoneDefinition {\r\n MilestoneID?: string;\r\n DisplayName?: string;\r\n AssetPaths?: Record<string, string>;\r\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\r\n Rewards?: ResourceGrant; // base payout\r\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\r\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\r\n SortOrder?: number;\r\n IsFeatured?: boolean;\r\n}\r\n```\r\n\r\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\r\n`BonusActive`/`SeasonChainID` context (see\r\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\r\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\r\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\r\nthough the type carries them for other modules.\r\n\r\n---\r\n\r\n## SpendRewardDefinition\r\n\r\n```ts\r\ninterface SpendRewardDefinition {\r\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\r\n IsEnabled?: boolean; // default true\r\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\r\n SourceCurrencyID?: string; // currency the follower spends\r\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\r\n}\r\n```\r\n\r\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\r\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\r\nsupposed to call after a successful deduction to compute and grant the\r\nkickback — but grepping the entire backend turns up **zero** definitions or\r\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\r\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\r\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\r\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\r\nevent tied to a follower's purchase — there is nothing to listen for.\r\n\r\n---\r\n\r\n## Player state: UserReferralState\r\n\r\nReturned by `getUserState()` as `{ Referral }`; cached at\r\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\r\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\r\n\r\n```ts\r\ninterface UserReferralState {\r\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\r\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\r\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\r\n FollowerIDs?: string[]; // UserIDs of those followers — kept in sync so a follower switching away can be pulled out correctly\r\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\r\n UpdatedAt?: string; // ISO timestamp of last change\r\n}\r\n\r\ninterface ReferralInviteRewardState {\r\n RewardID?: string; // == the MilestoneID key\r\n IsClaimed?: boolean;\r\n ClaimedAt?: string | null;\r\n}\r\n```\r\n\r\n`InviteRewardStates` only contains entries that have actually been claimed —\r\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\r\nmilestone systems); a milestone id absent from the map simply means \"not yet\r\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\r\n`RequiredProgress`.\r\n\r\nA player's referral code **is their own `UserID`** — the module has no\r\nseparate generated/short code. To let a player share \"their\" code, show them\r\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\r\nor endpoint for a display-friendly code.\r\n\r\n---\r\n\r\n## Invite-reward payout — the Milestone resolver\r\n\r\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\r\nverbatim. The backend runs it through the shared\r\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\r\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\r\ncontext (`Referral.cs`, `ClaimInviteReward`):\r\n\r\n```csharp\r\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\r\n{\r\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\r\n Player = doc,\r\n NowUtc = DateTime.UtcNow,\r\n});\r\n```\r\n\r\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\r\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\r\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\r\nbranches are always skipped for Referral. The **only** overlay that can ever\r\nchange an invite-reward payout is the title-wide progression multiplier:\r\n\r\n1. Read the title's `RewardProgressionMultiplierSpec` from\r\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\r\n also read — configured once per title, not per-module).\r\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\r\n scaling.\r\n3. Otherwise (`RewardProgressionResolver.cs`):\r\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\r\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\r\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\r\n **not** `FollowersCount` — the multiplier's progression axis is\r\n independent of the referral threshold you're claiming against.\r\n - Evaluate the multiplier (`EvaluateMultiplier`): `spec.Curve` is the shared\r\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\r\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\r\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\r\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\r\n point the curve is the **identity**, so a player who has not reached the first\r\n tier gets no bonus.\r\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\r\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\r\n real zero. `NaN`/`Infinity` collapses to `1.0`.\r\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\r\n the resolver: a reward multiplier never reduces a reward unless the publisher\r\n says so explicitly.\r\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\r\n `null`, the grant is returned unscaled.\r\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\r\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\r\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\r\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\r\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\r\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\r\n left alone — they're applied later, after scaling, inside\r\n `ResourceService`.\r\n - **Rounding**: each scaled amount goes through the platform-wide\r\n `ModifierService.Apply` with a `Multiply` step, which finishes with\r\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\r\nmultiplier)`, never negative, never silently truncated down.\r\n\r\nTo preview this on the client before the player claims, call\r\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\r\nthe exact same spec/progress/rounding server-side and returns\r\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\r\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\r\nown copy of this multiplier — it's title-wide, not per-module.\r\n\r\n---\r\n\r\n## Activation flow — server rules\r\n\r\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\r\nin order:\r\n\r\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\r\n the SDK side before this is even sent).\r\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\r\n uppercased): `\"Cannot activate your own referral code\"`.\r\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\r\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\r\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\r\n6. If the caller is already subscribed to that **same** code:\r\n `\"Referral code already activated\"`.\r\n7. Otherwise the call **succeeds**, whether or not the player had a previous\r\n referrer:\r\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\r\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\r\n the caller's id is pulled from their `FollowerIDs`.\r\n - The new referrer's `FollowersCount` is atomically incremented and the\r\n caller's id added to `FollowerIDs` (`$addToSet`, so re-adding is a\r\n no-op).\r\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\r\n two as mutual friends (capped by the Social module's friend limit;\r\n silently skipped if either side is already at the cap).\r\n - `IsFirstActivation` is `true` only when the caller had **no** previous\r\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\r\n When true, `ActivationReward` is granted via\r\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\r\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\r\n set permanently — a later referrer switch will not re-grant it.\r\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\r\n guarding against a concurrent change (matches \"no previous referrer\" or\r\n \"still the previously-read referrer\"); if that races, the call fails\r\n with `\"Referral state was modified concurrently. Please retry.\"` and the\r\n client should just retry.\r\n\r\n## Claim flow — server rules\r\n\r\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\r\norder:\r\n\r\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\r\n2. Must exist in `config.Referral.InviteRewards`, else\r\n `\"Invite reward '{id}' not found in configuration\"`.\r\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\r\n `\"Not enough followers. Required: {n}, current: {m}\"`.\r\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\r\n5. The resolved grant (see above) is applied atomically with idempotency key\r\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\r\n only allows the write when there's no existing claimed state for that\r\n reward id (protects against a double-claim race the same way step 3/4\r\n protect against a stale read).\r\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Reward data model — reference\n\nFull shape of the config (Definitions) and player state for all four\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\nand the milestone reward multiplier curve. The config side is **strictly\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\nlater still round-trips. The deeper nested shapes shown below as plain\n`interface` blocks in this doc (`DailyCalendarDefinition`,\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\ntypes — most of them are **not individually exported by name** from\n`@idosgames/core`'s public entry point today; don't write `import type {\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\nstyle indexed-access types if you need the standalone name). Per-user state\nobjects beyond the top-level four dictionaries are typed as lenient\npassthrough shapes on the SDK side — the fields documented below are what the\nbackend actually puts on them. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\n- [Tier-reward settings](#tier-reward-settings)\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\n\n---\n\n## Root config: RewardDefinitions\n\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\n`RewardDefinitions.cs`.\n\n```ts\ninterface RewardDefinitions {\n TierRewards?: TierRewardSettings | null;\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\n Claims?: Record<string, ClaimRewardDefinition> | null;\n}\n```\n\nEach of the four dictionaries is an **independent subsystem** — a title can\nuse only some of them; an empty/absent dictionary just means that subsystem is\noff. All four grant rewards through the same `ResourceGrant`, so premium\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\n\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\n\n```ts\ninterface UserRewardState {\n DailyCalendars?: Record<string, UserDailyCalendarState>;\n IdleAccruals?: Record<string, UserIdleAccrualState>;\n Comebacks?: Record<string, UserComebackState>;\n Claims?: Record<string, UserClaimRewardState>;\n}\n```\n\nAn absent entry in any of the four dictionaries means \"player never touched\nthis ID\" — the server treats it as default/zero state, not an error.\n\n---\n\n## Tier-reward settings\n\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\nrewards resolve across _every_ system that has tiers (premium, season, battle\npass, etc.), not just Reward itself. One mode per title.\n\n```ts\ninterface TierRewardSettings {\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\n RewardStackLowerTiers?: boolean; // default: false\n}\n```\n\n- `Additive` — tier rewards are added **on top of** the base reward.\n- `Replace` — tier rewards **fully replace** the base reward.\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\n `false` (default) — only the best matching tier applies.\n\nThis block is read by `ResourceService`, not by Reward's own claim logic\ndirectly — it's here because `RewardDefinitions` is where it's configured.\n\n---\n\n## Daily calendars\n\n### Config: `DailyCalendarDefinition`\n\n```ts\ninterface DailyCalendarDefinition {\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\n MissThresholdMultiplier?: number; // default 2.0\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\n Gate?: SegmentGate; // null/empty = everyone\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface DailyRewardDay {\n DayNumber?: number; // 1-based, unique per calendar\n Rewards?: ResourceGrant;\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserDailyCalendarState`\n\n```ts\ninterface UserDailyCalendarState {\n CalendarID?: string;\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\n}\n```\n\n### Claim eligibility (`ClaimMode`)\n\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\n\n- **`CalendarDayUtc`** (default): a new claim is available once\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\n `\"Daily reward already claimed today for this calendar\"` if the player\n already claimed on today's UTC date. Ignores player timezone.\n- **`SlidingWindow`**: a new claim is available once\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\n\n### Miss detection and `MissBehavior`\n\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\nfirst, before the new day is computed.\n\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\n- **Miss condition** (was the gap too large?):\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\n- **On miss**, `CollectedDays` becomes:\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\n rewards are forfeited, the streak count itself survives).\n - `ResetToStart` — `0` (hard streak reset).\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\n at 0).\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\n\n### Day resolution\n\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\nlooping — only the _day looked up_ wraps, not the counter).\n\nDefault-calendar resolution when `calendarID` is omitted: the server uses\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\nback to the first entry in the dictionary.\n\n---\n\n## Idle accruals\n\n### Config: `IdleAccrualDefinition`\n\n```ts\ninterface IdleAccrualDefinition {\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Rate?: IdleRateConfig;\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\n Requirements?: IdleAccrualRequirements;\n FirstClaimMode?:\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface IdleRateConfig {\n BaseRatePerSecond: number; // flat, unconditional\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\n}\n\ninterface PremiumTierMultiplier {\n MinPremiumTier?: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n Multiplier?: number;\n}\n\ninterface IdleAccrualRequirements {\n MinCharacterLevel: number; // 0 = not checked\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\n Gate?: SegmentGate; // null/empty = not checked\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\n}\n```\n\n### State: `UserIdleAccrualState`\n\n```ts\ninterface UserIdleAccrualState {\n AccrualID?: string;\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\n}\n```\n\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\n\n```\nrawRate = BaseRatePerSecond\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\n\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\n MinPremiumTier <= player's MaxActiveTier (and matching\n RequiredPremiumID if set); 1.0 if none match or premium is null\n — multipliers never stack.\n\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\n```\n\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\n\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\nsite yet. The config fields exist and round-trip, but don't promise \"gear\nboosts idle income\" in product copy until this is verified live via\n`AppliedRatePerSecond` in a real claim response.\n\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\n\n```\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\n = otherwise, resolved by FirstClaimMode:\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\n\nelapsedSeconds = max(0, now - effectiveStart) in seconds\naccruedSeconds = MaxAccumulationSeconds > 0\n ? min(elapsedSeconds, MaxAccumulationSeconds)\n : elapsedSeconds\n```\n\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\nTry again in {n}s\"`.\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\n zero-payout finalize — sets `LastCollectAt = now`, returns\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\n unscaled and are applied afterward by `ResourceService` as usual.\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\n\n### Requirements gate (checked every claim, not persisted)\n\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\n\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\nhigher premium tier\"`.\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\n'{id}' level {n} is below required {m}\"`.\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\n `\"Required item '{id}' is not in inventory\"`.\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\n'{charID}'\"`.\n\nFailing a requirement does **not** move `LastCollectAt` — once the\nrequirement is met again, the previously-accrued time (up to the cap) is still\ncollectible.\n\n---\n\n## Comeback rewards\n\n### Config: `ComebackRewardDefinition`\n\n```ts\ninterface ComebackRewardDefinition {\n ComebackID?: string; // key in Comebacks; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tiers?: ComebackTier[];\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\n TrackPresenceOnRead?: boolean; // default true\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ComebackTier {\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\n Rewards?: ResourceGrant;\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserComebackState`\n\n```ts\ninterface UserComebackState {\n ComebackID?: string;\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\n LastClaimAt: string; // ISO; MinValue = never claimed\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\n}\n```\n\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\n\nRuns on **every** claim call for this comeback, and also on\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\n\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\n stop — no absence to evaluate yet.\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\n both set): if `ClaimWindowSeconds > 0` and\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\n means it never expires on its own.)\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\n ties broken by taking the higher threshold). If a tier matches AND the\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\n\nThe tier is deliberately locked at the **moment of return**, not at claim\ntime — this stops a player from delaying the claim to try to \"grow into\" a\nricher tier.\n\n### Claim (`RewardV2.ClaimComebackReward`)\n\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\ntimestamp, so a stale pending anchor from a concurrent request can't be\ndouble-spent.\n\n---\n\n## Claim rewards\n\n### Config: `ClaimRewardDefinition`\n\n```ts\ninterface ClaimRewardDefinition {\n ClaimID?: string; // key in Claims; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\n Rewards?: ResourceGrant;\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ClaimLimitOverride {\n MinPremiumTier: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\n WindowSeconds?: number | null;\n TotalClaimLimit?: number | null;\n}\n```\n\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\n\n### State: `UserClaimRewardState`\n\n```ts\ninterface UserClaimRewardState {\n ClaimID?: string;\n TotalClaims: number; // monotonically increasing; never resets\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\n LastClaimAt: string; // ISO; MinValue = never claimed\n}\n```\n\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\n\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\nthe player has an active premium tier, the **one** override with the highest\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\noverrides never stack. Each of that override's four fields is applied only if\nnon-null; a null field falls back to the base `Limits` value, not to \"no\nlimit.\"\n\n### Claim validation order (`RewardV2.PrepareClaimReward`)\n\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\nclaimable by client (server-only)\"`), and `Rewards` is configured.\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\n4. Resolve effective limits (base + best override).\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\n `\"Total claim limit reached ({have}/{limit})\"`.\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\n `\"Reward is on cooldown. Try again in {n}s\"`.\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\n{window}s)\"`.\n8. On success, `now` is appended to the window list, then the list is\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\n first). `TotalClaims` increments by 1 regardless of window/cooldown\n settings.\n\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\nclient `claimReward` call against one is always rejected.\n\n### Batch claiming (backend-only today)\n\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\nindependently (invalid/ineligible ids are filtered out and reported before any\ncharge), then applies the merged valid set as a single atomic operation with\none combined `Resources` payload attached to the first successful result\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\nso this path is not reachable from the TS client yet.\n\n---\n\n## Milestone reward multiplier\n\n`RewardDefinitions.MilestoneRewardMultiplier` is a\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\nfour subsystems; it's a title-wide overlay that other milestone-bearing\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\n_last_ overlay in their reward-resolution chain.\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: ProgressionSource; // metric the multiplier is driven by\n SourceKey?: string; // disambiguator when Source needs one\n CurveType?: \"Tiered\" | \"Linear\"; // default Tiered\n Tiers?: { AtProgress?: number; Multiplier?: number }[]; // used when CurveType = Tiered\n TierMode?: \"Step\" | \"Linear\"; // interpolation BETWEEN tier breakpoints; default Step\n BaseMultiplier?: number; // floor value below the first tier / Linear curve's base\n PerUnit?: number; // used when CurveType = Linear\n Anchor?: number; // used when CurveType = Linear\n MinMultiplier?: number; // hard floor after evaluation\n MaxMultiplier?: number; // hard ceiling after evaluation; <= 0 = no ceiling\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\n`ProgressionSource` values (from `MilestoneModels.ts` /\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\n\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\n\n```\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\n\nif CurveType == \"Linear\":\n raw = BaseMultiplier + PerUnit * max(0, progress - Anchor)\n\nelse (CurveType == \"Tiered\", default):\n tiers sorted ascending by AtProgress\n if no tiers: raw = BaseMultiplier\n else if progress < tiers[0].AtProgress: raw = BaseMultiplier\n else if progress >= tiers[last].AtProgress: raw = tiers[last].Multiplier\n else: find bracketing tiers (lo, hi) where progress falls between AtProgress values\n if TierMode == \"Linear\": raw = lo.Multiplier + frac * (hi.Multiplier - lo.Multiplier)\n where frac = (progress - lo.AtProgress) / (hi.AtProgress - lo.AtProgress)\n else (\"Step\", default): raw = lo.Multiplier\n\nfinal = clamp(raw): NaN/Infinity -> 1.0; then floor at MinMultiplier;\n then ceiling at MaxMultiplier only if MaxMultiplier > 0\n```\n\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\nlive `Multiplier`, the raw `Progress` value read from the player's current\nprogression state, and echoes of `Source`/`SourceKey`.\n\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\n\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\nMatching for items is by `ItemID`; for currencies/event-tokens, by\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\nuntouched by this step — they're applied afterward, on top of the\nalready-scaled `Standard` bundle, by `ResourceService`.\n\n---\n\n## Shared plumbing\n\nThese blocks are reused by all four subsystems (and the rest of the\nplatform) — full details live in their own modules; summarized here only as\nthey affect Reward.\n\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\n used by `Gate` fields on `DailyCalendarDefinition`,\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\n a failing gate always surfaces as `reason: \"server\"` with a\n \"locked behind a higher premium tier\"-style message — there is no\n client-visible breakdown of _which_ gate condition failed.\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\n shared type but ignored by `RewardV2`.\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\n subsystem's `Rewards` field and every claim response's `data.Resources` use\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\n amount by the time it reaches the client.\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\n one of the four definition types follow the same rule:\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\n both may be absent for \"no bound.\"\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\n the three id-taking methods (not `claimDailyReward`'s optional\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\n trip for the common typo case.\n"
8
+ "content": "# Reward data model — reference\r\n\r\nFull shape of the config (Definitions) and player state for all four\r\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\r\nand the milestone reward multiplier curve. The config side is **strictly\r\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\r\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\r\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\r\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\r\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\r\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\r\nlater still round-trips. The deeper nested shapes shown below as plain\r\n`interface` blocks in this doc (`DailyCalendarDefinition`,\r\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\r\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\r\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\r\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\r\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\r\ntypes — most of them are **not individually exported by name** from\r\n`@idosgames/core`'s public entry point today; don't write `import type {\r\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\r\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\r\nstyle indexed-access types if you need the standalone name). Per-user state\r\nobjects beyond the top-level four dictionaries are typed as lenient\r\npassthrough shapes on the SDK side — the fields documented below are what the\r\nbackend actually puts on them. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\n## Contents\r\n\r\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\r\n- [Tier-reward settings](#tier-reward-settings)\r\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\r\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\r\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\r\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\r\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\r\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\r\n\r\n---\r\n\r\n## Root config: RewardDefinitions\r\n\r\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\r\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\r\n`RewardDefinitions.cs`.\r\n\r\n```ts\r\ninterface RewardDefinitions {\r\n TierRewards?: TierRewardSettings | null;\r\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\r\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\r\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\r\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\r\n Claims?: Record<string, ClaimRewardDefinition> | null;\r\n}\r\n```\r\n\r\nEach of the four dictionaries is an **independent subsystem** — a title can\r\nuse only some of them; an empty/absent dictionary just means that subsystem is\r\noff. All four grant rewards through the same `ResourceGrant`, so premium\r\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\r\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\r\n\r\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\r\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\r\n\r\n```ts\r\ninterface UserRewardState {\r\n DailyCalendars?: Record<string, UserDailyCalendarState>;\r\n IdleAccruals?: Record<string, UserIdleAccrualState>;\r\n Comebacks?: Record<string, UserComebackState>;\r\n Claims?: Record<string, UserClaimRewardState>;\r\n}\r\n```\r\n\r\nAn absent entry in any of the four dictionaries means \"player never touched\r\nthis ID\" — the server treats it as default/zero state, not an error.\r\n\r\n---\r\n\r\n## Tier-reward settings\r\n\r\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\r\nrewards resolve across _every_ system that has tiers (premium, season, battle\r\npass, etc.), not just Reward itself. One mode per title.\r\n\r\n```ts\r\ninterface TierRewardSettings {\r\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\r\n RewardStackLowerTiers?: boolean; // default: false\r\n}\r\n```\r\n\r\n- `Additive` — tier rewards are added **on top of** the base reward.\r\n- `Replace` — tier rewards **fully replace** the base reward.\r\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\r\n `false` (default) — only the best matching tier applies.\r\n\r\nThis block is read by `ResourceService`, not by Reward's own claim logic\r\ndirectly — it's here because `RewardDefinitions` is where it's configured.\r\n\r\n---\r\n\r\n## Daily calendars\r\n\r\n### Config: `DailyCalendarDefinition`\r\n\r\n```ts\r\ninterface DailyCalendarDefinition {\r\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\r\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\r\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\r\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\r\n MissThresholdMultiplier?: number; // default 2.0\r\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\r\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\r\n Gate?: SegmentGate; // null/empty = everyone\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface DailyRewardDay {\r\n DayNumber?: number; // 1-based, unique per calendar\r\n Rewards?: ResourceGrant;\r\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserDailyCalendarState`\r\n\r\n```ts\r\ninterface UserDailyCalendarState {\r\n CalendarID?: string;\r\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\r\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\r\n}\r\n```\r\n\r\n### Claim eligibility (`ClaimMode`)\r\n\r\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\r\n\r\n- **`CalendarDayUtc`** (default): a new claim is available once\r\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\r\n `\"Daily reward already claimed today for this calendar\"` if the player\r\n already claimed on today's UTC date. Ignores player timezone.\r\n- **`SlidingWindow`**: a new claim is available once\r\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\r\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\r\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\r\n\r\n### Miss detection and `MissBehavior`\r\n\r\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\r\nfirst, before the new day is computed.\r\n\r\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\r\n- **Miss condition** (was the gap too large?):\r\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\r\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\r\n- **On miss**, `CollectedDays` becomes:\r\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\r\n rewards are forfeited, the streak count itself survives).\r\n - `ResetToStart` — `0` (hard streak reset).\r\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\r\n at 0).\r\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\r\n\r\n### Day resolution\r\n\r\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\r\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\r\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\r\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\r\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\r\nlooping — only the _day looked up_ wraps, not the counter).\r\n\r\nDefault-calendar resolution when `calendarID` is omitted: the server uses\r\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\r\nback to the first entry in the dictionary.\r\n\r\n---\r\n\r\n## Idle accruals\r\n\r\n### Config: `IdleAccrualDefinition`\r\n\r\n```ts\r\ninterface IdleAccrualDefinition {\r\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Rate?: IdleRateConfig;\r\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\r\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\r\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\r\n Requirements?: IdleAccrualRequirements;\r\n FirstClaimMode?:\r\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface IdleRateConfig {\r\n BaseRatePerSecond: number; // flat, unconditional\r\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\r\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\r\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\r\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\r\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\r\n}\r\n\r\ninterface PremiumTierMultiplier {\r\n MinPremiumTier?: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n Multiplier?: number;\r\n}\r\n\r\ninterface IdleAccrualRequirements {\r\n MinCharacterLevel: number; // 0 = not checked\r\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\r\n Gate?: SegmentGate; // null/empty = not checked\r\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\r\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\r\n}\r\n```\r\n\r\n### State: `UserIdleAccrualState`\r\n\r\n```ts\r\ninterface UserIdleAccrualState {\r\n AccrualID?: string;\r\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\r\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\r\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\r\n}\r\n```\r\n\r\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\r\n\r\n```\r\nrawRate = BaseRatePerSecond\r\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\r\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\r\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\r\n\r\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\r\n MinPremiumTier <= player's MaxActiveTier (and matching\r\n RequiredPremiumID if set); 1.0 if none match or premium is null\r\n — multipliers never stack.\r\n\r\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\r\n```\r\n\r\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\r\n\r\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\r\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\r\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\r\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\r\nsite yet. The config fields exist and round-trip, but don't promise \"gear\r\nboosts idle income\" in product copy until this is verified live via\r\n`AppliedRatePerSecond` in a real claim response.\r\n\r\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\r\n\r\n```\r\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\r\n = otherwise, resolved by FirstClaimMode:\r\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\r\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\r\n\r\nelapsedSeconds = max(0, now - effectiveStart) in seconds\r\naccruedSeconds = MaxAccumulationSeconds > 0\r\n ? min(elapsedSeconds, MaxAccumulationSeconds)\r\n : elapsedSeconds\r\n```\r\n\r\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\r\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\r\nTry again in {n}s\"`.\r\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\r\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\r\n zero-payout finalize — sets `LastCollectAt = now`, returns\r\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\r\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\r\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\r\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\r\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\r\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\r\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\r\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\r\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\r\n unscaled and are applied afterward by `ResourceService` as usual.\r\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\r\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\r\n\r\n### Requirements gate (checked every claim, not persisted)\r\n\r\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\r\n\r\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\r\nhigher premium tier\"`.\r\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\r\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\r\n'{id}' level {n} is below required {m}\"`.\r\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\r\n `\"Required item '{id}' is not in inventory\"`.\r\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\r\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\r\n'{charID}'\"`.\r\n\r\nFailing a requirement does **not** move `LastCollectAt` — once the\r\nrequirement is met again, the previously-accrued time (up to the cap) is still\r\ncollectible.\r\n\r\n---\r\n\r\n## Comeback rewards\r\n\r\n### Config: `ComebackRewardDefinition`\r\n\r\n```ts\r\ninterface ComebackRewardDefinition {\r\n ComebackID?: string; // key in Comebacks; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Tiers?: ComebackTier[];\r\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\r\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\r\n TrackPresenceOnRead?: boolean; // default true\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ComebackTier {\r\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\r\n Rewards?: ResourceGrant;\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n### State: `UserComebackState`\r\n\r\n```ts\r\ninterface UserComebackState {\r\n ComebackID?: string;\r\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\r\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\r\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\r\n}\r\n```\r\n\r\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\r\n\r\nRuns on **every** claim call for this comeback, and also on\r\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\r\n\r\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\r\n stop — no absence to evaluate yet.\r\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\r\n both set): if `ClaimWindowSeconds > 0` and\r\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\r\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\r\n means it never expires on its own.)\r\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\r\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\r\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\r\n ties broken by taking the higher threshold). If a tier matches AND the\r\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\r\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\r\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\r\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\r\n\r\nThe tier is deliberately locked at the **moment of return**, not at claim\r\ntime — this stops a player from delaying the claim to try to \"grow into\" a\r\nricher tier.\r\n\r\n### Claim (`RewardV2.ClaimComebackReward`)\r\n\r\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\r\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\r\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\r\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\r\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\r\ntimestamp, so a stale pending anchor from a concurrent request can't be\r\ndouble-spent.\r\n\r\n---\r\n\r\n## Claim rewards\r\n\r\n### Config: `ClaimRewardDefinition`\r\n\r\n```ts\r\ninterface ClaimRewardDefinition {\r\n ClaimID?: string; // key in Claims; no '.' or '$'\r\n DisplayName?: string;\r\n Description?: string;\r\n AssetPaths?: Record<string, string>;\r\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\r\n Rewards?: ResourceGrant;\r\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\r\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\r\n Gate?: SegmentGate;\r\n AvailableFromUtc?: string;\r\n AvailableUntilUtc?: string;\r\n}\r\n\r\ninterface ClaimLimitOverride {\r\n MinPremiumTier: number;\r\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\r\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\r\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\r\n WindowSeconds?: number | null;\r\n TotalClaimLimit?: number | null;\r\n}\r\n```\r\n\r\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\r\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\r\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\r\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\r\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\r\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\r\n\r\n### State: `UserClaimRewardState`\r\n\r\n```ts\r\ninterface UserClaimRewardState {\r\n ClaimID?: string;\r\n TotalClaims: number; // monotonically increasing; never resets\r\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\r\n LastClaimAt: string; // ISO; MinValue = never claimed\r\n}\r\n```\r\n\r\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\r\n\r\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\r\nthe player has an active premium tier, the **one** override with the highest\r\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\r\noverrides never stack. Each of that override's four fields is applied only if\r\nnon-null; a null field falls back to the base `Limits` value, not to \"no\r\nlimit.\"\r\n\r\n### Claim validation order (`RewardV2.PrepareClaimReward`)\r\n\r\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\r\nclaimable by client (server-only)\"`), and `Rewards` is configured.\r\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\r\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\r\n4. Resolve effective limits (base + best override).\r\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\r\n `\"Total claim limit reached ({have}/{limit})\"`.\r\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\r\n `\"Reward is on cooldown. Try again in {n}s\"`.\r\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\r\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\r\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\r\n{window}s)\"`.\r\n8. On success, `now` is appended to the window list, then the list is\r\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\r\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\r\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\r\n first). `TotalClaims` increments by 1 regardless of window/cooldown\r\n settings.\r\n\r\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\r\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\r\nclient `claimReward` call against one is always rejected.\r\n\r\n### Batch claiming (backend-only today)\r\n\r\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\r\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\r\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\r\nindependently (invalid/ineligible ids are filtered out and reported before any\r\ncharge), then applies the merged valid set as a single atomic operation with\r\none combined `Resources` payload attached to the first successful result\r\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\r\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\r\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\r\nso this path is not reachable from the TS client yet.\r\n\r\n---\r\n\r\n## Milestone reward multiplier\r\n\r\n`RewardDefinitions.MilestoneRewardMultiplier` is a\r\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\r\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\r\nfour subsystems; it's a title-wide overlay that other milestone-bearing\r\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\r\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\r\n_last_ overlay in their reward-resolution chain.\r\n\r\n```ts\r\ninterface RewardProgressionMultiplierSpec {\r\n Source?: ProgressionSource; // metric the multiplier is driven by\r\n SourceKey?: string; // disambiguator when Source needs one\r\n Curve?: ScalarCurveSpec; // the curve; base 1 unless Base is set. Empty = no scaling\r\n Anchor?: number; // progress value the curve starts counting from; empty = 0\r\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\r\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\r\n}\r\n```\r\n\r\n`ProgressionSource` values (from `MilestoneModels.ts` /\r\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\r\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\r\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\r\n\r\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\r\n\r\nThe eight fields that used to describe the curve here (`CurveType`, `Tiers`, `TierMode`,\r\n`BaseMultiplier`, `PerUnit`, `MinMultiplier`, `MaxMultiplier`) collapsed into one shared\r\n`ScalarCurveSpec`:\r\n\r\n| Old shape | Now |\r\n| --- | --- |\r\n| `CurveType: \"Tiered\"` + `Tiers` | `Shape: \"Table\"` with `Points: [{ AtStep, Value }]` |\r\n| `TierMode: \"Step\" \\| \"Linear\"` | `Interpolation: \"Step\" \\| \"Linear\"` (also `\"Geometric\"`) |\r\n| `CurveType: \"Linear\"` + `PerUnit` | `Shape: \"PerStepRate\"` (share of the base per unit) |\r\n| `BaseMultiplier` | `Base` (empty = 1, i.e. a multiplier that changes nothing) |\r\n| `MinMultiplier` / `MaxMultiplier` | `MinResult` / `MaxResult` — **empty means NO bound**, and `0` now means a real zero |\r\n\r\n```\r\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\r\n\r\nraw = evaluateCurve(spec.Curve, base = 1.0, step = progress, firstStep = spec.Anchor ?? 0)\r\nfinal = NaN/Infinity -> 1.0\r\n```\r\n\r\n⚠ **The floor \"a reward multiplier never REDUCES a reward\" is no longer a config field.**\r\nIt is a domain rule of the resolver: when the publisher sets no `MinResult`, the result is\r\nfloored at `1.0`. Deliberate reduction is expressed by a curve that DOES set `MinResult`\r\nbelow 1 — so it can only happen on purpose, never by a stray zero.\r\n\r\n⚠ **Before the first table point a curve is the IDENTITY, not the first point's value.**\r\nA player who has not reached the first tier gets no bonus at all.\r\n\r\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\r\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\r\nlive `Multiplier`, the raw `Progress` value read from the player's current\r\nprogression state, and echoes of `Source`/`SourceKey`.\r\n\r\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\r\n\r\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\r\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\r\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\r\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\r\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\r\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\r\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\r\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\r\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\r\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\r\nMatching for items is by `ItemID`; for currencies/event-tokens, by\r\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\r\nuntouched by this step — they're applied afterward, on top of the\r\nalready-scaled `Standard` bundle, by `ResourceService`.\r\n\r\n---\r\n\r\n## Shared plumbing\r\n\r\nThese blocks are reused by all four subsystems (and the rest of the\r\nplatform) — full details live in their own modules; summarized here only as\r\nthey affect Reward.\r\n\r\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\r\n used by `Gate` fields on `DailyCalendarDefinition`,\r\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\r\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\r\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\r\n a failing gate always surfaces as `reason: \"server\"` with a\r\n \"locked behind a higher premium tier\"-style message — there is no\r\n client-visible breakdown of _which_ gate condition failed.\r\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\r\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\r\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\r\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\r\n shared type but ignored by `RewardV2`.\r\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\r\n subsystem's `Rewards` field and every claim response's `data.Resources` use\r\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\r\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\r\n amount by the time it reaches the client.\r\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\r\n one of the four definition types follow the same rule:\r\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\r\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\r\n both may be absent for \"no bound.\"\r\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\r\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\r\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\r\n the three id-taking methods (not `claimDailyReward`'s optional\r\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\r\n trip for the common typo case.\r\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -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| `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",
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,11 +1,11 @@
1
1
  {
2
2
  "name": "store-system",
3
3
  "description": "Build a store / shop system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load storefront and offer (SKU) definitions, load the player's purchase counters, and purchase one or many offers (currency/item packs, bundles, cosmetics) with virtual/item cost. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop/store screen, IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition, or offer purchasing — even if they don't name the module explicitly.",
4
- "content": "---\nname: store-system\ndescription: >-\n Build a store / shop system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load storefront and offer\n (SKU) definitions, load the player's purchase counters, and purchase one or\n many offers (currency/item packs, bundles, cosmetics) with virtual/item\n cost. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a shop/store screen,\n IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise\n touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition,\n or offer purchasing — even if they don't name the module explicitly.\n---\n\n# Store system (iDosGames TS SDK)\n\nThe Store module lets a title sell **offers** (SKUs) — currency packs, item\nbundles, cosmetics, anything priced via `ResourceConsume` — grouped into one or\nmore **storefronts**. Everything is **server-authoritative**: the client asks\nthe backend to purchase, the backend validates cost, rules, and limits, and the\nSDK mirrors the confirmed result (resources + purchase counters) into a local\ncache your UI reads. You never mutate store state yourself — you call a\nmethod, check the result, and render from the cache.\n\nThis skill is for **using** the production `StoreService`, not for porting or\nextending it. If a purchase is rejected, that's the backend enforcing a rule\n(cost, time window, audience gate, purchase cap) — surface the error, don't try\nto reproduce the check client-side.\n\nStore's `Cost`/`Rewards` are virtual (`ResourceConsume`/`ResourceGrant`) —\ncurrency, items, event tokens, premium-tier grants. There is no real-money IAP\nreceipt flow inside this module; that lives entirely in the separate Purchase\nmodule (not covered here).\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n storefronts and offers: which offers live in which store, their `Cost` and\n `Rewards`, and their availability `Rules`. Fetched with `getDefinitions()`.\n2. **User store state** (state, per player) — this player's purchase counters\n per offer (`TotalPurchases`, `DailyPurchases`, reset time). Fetched with\n `getUserState()`.\n\nAn offer is identified by a string `OfferID`; a storefront by `StoreID`. An\noffer can be listed in multiple stores via `StoreIDs`, letting the same SKU\nappear in, say, both the main shop and a limited-time event shop. For the full\nfield-by-field shape of Definitions and state (purchase-limit reset math,\nbatch semantics, special-value rules), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\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 store = client.store; // the StoreService\n```\n\nEvery store 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, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Offer not found\nin the specified store.\", \"Offer is not yet available.\", \"Offer has expired.\",\n\"Offer is not available for you.\", \"Purchase limit reached for offer\n'<id>'. Max: <n>.\", \"Daily purchase limit reached for offer '<id>'. Max per\nday: <n>.\", or an `ApplyResourceOperationAtomicAsync` failure such as\ninsufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------- | ---------------------------------------------- | -------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's store/offer catalog (config). | `StoreDefinitions` |\n| `getUserState()` | Load this player's purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` (`Resources`) |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` (`BatchItemResult<StorePurchaseResponse>[]`) |\n\n`purchase` clamps `count` server-side to the range **1–100** (values ≤0 sent by\na caller are floored to 1 by the backend, but the SDK itself already rejects\n`count < 1` client-side as `reason: \"client\"`). `purchaseBatch` takes\n`StorePurchaseRef[]`: `{ OfferID, Count }[]` — deduped by `OfferID` (one entry\nper offer per call; use `Count` for multiple units), each `Count` clamped to\n1–100, and the list itself clamped to **50 refs per call** (entries past 50 are\nsilently dropped server-side — chunk larger sets yourself).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `purchase`/`purchaseBatch`\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\nsee the currency-system skill for the full `ResourceConsume`/`ResourceGrant`\nreference) to the cached currency/item balances, and bump the purchased\noffer's counters (`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`). Read\nupdated balances and counters straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { StoreDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst offer = defs?.StoreOffers?.[\"pack1\"];\noffer?.Cost; // ResourceConsume — what it costs\noffer?.Rewards; // ResourceGrant — what it grants\noffer?.Rules; // time window, Gate (SegmentGate), Limits (LimitSpec)\n\n// Purchase counters (only present after getUserState() or a purchase):\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\npurchases[\"pack1\"]?.TotalPurchases;\npurchases[\"pack1\"]?.DailyPurchases;\npurchases[\"pack1\"]?.DailyResetUtc; // ISO — next UTC-midnight reset\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"store:offerPurchased\", (r) => {\n console.log(`Bought ${r.Count}x ${r.OfferID}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show a storefront with purchase-limit UI\n\n```ts\nawait client.store.getDefinitions();\nawait client.store.getUserState();\n\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\n\nconst offersInMainStore = Object.values(defs?.StoreOffers ?? {}).filter((o) =>\n o.StoreIDs?.includes(\"main\"),\n);\n\nfor (const offer of offersInMainStore) {\n const counters = purchases[offer.OfferID];\n const limits = offer.Rules?.Limits;\n const totalLeft =\n limits?.TotalCap && limits.TotalCap > 0\n ? Math.max(0, limits.TotalCap - (counters?.TotalPurchases ?? 0))\n : null; // null = no lifetime cap\n const dailyLeft =\n limits?.DailyCap && limits.DailyCap > 0\n ? Math.max(0, limits.DailyCap - (counters?.DailyPurchases ?? 0))\n : null; // null = no daily cap\n // Disable the buy button when totalLeft === 0 or dailyLeft === 0.\n // Don't try to predict the daily reset instant yourself beyond display —\n // read counters.DailyResetUtc fresh after each purchase/getUserState().\n}\n```\n\n`Rules` (time window + `Gate` audience + `Limits` purchase caps) are enforced\nserver-side — use them client-side only to pre-filter/gray out what you\nalready know will be rejected, not as the source of truth.\n\n### Purchase an offer\n\n```ts\nconst res = await client.store.purchase(\"pack1\", 1);\nif (!res.ok) return showError(res.error); // e.g. \"Purchase limit reached...\", can't afford\n// cache now has updated balances + counters. UI re-renders from cache.\nres.data.Resources; // ResourceOperation actually applied (Consume + Grant)\n```\n\n### Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"pack1\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\neach element's `Success`/`Error` tells you whether that item applied. Offers\nrejected on their own merits (unknown id, outside window, gate failed, limit\nreached) are filtered out _before_ the merged charge is built and simply\nreport their own reason — they never affect other items in the batch. The\nremaining, valid offers are then charged as **one merged, all-or-nothing\ntransaction**: if the combined cost can't be paid, every one of those\nsurvivors comes back `Success: false` with an \"Atomic batch purchase failed\"\nerror, even though each was individually valid.\n\n### Cosmetic/bundle offer with only item rewards\n\nNothing offer-specific to do differently — `Rewards` is a `ResourceGrant` like\nany other, so an offer that only grants items (no currency) works through the\nsame `purchase()` call. Read the granted item instances back from\n`client.data.user.state?.InventoryV2` (see the item-system skill) after the\ncall, or from `res.data.Resources.Grant`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` call mints a fresh\n idempotency key (`store_buy_{offerID}_{userID}_{uuid}` client-side, further\n wrapped server-side), so two separate calls are two real operations — a\n double-clicked \"Buy\" can charge twice. Disable the control while a call is\n 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- **`Cost` and `Rewards` can be discounted/boosted server-side.** `Cost` is a\n `ResourceConsume` (may carry `PremiumDiscounts`) and `Rewards` is a\n `ResourceGrant` (may carry `PremiumTiers`) — the backend auto-applies the\n player's best subscription tier (see the premium-system skill). Don't assume\n the displayed base price/reward equals what's actually charged/granted; read\n the actual amounts off `res.data.Resources`.\n- **`count` scales cost and rewards linearly, then premium is applied once.**\n Buying `count=3` multiplies every `Cost`/`Rewards` entry (including event\n tokens) by 3 before premium discounts/bonuses are resolved — it is not 3\n independent purchases, so per-purchase minimums/rounding don't compound.\n- **Only one `Resources` apply per batch call, but every item's own data is\n still correct.** `purchaseBatch` applies the first successful item's\n `Resources` to the cache (the batch charge is merged server-side into one\n operation, so attaching it to every item would double-count balances); the\n per-item `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc` are still correct\n for each offer and drive the purchase-counter patch for every successful\n item, not just the first.\n- **`DailyPurchases` resets on UTC midnight, compared as ISO strings.** The SDK\n mirrors the server's reset logic locally when patching after a purchase\n (`state.DailyResetUtc` becomes the next UTC midnight after\n `ServerTimeUtc`) — you don't need to compute it, just read\n `DailyResetUtc`/`DailyPurchases` from the cache after the call.\n- **Purchase-history writes are best-effort and don't affect the result.** The\n backend appends an audit-log row after a successful purchase; if that write\n fails it's swallowed silently and never surfaces to the client — don't\n expect a Store endpoint to expose purchase history.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the purchase-limit/reset rule matrix, batch all-or-nothing semantics,\nand special-value conventions. Read it when building config-driven UI (cap\npreviews, cooldown countdowns) or when an error message points at a config\nrule you need to understand.\n",
4
+ "content": "---\nname: store-system\ndescription: >-\n Build a store / shop system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load storefront and offer\n (SKU) definitions, load the player's purchase counters, and purchase one or\n many offers (currency/item packs, bundles, cosmetics) with virtual/item\n cost. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a shop/store screen,\n IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise\n touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition,\n or offer purchasing — even if they don't name the module explicitly.\n---\n\n# Store system (iDosGames TS SDK)\n\nThe Store module lets a title sell **offers** (SKUs) — currency packs, item\nbundles, cosmetics, anything priced via `ResourceConsume` — grouped into one or\nmore **storefronts**. Everything is **server-authoritative**: the client asks\nthe backend to purchase, the backend validates cost, rules, and limits, and the\nSDK mirrors the confirmed result (resources + purchase counters) into a local\ncache your UI reads. You never mutate store state yourself — you call a\nmethod, check the result, and render from the cache.\n\nThis skill is for **using** the production `StoreService`, not for porting or\nextending it. If a purchase is rejected, that's the backend enforcing a rule\n(cost, time window, audience gate, purchase cap) — surface the error, don't try\nto reproduce the check client-side.\n\nStore's `Cost`/`Rewards` are virtual (`ResourceConsume`/`ResourceGrant`) —\ncurrency, items, event tokens, premium-tier grants. There is no real-money IAP\nreceipt flow inside this module; that lives entirely in the separate Purchase\nmodule (not covered here).\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n storefronts and offers: which offers live in which store, their `Cost` and\n `Rewards`, and their availability `Rules`. Fetched with `getDefinitions()`.\n2. **User store state** (state, per player) — this player's purchase counters\n per offer (`TotalPurchases`, `DailyPurchases`, reset time). Fetched with\n `getUserState()`.\n\nAn offer is identified by a string `OfferID`; a storefront by `StoreID`. An\noffer can be listed in multiple stores via `StoreIDs`, letting the same SKU\nappear in, say, both the main shop and a limited-time event shop. For the full\nfield-by-field shape of Definitions and state (purchase-limit reset math,\nbatch semantics, special-value rules), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\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 store = client.store; // the StoreService\n```\n\nEvery store 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, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Offer not found\nin the specified store.\", \"Offer is not yet available.\", \"Offer has expired.\",\n\"Offer is not available for you.\", \"Purchase limit reached for offer\n'<id>'. Max: <n>.\", \"Daily purchase limit reached for offer '<id>'. Max per\nday: <n>.\", or an `ApplyResourceOperationAtomicAsync` failure such as\ninsufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's store/offer catalog (config). | `StoreDefinitions` |\n| `getUserState()` | Load this player's purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. `options` = `{ selectedOptionID?, payment? }`. | `StorePurchaseResponse` (`Resources`) |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` (`BatchItemResult<StorePurchaseResponse>[]`) |\n\n`purchase` clamps `count` server-side to the range **1–100** (values ≤0 sent by\na caller are floored to 1 by the backend, but the SDK itself already rejects\n`count < 1` client-side as `reason: \"client\"`). `purchaseBatch` takes\n`StorePurchaseRef[]`: `{ OfferID, Count }[]` — deduped by `OfferID` (one entry\nper offer per call; use `Count` for multiple units), each `Count` clamped to\n1–100, and the list itself clamped to **50 refs per call** (entries past 50 are\nsilently dropped server-side — chunk larger sets yourself).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `purchase`/`purchaseBatch`\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\nsee the currency-system skill for the full `ResourceConsume`/`ResourceGrant`\nreference) to the cached currency/item balances, and bump the purchased\noffer's counters (`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`). Read\nupdated balances and counters straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { StoreDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst offer = defs?.StoreOffers?.[\"pack1\"];\noffer?.PriceOptions; // ways to pay; render with client.checkout.availableOptions(...)\noffer?.Rewards; // ResourceGrant — what it grants\noffer?.Rules; // time window, Gate (SegmentGate), Limits (LimitSpec)\n\n// Purchase counters (only present after getUserState() or a purchase):\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\npurchases[\"pack1\"]?.TotalPurchases;\npurchases[\"pack1\"]?.DailyPurchases;\npurchases[\"pack1\"]?.DailyResetUtc; // ISO — next UTC-midnight reset\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"store:offerPurchased\", (r) => {\n console.log(`Bought ${r.Count}x ${r.OfferID}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show a storefront with purchase-limit UI\n\n```ts\nawait client.store.getDefinitions();\nawait client.store.getUserState();\n\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\n\nconst offersInMainStore = Object.values(defs?.StoreOffers ?? {}).filter((o) =>\n o.StoreIDs?.includes(\"main\"),\n);\n\nfor (const offer of offersInMainStore) {\n const counters = purchases[offer.OfferID];\n const limits = offer.Rules?.Limits;\n const totalLeft =\n limits?.TotalCap && limits.TotalCap > 0\n ? Math.max(0, limits.TotalCap - (counters?.TotalPurchases ?? 0))\n : null; // null = no lifetime cap\n const dailyLeft =\n limits?.DailyCap && limits.DailyCap > 0\n ? Math.max(0, limits.DailyCap - (counters?.DailyPurchases ?? 0))\n : null; // null = no daily cap\n // Disable the buy button when totalLeft === 0 or dailyLeft === 0.\n // Don't try to predict the daily reset instant yourself beyond display —\n // read counters.DailyResetUtc fresh after each purchase/getUserState().\n}\n```\n\n`Rules` (time window + `Gate` audience + `Limits` purchase caps) are enforced\nserver-side — use them client-side only to pre-filter/gray out what you\nalready know will be rejected, not as the source of truth.\n\n### Purchase an offer\n\n```ts\nconst res = await client.store.purchase(\"pack1\", 1);\nif (!res.ok) return showError(res.error); // e.g. \"Purchase limit reached...\", can't afford\n// cache now has updated balances + counters. UI re-renders from cache.\nres.data.Resources; // ResourceOperation actually applied (Consume + Grant)\n```\n\nWhen the offer has several ways to pay, render them with\n`client.checkout.availableOptions(offer.PriceOptions)` and pass the chosen one.\nAn option paid in a store needs the receipt too:\n\n```ts\nawait client.store.purchase(\"pack1\", 1, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"pack1\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\neach element's `Success`/`Error` tells you whether that item applied. Offers\nrejected on their own merits (unknown id, outside window, gate failed, limit\nreached) are filtered out _before_ the merged charge is built and simply\nreport their own reason — they never affect other items in the batch. The\nremaining, valid offers are then charged as **one merged, all-or-nothing\ntransaction**: if the combined cost can't be paid, every one of those\nsurvivors comes back `Success: false` with an \"Atomic batch purchase failed\"\nerror, even though each was individually valid.\n\n### Cosmetic/bundle offer with only item rewards\n\nNothing offer-specific to do differently — `Rewards` is a `ResourceGrant` like\nany other, so an offer that only grants items (no currency) works through the\nsame `purchase()` call. Read the granted item instances back from\n`client.data.user.state?.InventoryV2` (see the item-system skill) after the\ncall, or from `res.data.Resources.Grant`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` call mints a fresh\n idempotency key (`store_buy_{offerID}_{userID}_{uuid}` client-side, further\n wrapped server-side), so two separate calls are two real operations — a\n double-clicked \"Buy\" can charge twice. Disable the control while a call is\n 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- **`Cost` and `Rewards` can be discounted/boosted server-side.** `Cost` is a\n `ResourceConsume` (may carry `PremiumDiscounts`) and `Rewards` is a\n `ResourceGrant` (may carry `PremiumTiers`) — the backend auto-applies the\n player's best subscription tier (see the premium-system skill). Don't assume\n the displayed base price/reward equals what's actually charged/granted; read\n the actual amounts off `res.data.Resources`.\n- **`count` scales cost and rewards linearly, then premium is applied once.**\n Buying `count=3` multiplies every `Cost`/`Rewards` entry (including event\n tokens) by 3 before premium discounts/bonuses are resolved — it is not 3\n independent purchases, so per-purchase minimums/rounding don't compound.\n- **Only one `Resources` apply per batch call, but every item's own data is\n still correct.** `purchaseBatch` applies the first successful item's\n `Resources` to the cache (the batch charge is merged server-side into one\n operation, so attaching it to every item would double-count balances); the\n per-item `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc` are still correct\n for each offer and drive the purchase-counter patch for every successful\n item, not just the first.\n- **`DailyPurchases` resets on UTC midnight, compared as ISO strings.** The SDK\n mirrors the server's reset logic locally when patching after a purchase\n (`state.DailyResetUtc` becomes the next UTC midnight after\n `ServerTimeUtc`) — you don't need to compute it, just read\n `DailyResetUtc`/`DailyPurchases` from the cache after the call.\n- **Purchase-history writes are best-effort and don't affect the result.** The\n backend appends an audit-log row after a successful purchase; if that write\n fails it's swallowed silently and never surfaces to the client — don't\n expect a Store endpoint to expose purchase history.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the purchase-limit/reset rule matrix, batch all-or-nothing semantics,\nand special-value conventions. Read it when building config-driven UI (cap\npreviews, cooldown countdowns) or when an error message points at a config\nrule you need to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Store data model — reference\n\nFull shape of the config (Definitions) and player state, the purchase-limit\nrule matrix, batch all-or-nothing semantics, and special-value conventions.\nAll of these are **strictly typed in the SDK** — `StoreDefinitions` and every\nnested block (`StoreDefinition`, `StoreRules`, `StoreOfferDefinition`,\n`StoreOfferRules`) are exported from `@idosgames/core`, so `getDefinitions()`\nand `getSection<StoreDefinitions>(\"Store\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: StoreDefinitions](#config-storedefinitions) — what `getDefinitions()` returns\n- [StoreDefinition (storefront)](#storedefinition-storefront)\n- [StoreOfferDefinition (SKU)](#storeofferdefinition-sku)\n- [Purchase-limit rule matrix](#purchase-limit-rule-matrix)\n- [Purchase flow, scaling, and idempotency](#purchase-flow-scaling-and-idempotency)\n- [Batch purchase semantics](#batch-purchase-semantics)\n- [Special values](#special-values)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Purchases?: Record<string, StorePurchaseState> }`\nand cached at `client.data.user.state?.Store?.Purchases`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/UserStoreState.cs`.\n\n```ts\ninterface StorePurchaseState {\n OfferID: string;\n TotalPurchases: number; // lifetime count, all-time\n DailyPurchases: number; // count since the last DailyResetUtc\n DailyResetUtc: string; // ISO — the next instant DailyPurchases resets to 0\n LastPurchasedAt: string; // ISO — server time of the last successful purchase\n}\n```\n\nThis is a **rate-limit counter store**, not a purchase-history log — it only\nholds what's needed to enforce `TotalCap`/`DailyCap` atomically. A separate\n`StorePurchaseHistoryDocument` audit-log collection exists server-side\n(`UserID`, `TitleID`, `OfferID`, `Count`, `Resources`, `PurchasedAt`) but it is\n**not exposed through any Store endpoint** — there is no \"purchase history\"\nclient call.\n\nA player with no purchase for a given `OfferID` simply has no entry in\n`Purchases` — treat a missing key as `TotalPurchases: 0`, `DailyPurchases: 0`,\nno active daily window.\n\n---\n\n## Config: StoreDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<StoreDefinitions>(\"Store\")`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreDefinitions {\n Stores?: Record<string, StoreDefinition> | null; // key = StoreID\n StoreOffers?: Record<string, StoreOfferDefinition> | null; // key = OfferID\n}\n```\n\nStorefronts and offers are deliberately separate catalogs: an offer references\nthe storefronts it appears in via `StoreIDs`, so the same SKU (price, rewards,\n`OfferID`, analytics) can be reused across the main shop, an event shop, a VIP\nshop, etc. without duplication.\n\n---\n\n## StoreDefinition (storefront)\n\nA logical shop screen (main / event / VIP). Does not embed offers.\n\n```ts\ninterface StoreDefinition {\n StoreID: string; // stable id; never rename after publication — offers reference it\n Type?: string; // segmentation/grouping tag, free-form\n Name?: string; // display name; optional for internal storefronts\n Description?: string;\n Rules?: StoreRules;\n AssetPaths?: Record<string, string>; // banner/icon/background, key = asset slug\n}\n\ninterface StoreRules {\n StartUtc?: string; // storefront opens at this UTC instant; absent = available from the start\n EndUtc?: string; // storefront closes at this UTC instant; absent = no expiration\n RequiredFlags?: string[]; // ALL must be set on the player for the storefront to show\n}\n```\n\n`StoreRules.RequiredFlags` is **not enforced by the `Store.Purchase` /\n`PurchaseBatch` endpoints** — the backend's purchase path (`Store.cs`) only\nvalidates the _offer's_ own `Rules` (window, `Gate`, `Limits`); it never looks\nup which storefront the purchase came through. Treat `StoreRules` purely as\nclient-side \"should I show this storefront\" filtering data, not as a\nserver-enforced purchase gate — the offer-level `Gate`/window/limits are the\nactual enforcement.\n\n---\n\n## StoreOfferDefinition (SKU)\n\nThe purchasable unit. Backend field-level docs from\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreOfferDefinition {\n OfferID: string; // stable id; used in analytics/purchase logs; never rename after publication\n StoreIDs?: string[]; // storefronts this offer appears in; empty/null = invisible everywhere\n Name?: string;\n Cost?: ResourceConsume; // debit-only; see currency-system skill for the shared shape\n Rewards?: ResourceGrant; // grant-only; see currency-system skill for the shared shape\n Rules?: StoreOfferRules;\n AssetPaths?: Record<string, string>;\n}\n\ninterface StoreOfferRules {\n StartUtc?: string; // offer becomes purchasable at this UTC instant; absent = from the start\n EndUtc?: string; // offer stops being purchasable at this UTC instant; absent = no expiration\n Gate?: SegmentGate; // \"who can buy this\" — premium tier/ID, segment, level, country, recency, experiment\n Limits?: LimitSpec; // purchase caps — see the matrix below\n}\n```\n\n`Gate` is the shared `SegmentGate` (Core/Segment) — all conditions AND-ed, an\nabsent/empty gate means available to everyone. Resolved server-side by\n`SegmentGateEvaluator.Passes` against the player's document at the moment of\npurchase (`Store.cs` line ~170: `\"Offer is not available for you.\"` on\nfailure).\n\n**Shape validation** (`StoreHelpers.ValidateOfferShape`, always run before a\npurchase is accepted): an offer with an empty `Cost` (no `Standard.Entries` and\nno `Standard.EventTokens`) fails with `\"Offer cost is empty.\"`; an offer with\nno `Rewards` at all (`Standard.Entries`, `Standard.EventTokens`, and\n`PremiumTiers` all empty) fails with `\"Offer rewards are empty.\"`. In other\nwords: **every real offer must both cost something and grant something** —\nthere is no free-claim or cost-only shape for Store offers (use the Reward or\nDealOffer module for pure-claim mechanics).\n\n---\n\n## Purchase-limit rule matrix\n\n`LimitSpec` (shared `Core/Limits` type; full field list in\n`packages/core/src/models/_shared/LimitModels.ts`) is reused across the SDK,\nbut Store's enforcement (`StoreHelpers.CheckPurchaseLimits` and\n`BuildPurchaseCounterPatches`, in\n`IDosGamesSDK/API/Client/v2/Store/Services/StoreHelpers.cs`) only reads two of\nits axes:\n\n| `LimitSpec` field | Meaning for Store | Enforcement |\n| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `TotalCap` | Lifetime purchase cap for the offer, summed over `Count` across all purchases | `TotalPurchases + count > TotalCap` → `\"Purchase limit reached for offer '<id>'. Max: <n>.\"` |\n| `DailyCap` | Per-UTC-day purchase cap | `DailyPurchases + count > DailyCap` (only while `now < DailyResetUtc`; otherwise treated as 0) → `\"Daily purchase limit reached for offer '<id>'. Max per day: <n>.\"` |\n\nOther `LimitSpec` axes (`DailyWeightCap`, `PerActivationCap`,\n`CooldownSeconds`, `MaxPerWindow`, `WindowSeconds`) exist on the shared type\nfor other modules but **Store does not read them** — configuring them on a\nStore offer's `Rules.Limits` has no effect on purchase behavior.\n\n**Daily reset timing.** `DailyResetUtc` is set to `now.Date.AddDays(1)` (the\nUTC midnight _after_ the purchase that (re)started the window) the first time\nan offer is bought, or whenever `now >= DailyResetUtc` on a subsequent\npurchase — i.e. the daily window is lazily rolled forward on the next\npurchase attempt, not on a schedule. If a player buys at 23:59 UTC and again\nat 00:01 UTC, the second purchase sees `now >= DailyResetUtc` from the first,\nresets `DailyPurchases` to the new `count`, and pushes `DailyResetUtc` to the\nfollowing midnight.\n\n**Race protection.** The fail-fast check in `CheckPurchaseLimits` runs before\nthe atomic write, but the real guarantee against concurrent double-spends past\nthe cap is an `extraFilter` attached to the same Mongo update\n(`BuildPurchaseCounterPatches`): the write only commits if\n`TotalPurchases <= TotalCap - count` (and the daily equivalent, tolerant of an\nexpired window) still holds at write time. If two concurrent requests would\nboth push a counter over its cap, only one commits — the loser's whole\n`ApplyResourceOperationAtomicAsync` call fails and the purchase is rejected,\nresources untouched.\n\n---\n\n## Purchase flow, scaling, and idempotency\n\nOrder of checks in `Store.StorePurchase` (`Store.cs`), all before any resource\nmutation:\n\n1. `OfferID` required; `count` clamped to **1–100**.\n2. Offer looked up by `OfferID` (optionally filtered by `storeID`, unused by\n the public `Purchase` action) — `\"Offer not found in the specified store.\"`\n if missing.\n3. Window check (`StartUtc`/`EndUtc`) — `\"Offer is not yet available.\"` /\n `\"Offer has expired.\"`.\n4. Shape check (`Cost` non-empty, `Rewards` non-empty) — see above.\n5. Player document read (single read, id/`InventoryV2`/`EventToken`/`Premium`/`Store` projection only).\n6. `Gate` check — `\"Offer is not available for you.\"`.\n7. Limit check (`CheckPurchaseLimits`) — see the matrix above.\n8. **Scaling**: `Cost` and `Rewards` are each scaled by `count` — every\n `ResourceEntry.Amount` and every `EventTokenOperation.Amount` is multiplied\n by `count` (a fresh object; the config definition itself is never mutated).\n `PremiumDiscounts`/`PremiumTiers` percentages are **not** scaled by count —\n only flat amounts are.\n9. The scaled `Cost`/`Rewards` become one `ResourceOperation { Grant, Consume }`\n applied via `ResourceService.ApplyResourceOperationAtomicAsync`, alongside\n the purchase-counter patches from step 7 and a `FeatureUsage` touch (see\n below), under one Mongo transaction with the `extraFilter` guard.\n10. On success, a best-effort audit-log row is appended\n (`StoreHelpers.AppendPurchaseHistoryAsync`) — failures here are swallowed\n and never affect the client response.\n\n**Idempotency.** The reason key is\n`\"StoreBuy:\" + ResourceService.ResolveRelatedEntityID(relatedEntityID, \"store_buy_{offerID}_{userID}\")`.\nThe SDK's `purchase()` always supplies a fresh, unique `RelatedEntityID`\n(`store_buy_{offerID}_{userID}_{uuid}`) per call — so from the client's\nperspective **every `purchase()` call is a brand-new charge**; the idempotency\nkey only protects against the transport layer's own internal retries within a\nsingle logical call, not against you calling `purchase()` twice.\n\n**`FeatureUsage` touch.** Every successful `Purchase` (regardless of `count`)\nincrements a `FeatureIDs.Store` usage touch exactly once — this is \"the player\nengaged the store,\" unrelated to and not a substitute for the per-offer\n`TotalPurchases`/`DailyPurchases` counters.\n\n---\n\n## Batch purchase semantics\n\n`PurchaseBatch` (`Store.PurchaseBatch` in `Store.cs`) trades N round-trips for\none, but keeps per-offer validation independent from the shared charge:\n\n**1. Normalization** — for each `StorePurchaseRef` in `args.Purchases`:\nblank/whitespace `OfferID` is dropped; `OfferID` is trimmed; duplicates by\n`OfferID` are dropped (first occurrence wins — **one offer per batch call**;\nuse `Count` for multiple units of the same offer, not repeated refs);\n`Count <= 0` is treated as `1`, then clamped to **1–100**; the list stops\ngrowing once it reaches `BatchSupport.MaxBatchSize` = **50** — refs beyond the\n50th are silently dropped and never appear in the result at all. An\nall-empty/invalid request (0 refs survive normalization) fails outright with\n`\"Purchases is required\"`.\n\n**2. One player read** for the whole batch (not per-offer).\n\n**3. Per-offer validation, outside the transaction** — for each surviving\n`(offerID, count)`, in order: offer exists → window → shape → `Gate` → purchase\nlimits (same checks and same error strings as the single-purchase path,\nkeyed per offer). Any failure here produces an immediate `BatchItemResult`\nwith `Success: false` and that specific `Error`, and **excludes the offer from\nthe merged charge** — it does not abort the batch.\n\nIf **zero** offers survive this stage, the call returns `Ok` with only the\nper-offer failure results (no atomic transaction is attempted).\n\n**4. Merge + one atomic charge** — for every surviving offer: `Cost`/`Rewards`\nare scaled by that offer's own `count`, then premium discounts/tiers are\nresolved and flattened per-offer (`ResourceService.FilterByPremium`) _before_\nmerging, so each offer's own premium tier is applied — the merge does not\ncreate a single blended discount. The flattened bundles from every surviving\noffer are summed into one `ResourceGrant`/`ResourceConsume`, together with the\npurchase-counter patches for every surviving offer and one shared\n`FeatureUsage` touch, and applied as a **single**\n`ApplyResourceOperationAtomicAsync` call with a combined `extraFilter`\n(AND of every offer's own race-protection filter).\n\n**All-or-nothing across survivors.** If the merged charge fails (e.g. can't\nafford the combined cost, or any one offer's `extraFilter` no longer holds),\n**every surviving offer** — even ones that were individually valid — comes\nback `Success: false` with `\"Atomic batch purchase failed: <reason>\"`. There is\nno partial application within the merged group; only the pre-filtered\nindividually-invalid offers were ever excluded.\n\n**5. Result shape and `Resources` placement.** The merged `ResourceOperation`\nreturned by the atomic call is attached to `Data.Resources` on **only the\nfirst successful item** in call order; every other successful item gets\n`Data.Resources = new ResourceOperation()` (empty, not null) — so summing\n`Resources` across all successful items double-counts nothing, but reading a\nnon-first item's `Resources` for balance info will show nothing. Read balances\nfrom the cache (which the SDK patches once per successful item's own\n`OfferID`/`Count`, so counters are correct for every item) rather than from\neach item's own `Resources`. `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc`\nare correct and independent for every successful item regardless of where\n`Resources` landed.\n\n**Reason key.** `BatchSupport.BuildBatchReason(\"StoreBuyBatch\", relatedEntityID, includedOfferIDs)`\n— one idempotency key covering the whole merged transaction, not one per\noffer.\n\n**Audit log.** On a successful merged charge, one best-effort history row is\nappended per surviving offer (same swallow-on-failure semantics as the single\npath).\n\n---\n\n## Special values\n\n- `Rules` absent entirely on a storefront or offer ⇒ no restriction on that\n axis (always visible / always purchasable / no gate / no limits).\n- `LimitSpec.TotalCap` / `DailyCap` `<= 0` (including absent, which the config\n default `LimitSpec` treats as `0`) ⇒ **unlimited** on that axis — the check\n is skipped entirely, not \"zero purchases allowed.\"\n- `StoreOfferDefinition.StoreIDs` empty or `null` ⇒ the offer exists in the\n catalog but is invisible in every storefront (it can still theoretically be\n purchased by `OfferID` directly, since `Purchase`'s `storeID` filter is\n unused by the public action — but there is no supported storefront UI path\n to reach it).\n- A player with no `Purchases[offerID]` entry is equivalent to\n `TotalPurchases: 0, DailyPurchases: 0`, with no active daily window (the\n `DailyExpired` check treats a missing state the same as an expired one).\n"
8
+ "content": "# Store data model — reference\n\nFull shape of the config (Definitions) and player state, the purchase-limit\nrule matrix, batch all-or-nothing semantics, and special-value conventions.\nAll of these are **strictly typed in the SDK** — `StoreDefinitions` and every\nnested block (`StoreDefinition`, `StoreRules`, `StoreOfferDefinition`,\n`StoreOfferRules`) are exported from `@idosgames/core`, so `getDefinitions()`\nand `getSection<StoreDefinitions>(\"Store\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: StoreDefinitions](#config-storedefinitions) — what `getDefinitions()` returns\n- [StoreDefinition (storefront)](#storedefinition-storefront)\n- [StoreOfferDefinition (SKU)](#storeofferdefinition-sku)\n- [Purchase-limit rule matrix](#purchase-limit-rule-matrix)\n- [Purchase flow, scaling, and idempotency](#purchase-flow-scaling-and-idempotency)\n- [Batch purchase semantics](#batch-purchase-semantics)\n- [Special values](#special-values)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Purchases?: Record<string, StorePurchaseState> }`\nand cached at `client.data.user.state?.Store?.Purchases`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/UserStoreState.cs`.\n\n```ts\ninterface StorePurchaseState {\n OfferID: string;\n TotalPurchases: number; // lifetime count, all-time\n DailyPurchases: number; // count since the last DailyResetUtc\n DailyResetUtc: string; // ISO — the next instant DailyPurchases resets to 0\n LastPurchasedAt: string; // ISO — server time of the last successful purchase\n}\n```\n\nThis is a **rate-limit counter store**, not a purchase-history log — it only\nholds what's needed to enforce `TotalCap`/`DailyCap` atomically. A separate\n`StorePurchaseHistoryDocument` audit-log collection exists server-side\n(`UserID`, `TitleID`, `OfferID`, `Count`, `Resources`, `PurchasedAt`) but it is\n**not exposed through any Store endpoint** — there is no \"purchase history\"\nclient call.\n\nA player with no purchase for a given `OfferID` simply has no entry in\n`Purchases` — treat a missing key as `TotalPurchases: 0`, `DailyPurchases: 0`,\nno active daily window.\n\n---\n\n## Config: StoreDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<StoreDefinitions>(\"Store\")`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreDefinitions {\n Stores?: Record<string, StoreDefinition> | null; // key = StoreID\n StoreOffers?: Record<string, StoreOfferDefinition> | null; // key = OfferID\n}\n```\n\nStorefronts and offers are deliberately separate catalogs: an offer references\nthe storefronts it appears in via `StoreIDs`, so the same SKU (price, rewards,\n`OfferID`, analytics) can be reused across the main shop, an event shop, a VIP\nshop, etc. without duplication.\n\n---\n\n## StoreDefinition (storefront)\n\nA logical shop screen (main / event / VIP). Does not embed offers.\n\n```ts\ninterface StoreDefinition {\n StoreID: string; // stable id; never rename after publication — offers reference it\n Type?: string; // segmentation/grouping tag, free-form\n Name?: string; // display name; optional for internal storefronts\n Description?: string;\n Rules?: StoreRules;\n AssetPaths?: Record<string, string>; // banner/icon/background, key = asset slug\n}\n\ninterface StoreRules {\n StartUtc?: string; // storefront opens at this UTC instant; absent = available from the start\n EndUtc?: string; // storefront closes at this UTC instant; absent = no expiration\n RequiredFlags?: string[]; // ALL must be set on the player for the storefront to show\n}\n```\n\n`StoreRules.RequiredFlags` is **not enforced by the `Store.Purchase` /\n`PurchaseBatch` endpoints** — the backend's purchase path (`Store.cs`) only\nvalidates the _offer's_ own `Rules` (window, `Gate`, `Limits`); it never looks\nup which storefront the purchase came through. Treat `StoreRules` purely as\nclient-side \"should I show this storefront\" filtering data, not as a\nserver-enforced purchase gate — the offer-level `Gate`/window/limits are the\nactual enforcement.\n\n---\n\n## StoreOfferDefinition (SKU)\n\nThe purchasable unit. Backend field-level docs from\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreOfferDefinition {\n OfferID: string; // stable id; used in analytics/purchase logs; never rename after publication\n StoreIDs?: string[]; // storefronts this offer appears in; empty/null = invisible everywhere\n Name?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; see the checkout-system skill\n Rewards?: ResourceGrant; // grant-only; see currency-system skill for the shared shape\n Rules?: StoreOfferRules;\n AssetPaths?: Record<string, string>;\n}\n\n\nEvery price in this module is a **`PriceOptions` dictionary** (the platform-wide\nshape, see the `checkout-system` skill): the key is the `OptionID`, one option is\none way to pay, and the entries inside an option's `Cost` are charged together.\n`purchase()` takes the chosen id as `options.selectedOptionID`; omit it and the\nserver takes the first option available on the caller's platform, so a\nsingle-price offer needs no client change. An option whose `Cost` holds a\n`Purchase` entry is paid **in a store** — buy the product and pass the receipt as\n`options.payment`.\n\ninterface StoreOfferRules {\n StartUtc?: string; // offer becomes purchasable at this UTC instant; absent = from the start\n EndUtc?: string; // offer stops being purchasable at this UTC instant; absent = no expiration\n Gate?: SegmentGate; // \"who can buy this\" — premium tier/ID, segment, level, country, recency, experiment\n Limits?: LimitSpec; // purchase caps — see the matrix below\n}\n```\n\n`Gate` is the shared `SegmentGate` (Core/Segment) — all conditions AND-ed, an\nabsent/empty gate means available to everyone. Resolved server-side by\n`SegmentGateEvaluator.Passes` against the player's document at the moment of\npurchase (`Store.cs` line ~170: `\"Offer is not available for you.\"` on\nfailure).\n\n**Shape validation** (`StoreHelpers.ValidateOfferShape`, always run before a\npurchase is accepted): an offer with an empty `Cost` (no `Standard.Entries` and\nno `Standard.EventTokens`) fails with `\"Offer cost is empty.\"`; an offer with\nno `Rewards` at all (`Standard.Entries`, `Standard.EventTokens`, and\n`PremiumTiers` all empty) fails with `\"Offer rewards are empty.\"`. In other\nwords: **every real offer must both cost something and grant something** —\nthere is no free-claim or cost-only shape for Store offers (use the Reward or\nDealOffer module for pure-claim mechanics).\n\n---\n\n## Purchase-limit rule matrix\n\n`LimitSpec` (shared `Core/Limits` type; full field list in\n`packages/core/src/models/_shared/LimitModels.ts`) is reused across the SDK,\nbut Store's enforcement (`StoreHelpers.CheckPurchaseLimits` and\n`BuildPurchaseCounterPatches`, in\n`IDosGamesSDK/API/Client/v2/Store/Services/StoreHelpers.cs`) only reads two of\nits axes:\n\n| `LimitSpec` field | Meaning for Store | Enforcement |\n| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `TotalCap` | Lifetime purchase cap for the offer, summed over `Count` across all purchases | `TotalPurchases + count > TotalCap` → `\"Purchase limit reached for offer '<id>'. Max: <n>.\"` |\n| `DailyCap` | Per-UTC-day purchase cap | `DailyPurchases + count > DailyCap` (only while `now < DailyResetUtc`; otherwise treated as 0) → `\"Daily purchase limit reached for offer '<id>'. Max per day: <n>.\"` |\n\nOther `LimitSpec` axes (`DailyWeightCap`, `PerActivationCap`,\n`CooldownSeconds`, `MaxPerWindow`, `WindowSeconds`) exist on the shared type\nfor other modules but **Store does not read them** — configuring them on a\nStore offer's `Rules.Limits` has no effect on purchase behavior.\n\n**Daily reset timing.** `DailyResetUtc` is set to `now.Date.AddDays(1)` (the\nUTC midnight _after_ the purchase that (re)started the window) the first time\nan offer is bought, or whenever `now >= DailyResetUtc` on a subsequent\npurchase — i.e. the daily window is lazily rolled forward on the next\npurchase attempt, not on a schedule. If a player buys at 23:59 UTC and again\nat 00:01 UTC, the second purchase sees `now >= DailyResetUtc` from the first,\nresets `DailyPurchases` to the new `count`, and pushes `DailyResetUtc` to the\nfollowing midnight.\n\n**Race protection.** The fail-fast check in `CheckPurchaseLimits` runs before\nthe atomic write, but the real guarantee against concurrent double-spends past\nthe cap is an `extraFilter` attached to the same Mongo update\n(`BuildPurchaseCounterPatches`): the write only commits if\n`TotalPurchases <= TotalCap - count` (and the daily equivalent, tolerant of an\nexpired window) still holds at write time. If two concurrent requests would\nboth push a counter over its cap, only one commits — the loser's whole\n`ApplyResourceOperationAtomicAsync` call fails and the purchase is rejected,\nresources untouched.\n\n---\n\n## Purchase flow, scaling, and idempotency\n\nOrder of checks in `Store.StorePurchase` (`Store.cs`), all before any resource\nmutation:\n\n1. `OfferID` required; `count` clamped to **1–100**.\n2. Offer looked up by `OfferID` (optionally filtered by `storeID`, unused by\n the public `Purchase` action) — `\"Offer not found in the specified store.\"`\n if missing.\n3. Window check (`StartUtc`/`EndUtc`) — `\"Offer is not yet available.\"` /\n `\"Offer has expired.\"`.\n4. Shape check (`Cost` non-empty, `Rewards` non-empty) — see above.\n5. Player document read (single read, id/`InventoryV2`/`EventToken`/`Premium`/`Store` projection only).\n6. `Gate` check — `\"Offer is not available for you.\"`.\n7. Limit check (`CheckPurchaseLimits`) — see the matrix above.\n8. **Scaling**: `Cost` and `Rewards` are each scaled by `count` — every\n `ResourceEntry.Amount` and every `EventTokenOperation.Amount` is multiplied\n by `count` (a fresh object; the config definition itself is never mutated).\n `PremiumDiscounts`/`PremiumTiers` percentages are **not** scaled by count —\n only flat amounts are.\n9. The scaled `Cost`/`Rewards` become one `ResourceOperation { Grant, Consume }`\n applied via `ResourceService.ApplyResourceOperationAtomicAsync`, alongside\n the purchase-counter patches from step 7 and a `FeatureUsage` touch (see\n below), under one Mongo transaction with the `extraFilter` guard.\n10. On success, a best-effort audit-log row is appended\n (`StoreHelpers.AppendPurchaseHistoryAsync`) — failures here are swallowed\n and never affect the client response.\n\n**Idempotency.** The reason key is\n`\"StoreBuy:\" + ResourceService.ResolveRelatedEntityID(relatedEntityID, \"store_buy_{offerID}_{userID}\")`.\nThe SDK's `purchase()` always supplies a fresh, unique `RelatedEntityID`\n(`store_buy_{offerID}_{userID}_{uuid}`) per call — so from the client's\nperspective **every `purchase()` call is a brand-new charge**; the idempotency\nkey only protects against the transport layer's own internal retries within a\nsingle logical call, not against you calling `purchase()` twice.\n\n**`FeatureUsage` touch.** Every successful `Purchase` (regardless of `count`)\nincrements a `FeatureIDs.Store` usage touch exactly once — this is \"the player\nengaged the store,\" unrelated to and not a substitute for the per-offer\n`TotalPurchases`/`DailyPurchases` counters.\n\n---\n\n## Batch purchase semantics\n\n`PurchaseBatch` (`Store.PurchaseBatch` in `Store.cs`) trades N round-trips for\none, but keeps per-offer validation independent from the shared charge:\n\n**1. Normalization** — for each `StorePurchaseRef` in `args.Purchases`:\nblank/whitespace `OfferID` is dropped; `OfferID` is trimmed; duplicates by\n`OfferID` are dropped (first occurrence wins — **one offer per batch call**;\nuse `Count` for multiple units of the same offer, not repeated refs);\n`Count <= 0` is treated as `1`, then clamped to **1–100**; the list stops\ngrowing once it reaches `BatchSupport.MaxBatchSize` = **50** — refs beyond the\n50th are silently dropped and never appear in the result at all. An\nall-empty/invalid request (0 refs survive normalization) fails outright with\n`\"Purchases is required\"`.\n\n**2. One player read** for the whole batch (not per-offer).\n\n**3. Per-offer validation, outside the transaction** — for each surviving\n`(offerID, count)`, in order: offer exists → window → shape → `Gate` → purchase\nlimits (same checks and same error strings as the single-purchase path,\nkeyed per offer). Any failure here produces an immediate `BatchItemResult`\nwith `Success: false` and that specific `Error`, and **excludes the offer from\nthe merged charge** — it does not abort the batch.\n\nIf **zero** offers survive this stage, the call returns `Ok` with only the\nper-offer failure results (no atomic transaction is attempted).\n\n**4. Merge + one atomic charge** — for every surviving offer: `Cost`/`Rewards`\nare scaled by that offer's own `count`, then premium discounts/tiers are\nresolved and flattened per-offer (`ResourceService.FilterByPremium`) _before_\nmerging, so each offer's own premium tier is applied — the merge does not\ncreate a single blended discount. The flattened bundles from every surviving\noffer are summed into one `ResourceGrant`/`ResourceConsume`, together with the\npurchase-counter patches for every surviving offer and one shared\n`FeatureUsage` touch, and applied as a **single**\n`ApplyResourceOperationAtomicAsync` call with a combined `extraFilter`\n(AND of every offer's own race-protection filter).\n\n**All-or-nothing across survivors.** If the merged charge fails (e.g. can't\nafford the combined cost, or any one offer's `extraFilter` no longer holds),\n**every surviving offer** — even ones that were individually valid — comes\nback `Success: false` with `\"Atomic batch purchase failed: <reason>\"`. There is\nno partial application within the merged group; only the pre-filtered\nindividually-invalid offers were ever excluded.\n\n**5. Result shape and `Resources` placement.** The merged `ResourceOperation`\nreturned by the atomic call is attached to `Data.Resources` on **only the\nfirst successful item** in call order; every other successful item gets\n`Data.Resources = new ResourceOperation()` (empty, not null) — so summing\n`Resources` across all successful items double-counts nothing, but reading a\nnon-first item's `Resources` for balance info will show nothing. Read balances\nfrom the cache (which the SDK patches once per successful item's own\n`OfferID`/`Count`, so counters are correct for every item) rather than from\neach item's own `Resources`. `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc`\nare correct and independent for every successful item regardless of where\n`Resources` landed.\n\n**Reason key.** `BatchSupport.BuildBatchReason(\"StoreBuyBatch\", relatedEntityID, includedOfferIDs)`\n— one idempotency key covering the whole merged transaction, not one per\noffer.\n\n**Audit log.** On a successful merged charge, one best-effort history row is\nappended per surviving offer (same swallow-on-failure semantics as the single\npath).\n\n---\n\n## Special values\n\n- `Rules` absent entirely on a storefront or offer ⇒ no restriction on that\n axis (always visible / always purchasable / no gate / no limits).\n- `LimitSpec.TotalCap` / `DailyCap` `<= 0` (including absent, which the config\n default `LimitSpec` treats as `0`) ⇒ **unlimited** on that axis — the check\n is skipped entirely, not \"zero purchases allowed.\"\n- `StoreOfferDefinition.StoreIDs` empty or `null` ⇒ the offer exists in the\n catalog but is invisible in every storefront (it can still theoretically be\n purchased by `OfferID` directly, since `Purchase`'s `storeID` filter is\n unused by the public action — but there is no supported storefront UI path\n to reach it).\n- A player with no `Purchases[offerID]` entry is equivalent to\n `TotalPurchases: 0, DailyPurchases: 0`, with no active daily window (the\n `DailyExpired` check treats a missing state the same as an expired one).\n"
9
9
  }
10
10
  ]
11
11
  }