@idosgames/mcp 0.1.17 → 0.1.18

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.
Files changed (32) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +14 -6
  4. package/registry/index.json +731 -33
  5. package/registry/modules/board-game.json +4 -4
  6. package/registry/modules/character-ui.json +78 -0
  7. package/registry/modules/chat-ui.json +74 -0
  8. package/registry/modules/craft-ui.json +74 -0
  9. package/registry/modules/deal-offer-ui.json +73 -0
  10. package/registry/modules/events-ui.json +80 -0
  11. package/registry/modules/friends-ui.json +73 -0
  12. package/registry/modules/game-hud.json +15 -10
  13. package/registry/modules/idle-rpg.json +4 -4
  14. package/registry/modules/inventory-ui.json +73 -0
  15. package/registry/modules/leaderboard-ui.json +75 -0
  16. package/registry/modules/marketplace-ui.json +75 -0
  17. package/registry/modules/profile-ui.json +74 -0
  18. package/registry/modules/pvp-ui.json +72 -0
  19. package/registry/modules/referral-ui.json +73 -0
  20. package/registry/modules/rewards-ui.json +75 -0
  21. package/registry/modules/season-ui.json +79 -0
  22. package/registry/modules/showcase.json +92 -0
  23. package/registry/modules/store-ui.json +75 -0
  24. package/registry/modules/tutorial-ui.json +71 -0
  25. package/registry/modules/voxelcraft.json +75 -30
  26. package/registry/modules/workshop.json +3 -3
  27. package/registry/skills/idosgames-module-contract.json +1 -1
  28. package/registry/skills/idosgames-ui-kit.json +6 -0
  29. package/registry/skills/leaderboard-system.json +1 -1
  30. package/registry/skills/multiplayer-realtime.json +3 -3
  31. package/registry/skills/referral-system.json +2 -2
  32. package/registry/skills/timed-event-system.json +1 -1
@@ -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 (their own SHORT invite code, a ready-made invite link, who they're subscribed to, follower count, claimed invite rewards), activate someone else's 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. Also use it for \"share my invite link\", \"enter a friend's code\" and invite-code screens.",
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 (their own SHORT invite code, a ready-made invite link, who they're\n subscribed to, follower count, claimed invite rewards), activate someone\n else's 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. Also use\n it for \"share my invite link\", \"enter a friend's code\" and invite-code\n screens.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player gets\na **short human code** (`WDJBMJHT`) and a ready-made **invite link**, a new\nplayer **activates** someone else's code once, the referrer's `FollowersCount`\ngoes up, and the referrer can later **claim** staged rewards as that count\ncrosses configured thresholds.\n\n> ⚠ **A code is NOT a `UserID`.** Older docs and older UI said it was, and it\n> used to be true. It is not any more: the code is 8 characters from a\n> confusable-free alphabet, minted per player per title, and a `UserID` is not\n> accepted in the code field at all. Never render a `UserID` as \"your code\".\n\nMost bindings never touch this module's UI: the SDK captures `?ref=`, an\ninvite link, a Telegram `start_param` and ad tags at launch and ships them with\nthe login, so the server binds the player before any screen is shown. Manual\nentry is the fallback for people who arrived without a link. That capture side\n— and the attribution it feeds — is the `acquisition-attribution` skill; this\none is only about the referral loop itself. 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) — this player's own `Code`, who\n they activated (`SubscribedToUserID`), whether their activation reward was\n granted, whether one is still `PendingActivationReward`, their own\n `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`, which also returns\n `InviteUrl` alongside it (on the response, not inside `Referral`).\n\n⚠ **`getUserState()` is not a pure read, and two things depend on that.** The\nserver mints the player's `Code` LAZILY on that call — most players never\ninvite anyone, so nobody gets a code until they open the screen — and it\nsettles `PendingActivationReward`, paying the activation reward to a player who\nwas bound at login rather than by typing a code. So: call it when you open the\ninvite screen, and do not serve that screen from a stale cache.\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\nBecause the key is deterministic, a **retry of the same operation is a replay,\nnot a second grant**: `ResourceService` recognises the reason, returns the\nstored result and grants nothing again. Still disable the button while a call\nis in flight — that's about the player seeing what happened, not about\ndouble-paying.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to `data.ReferrerUserID` — the referrer's **id**, which\n the response carries separately from the code that was typed — clears\n `PendingActivationReward`, and applies `data.Resources` (the\n `ActivationReward`, only present when `IsFirstActivation` is true) to cached\n 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- **The code must be well-formed before it is even looked up.** Eight\n characters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, no `0/O`, no `1/I/L`),\n separators ignored. Anything else — including a `UserID` — fails with\n `\"Referral code is invalid\"` without a database round-trip.\n- **An unknown but well-formed code** fails the same way.\n- **Self-referral is rejected** with `\"Cannot activate your own referral code\"`\n — the server resolves the code to a player first and compares ids, so a\n player pasting their own link gets this rather than a generic error.\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` is decremented, the new one incremented, and\n `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?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n// ⚠ There is no FollowerIDs — only the count. Activating a code also makes the\n// two players friends, so the identities are `client.social.getFriendsList()`.\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### Show the player's own code and invite link\n\n```ts\nconst res = await client.referral.getUserState();\nif (!res.ok) return showLoadFailed(res.error); // NOT the same as \"no code yet\"\n\nconst code = res.data.Referral?.Code; // e.g. \"WDJBMJHT\"\nconst link = res.data.InviteUrl; // e.g. \"https://idosgames.com/go/MYTITLE?ref=WDJBMJHT\"\n\n// Group it for reading aloud; the server strips separators on the way back in.\nconst pretty =\n code?.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n```\n\nTake the link **as given**. Do not assemble one from a template: the server\nbuilds it precisely so a client cannot turn it into an open redirect, and so\nevery client words it identically. No `Code` means the title has no referral\nconfig at all — hide the sharing UI rather than showing an empty box.\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(pastedByPlayer);\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 (the referrer's id) + the granted ActivationReward.\n```\n\nPass through **whatever the player pasted**: the SDK accepts a bare code, a\ncode with separators, and the whole invite link, in any case. People forward\nthe link they were sent far more often than they retype eight characters, and\nhandling that in each title's UI separately is how the two drift apart.\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\n`SpendRewards` describes the _rules_ (rate, source/target currency, per\nfeature) and the backend applies them automatically: after a follower spends\nin Store, Marketplace, Lootbox, Craft, Item, Character, TimedBoost, Premium,\nCurrency or Reward, the referrer is credited.\n\nThere is still nothing to call from the referrer's side, and no `referral:*`\nevent fires for them: the payout lands in **another player's** document, in a\nrequest that player made. Read `defs?.SpendRewards` to show \"earn N% back when\nyour friends spend\", and show the balance itself from the referrer's own\nstate on their next request.\n\n## Gotchas\n\n- **`SpendRewards` is wired and pays.** (This entry used to claim the\n opposite; it was wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called\n from 12 modules after a successful deduction. What is still true: the\n referrer gets no event and no cache update from it, because the payout\n happens inside someone else's request. Their balance shows up on their next\n own request.\n- **The rate field is `Rate`, a FRACTION.** `0.05` means 5%. It is not\n `Percent`, and it is not `5`. A rule with no `Rate` is skipped silently — it\n neither pays nor errors.\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- **The code is a short human code (`WDJB-MJHT`), not a `UserID`.** The player\n reads it off a screen or gets it inside an invite link;\n `client.referral.activateReferralCode` accepts either the bare code or the\n whole link pasted. An internal identifier is not accepted at all.\n- **A code belongs to a TITLE, not to the platform.** It is stored under\n `{titleID}:{code}` and is unique only inside that title, which is why the\n invite link always carries the title (`/go/{titleID}?ref=...`) and why a\n link shaped like `/i/{code}` cannot exist.\n- **Most players never type it.** `AcquisitionCapture` (in `@idosgames/core`,\n wired automatically) reads `?ref=`, `idos_click`, Telegram `start_param` and\n ad tags at launch, keeps them across the login screen, and ships them with\n whichever sign-in the player uses. Manual entry is the fallback for people\n who arrived without a link. You do not call it and must not duplicate it.\n- **A player bound at login has an unpaid reward until the screen is opened.**\n That is what `PendingActivationReward` means: the login path has no per-user\n lock, so the server flags the debt and settles it on the next\n `getUserState()`. If your game never opens an invite screen, that reward is\n never paid — put the call somewhere the player reaches.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard; the rejection (`\"Cannot activate your own referral code\"`) only comes\n back after the 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- **A double-clicked \"Activate\"/\"Claim\" does NOT pay twice.** The idempotency\n key is deterministic, so the repeat is a replay and grants nothing again.\n (This entry used to claim the opposite.) Disable the control while a call is\n in flight anyway — so the player can tell what happened.\n- **Input is normalised for you** — `activateReferralCode` pulls the code out\n of a pasted link, strips separators, trims and uppercases before sending. Do\n not pre-clean it yourself; a second implementation of the same rule is how\n \"this code is invalid\" starts happening to valid codes.\n- **`SubscribedToUserID` holds an ID, `Code` holds a code.** They are different\n fields with different shapes, and the activation response carries both\n (`ReferrerUserID` and `ReferralCode`). Rendering one where the other belongs\n is the single easiest mistake here.\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 (their own SHORT invite code, a ready-made invite link, who they're\n subscribed to, follower count, claimed invite rewards), activate someone\n else's 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. Also use\n it for \"share my invite link\", \"enter a friend's code\" and invite-code\n screens.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player gets\na **short human code** (`WDJBMJHT`) and a ready-made **invite link**, a new\nplayer **activates** someone else's code once, the referrer's `FollowersCount`\ngoes up, and the referrer can later **claim** staged rewards as that count\ncrosses configured thresholds.\n\n> ⚠ **A code is NOT a `UserID`.** Older docs and older UI said it was, and it\n> used to be true. It is not any more: the code is 8 characters from a\n> confusable-free alphabet, minted per player per title, and a `UserID` is not\n> accepted in the code field at all. Never render a `UserID` as \"your code\".\n\nMost bindings never touch this module's UI: the SDK captures `?ref=`, an\ninvite link, a Telegram `start_param` and ad tags at launch and ships them with\nthe login, so the server binds the player before any screen is shown. Manual\nentry is the fallback for people who arrived without a link. That capture side\n— and the attribution it feeds — is the `acquisition-attribution` skill; this\none is only about the referral loop itself. 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) — this player's own `Code`, who\n they activated (`SubscribedToUserID`), whether their activation reward was\n granted, whether one is still `PendingActivationReward`, their own\n `FollowersCount`, and which `InviteRewards` they've claimed\n (`InviteRewardStates`). Fetched with `getUserState()`, which also returns\n `InviteUrl` alongside it (on the response, not inside `Referral`).\n\n⚠ **`getUserState()` is not a pure read, and two things depend on that.** The\nserver mints the player's `Code` LAZILY on that call — most players never\ninvite anyone, so nobody gets a code until they open the screen — and it\nsettles `PendingActivationReward`, paying the activation reward to a player who\nwas bound at login rather than by typing a code. So: call it when you open the\ninvite screen, and do not serve that screen from a stale cache.\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\nBecause the key is deterministic, a **retry of the same operation is a replay,\nnot a second grant**: `ResourceService` recognises the reason, returns the\nstored result and grants nothing again. Still disable the button while a call\nis in flight — that's about the player seeing what happened, not about\ndouble-paying.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to `data.ReferrerUserID` — the referrer's **id**, which\n the response carries separately from the code that was typed — clears\n `PendingActivationReward`, and applies `data.Resources` (the\n `ActivationReward`, only present when `IsFirstActivation` is true) to cached\n 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- **The code must be well-formed before it is even looked up.** Eight\n characters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, no `0/O`, no `1/I/L`),\n separators ignored. Anything else — including a `UserID` — fails with\n `\"Referral code is invalid\"` without a database round-trip.\n- **An unknown but well-formed code** fails the same way.\n- **Self-referral is rejected** with `\"Cannot activate your own referral code\"`\n — the server resolves the code to a player first and compares ids, so a\n player pasting their own link gets this rather than a generic error.\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` is decremented, the new one incremented, and\n `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?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n// ⚠ There is no FollowerIDs — only the count. Activating a code also makes the\n// two players friends, so the identities are `client.social.getFriendsList()`.\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### Show the player's own code and invite link\n\n```ts\nconst res = await client.referral.getUserState();\nif (!res.ok) return showLoadFailed(res.error); // NOT the same as \"no code yet\"\n\nconst code = res.data.Referral?.Code; // e.g. \"WDJBMJHT\"\nconst link = res.data.InviteUrl; // e.g. \"https://idosgames.com/go/MYTITLE/?ref=WDJBMJHT\"\n// Web-only title (no Google Play / App Store / Telegram link): \".../app/MYTITLE/?ref=WDJBMJHT\"\n\n// Group it for reading aloud; the server strips separators on the way back in.\nconst pretty =\n code?.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n```\n\nTake the link **as given**. Do not assemble one from a template: the server\nbuilds it precisely so a client cannot turn it into an open redirect, and so\nevery client words it identically. No `Code` means the title has no referral\nconfig at all — hide the sharing UI rather than showing an empty box.\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(pastedByPlayer);\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 (the referrer's id) + the granted ActivationReward.\n```\n\nPass through **whatever the player pasted**: the SDK accepts a bare code, a\ncode with separators, and the whole invite link, in any case. People forward\nthe link they were sent far more often than they retype eight characters, and\nhandling that in each title's UI separately is how the two drift apart.\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\n`SpendRewards` describes the _rules_ (rate, source/target currency, per\nfeature) and the backend applies them automatically: after a follower spends\nin Store, Marketplace, Lootbox, Craft, Item, Character, TimedBoost, Premium,\nCurrency or Reward, the referrer is credited.\n\nThere is still nothing to call from the referrer's side, and no `referral:*`\nevent fires for them: the payout lands in **another player's** document, in a\nrequest that player made. Read `defs?.SpendRewards` to show \"earn N% back when\nyour friends spend\", and show the balance itself from the referrer's own\nstate on their next request.\n\n## Gotchas\n\n- **`SpendRewards` is wired and pays.** (This entry used to claim the\n opposite; it was wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called\n from 12 modules after a successful deduction. What is still true: the\n referrer gets no event and no cache update from it, because the payout\n happens inside someone else's request. Their balance shows up on their next\n own request.\n- **The rate field is `Rate`, a FRACTION.** `0.05` means 5%. It is not\n `Percent`, and it is not `5`. A rule with no `Rate` is skipped silently — it\n neither pays nor errors.\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- **The code is a short human code (`WDJB-MJHT`), not a `UserID`.** The player\n reads it off a screen or gets it inside an invite link;\n `client.referral.activateReferralCode` accepts either the bare code or the\n whole link pasted. An internal identifier is not accepted at all.\n- **A code belongs to a TITLE, not to the platform.** It is stored under\n `{titleID}:{code}` and is unique only inside that title, which is why the\n invite link always carries the title (`/go/{titleID}?ref=...`, or\n `/app/{titleID}?ref=...` for a web-only title) and why a\n link shaped like `/i/{code}` cannot exist.\n- **Most players never type it.** `AcquisitionCapture` (in `@idosgames/core`,\n wired automatically) reads `?ref=`, `idos_click`, Telegram `start_param` and\n ad tags at launch, keeps them across the login screen, and ships them with\n whichever sign-in the player uses. Manual entry is the fallback for people\n who arrived without a link. You do not call it and must not duplicate it.\n- **A player bound at login has an unpaid reward until the screen is opened.**\n That is what `PendingActivationReward` means: the login path has no per-user\n lock, so the server flags the debt and settles it on the next\n `getUserState()`. If your game never opens an invite screen, that reward is\n never paid — put the call somewhere the player reaches.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard; the rejection (`\"Cannot activate your own referral code\"`) only comes\n back after the 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- **A double-clicked \"Activate\"/\"Claim\" does NOT pay twice.** The idempotency\n key is deterministic, so the repeat is a replay and grants nothing again.\n (This entry used to claim the opposite.) Disable the control while a call is\n in flight anyway — so the player can tell what happened.\n- **Input is normalised for you** — `activateReferralCode` pulls the code out\n of a pasted link, strips separators, trims and uppercases before sending. Do\n not pre-clean it yourself; a second implementation of the same rule is how\n \"this code is invalid\" starts happening to valid codes.\n- **`SubscribedToUserID` holds an ID, `Code` holds a code.** They are different\n fields with different shapes, and the activation response carries both\n (`ReferrerUserID` and `ReferralCode`). Rendering one where the other belongs\n is the single easiest mistake here.\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\", \"Lootbox\", \"Reward\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Rate?: number; // FRACTION: 0.05 = 5%. Never 0..100. Empty or 0 = the rule pays nothing.\n Basis?: string; // \"Spend\" (default) or \"Earn\" — what the share is taken from\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n MinSourceAmount?: number; // ignore operations below this. 0 = no floor\n MaxRewardPerOperation?: number; // cap the payout from ONE operation. 0 = no cap\n Limits?: LimitSpec; // anti-farm caps for the REFERRER: DailyCap, DailyWeightCap\n}\n```\n\n⚠ **`FeatureKey` is not free text.** The accepted values are listed in\n`ReferralV2.SpendFeatureKeys`; an unknown key never matches, so the rule stays\nsilent rather than erroring. `\"Gacha\"` is **not** one of them — the lootbox key\nis `\"Lootbox\"`.\n\n⚠ **The field is `Rate` and it is a fraction.** It used to be `Percent` holding\n0..100. A dashboard that wrote the old shape had it dropped at deserialisation\nand every rule it created silently paid nobody — no error anywhere. If you\nrender a percentage, convert at the edge and keep the wire value a fraction.\n\n**This is wired and it pays.** (This section used to claim the opposite; that\nwas wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called from 12 modules\nafter a successful deduction. What remains true is the client-side\nconsequence: the referrer gets **no event and no cache update**, because the\npayout happens inside a request made by _another_ player. Their balance simply\nreflects it on their next own request. So describe the rule in UI (\"earn N%\nback when friends spend\"), but do not build a claim or notification flow — the\ngrant is automatic and 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 Code?: string | null; // THIS player's own short code, e.g. \"WDJBMJHT\". Minted lazily on the first getUserState()\n PendingActivationReward?: boolean; // bound at login, reward not paid yet — settled by the next getUserState()\n SubscribedToUserID?: string | null; // the referrer's USER ID (not their code); 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\n // ⚠ FollowerIDs was REMOVED: an unbounded list of UserIDs inside the player document that\n // nothing read. Only the count remains. Activating a code also makes the two players friends,\n // so the identities are reachable as a friends list (client.social.getFriendsList()).\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\n### The code and the invite link\n\nA player's code is a **short generated string**, not their `UserID`: 8\ncharacters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, so no accidental words; no\n`0/O`, no `1/I/L`, so nothing is misread when dictated). It lives in\n`UserReferralState.Code`.\n\n- **Minted lazily**, on the first `getUserState()`. Most players never invite\n anyone, and giving every registration a row in the code collection would be\n a write per signup for a field nobody reads.\n- **Scoped to the TITLE**, stored as `{titleID}:{code}`. Two titles may hand\n out the same code to different people, which is why every invite link\n carries the title.\n- The ready-made link comes back **on the response**, as\n `UserReferralStateResponse.InviteUrl` — `idosgames.com/go/{titleID}?ref={code}`.\n The server builds it; a client-built link from a template would be an open\n redirect. It is absent whenever `Code` is.\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`): `spec.Curve` is the shared\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\n point the curve is the **identity**, so a player who has not reached the first\n tier gets no bonus.\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\n real zero. `NaN`/`Infinity` collapses to `1.0`.\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\n the resolver: a reward multiplier never reduces a reward unless the publisher\n says so explicitly.\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. **Shape checked before any lookup**: 8 characters from\n `BCDFGHJKMNPQRSTVWXZ`, separators ignored, case-insensitive. Anything else\n — a `UserID` included — is `\"Referral code is invalid\"` without touching\n the database.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The normalised code must resolve to a real player in THIS title, else\n `\"Referral code is invalid\"`. Resolving to the caller themselves is\n `\"Cannot activate your own referral code\"` — the comparison is on ids,\n after the lookup, so pasting your own link gives the specific message\n rather than a generic one.\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 (`FollowerIDs` no longer exists — only the count is kept).\n - The new referrer's `FollowersCount` is atomically incremented.\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\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\", \"Lootbox\", \"Reward\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Rate?: number; // FRACTION: 0.05 = 5%. Never 0..100. Empty or 0 = the rule pays nothing.\n Basis?: string; // \"Spend\" (default) or \"Earn\" — what the share is taken from\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n MinSourceAmount?: number; // ignore operations below this. 0 = no floor\n MaxRewardPerOperation?: number; // cap the payout from ONE operation. 0 = no cap\n Limits?: LimitSpec; // anti-farm caps for the REFERRER: DailyCap, DailyWeightCap\n}\n```\n\n⚠ **`FeatureKey` is not free text.** The accepted values are listed in\n`ReferralV2.SpendFeatureKeys`; an unknown key never matches, so the rule stays\nsilent rather than erroring. `\"Gacha\"` is **not** one of them — the lootbox key\nis `\"Lootbox\"`.\n\n⚠ **The field is `Rate` and it is a fraction.** It used to be `Percent` holding\n0..100. A dashboard that wrote the old shape had it dropped at deserialisation\nand every rule it created silently paid nobody — no error anywhere. If you\nrender a percentage, convert at the edge and keep the wire value a fraction.\n\n**This is wired and it pays.** (This section used to claim the opposite; that\nwas wrong.) `ReferralV2.TryProcessSpendRewardAsync` is called from 12 modules\nafter a successful deduction. What remains true is the client-side\nconsequence: the referrer gets **no event and no cache update**, because the\npayout happens inside a request made by _another_ player. Their balance simply\nreflects it on their next own request. So describe the rule in UI (\"earn N%\nback when friends spend\"), but do not build a claim or notification flow — the\ngrant is automatic and 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 Code?: string | null; // THIS player's own short code, e.g. \"WDJBMJHT\". Minted lazily on the first getUserState()\n PendingActivationReward?: boolean; // bound at login, reward not paid yet — settled by the next getUserState()\n SubscribedToUserID?: string | null; // the referrer's USER ID (not their code); 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\n // ⚠ FollowerIDs was REMOVED: an unbounded list of UserIDs inside the player document that\n // nothing read. Only the count remains. Activating a code also makes the two players friends,\n // so the identities are reachable as a friends list (client.social.getFriendsList()).\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\n### The code and the invite link\n\nA player's code is a **short generated string**, not their `UserID`: 8\ncharacters from `BCDFGHJKMNPQRSTVWXZ` (no vowels, so no accidental words; no\n`0/O`, no `1/I/L`, so nothing is misread when dictated). It lives in\n`UserReferralState.Code`.\n\n- **Minted lazily**, on the first `getUserState()`. Most players never invite\n anyone, and giving every registration a row in the code collection would be\n a write per signup for a field nobody reads.\n- **Scoped to the TITLE**, stored as `{titleID}:{code}`. Two titles may hand\n out the same code to different people, which is why every invite link\n carries the title.\n- The ready-made link comes back **on the response**, as\n `UserReferralStateResponse.InviteUrl` — `idosgames.com/go/{titleID}/?ref={code}` when the\n title has a Google Play, App Store or Telegram link (`Attribution.StoreLinks`), otherwise the\n web app page `idosgames.com/app/{titleID}/?ref={code}` (no one-button \"pick a platform\" step).\n The server builds it; a client-built link from a template would be an open\n redirect. It is absent whenever `Code` is.\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`): `spec.Curve` is the shared\n `ScalarCurveSpec`, evaluated from a base of `1.0` at `step = progress` with\n `firstStep = spec.Anchor ?? 0`. Tiered breakpoints are `Shape: \"Table\"`\n (`Points: [{ AtStep, Value }]`, `Interpolation` picks step/linear/geometric\n between them); a linear ramp is `Shape: \"PerStepRate\"`. Below the first table\n point the curve is the **identity**, so a player who has not reached the first\n tier gets no bonus.\n - Bounds are `Curve.MinResult` / `Curve.MaxResult`, and **an empty bound means\n no bound** — unlike the old `MaxMultiplier <= 0` convention, `0` now means a\n real zero. `NaN`/`Infinity` collapses to `1.0`.\n - ⚠ With no `MinResult` set, the result is floored at `1.0` by a domain rule of\n the resolver: a reward multiplier never reduces a reward unless the publisher\n says so explicitly.\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. **Shape checked before any lookup**: 8 characters from\n `BCDFGHJKMNPQRSTVWXZ`, separators ignored, case-insensitive. Anything else\n — a `UserID` included — is `\"Referral code is invalid\"` without touching\n the database.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The normalised code must resolve to a real player in THIS title, else\n `\"Referral code is invalid\"`. Resolving to the caller themselves is\n `\"Cannot activate your own referral code\"` — the comparison is on ids,\n after the lookup, so pasting your own link gives the specific message\n rather than a generic one.\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 (`FollowerIDs` no longer exists — only the count is kept).\n - The new referrer's `FollowersCount` is atomically incremented.\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"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "timed-event-system",
3
3
  "description": "Build a time-boxed live-ops event system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService): load active events and their title-wide definitions, read the player's per-event-instance token progress, spend event tokens (single + batch), grant event tokens (single + batch, server/trigger-driven), and claim milestone rewards as token balances cross thresholds (single, batch, and claim-all). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants limited-time events, seasonal/live-ops event screens, event-token or event-currency progress bars, milestone/reward-track UIs, chain/rotation events, Coin-Master-style bonus windows, or otherwise touches client.timedEvent, TimedEventService, TimedEventDefinitions, ActiveEventInfo, EventTokenProgress, or MilestoneDefinition — even if they don't name the module explicitly.",
4
- "content": "---\nname: timed-event-system\ndescription: >-\n Build a time-boxed live-ops event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService):\n load active events and their title-wide definitions, read the player's\n per-event-instance token progress, spend event tokens (single + batch),\n grant event tokens (single + batch, server/trigger-driven), and claim\n milestone rewards as token balances cross thresholds (single, batch, and\n claim-all). Use this whenever the user is working in the iDosGames TS SDK\n or its game templates (board-game, idle-rpg) and wants limited-time\n events, seasonal/live-ops event screens, event-token or event-currency\n progress bars, milestone/reward-track UIs, chain/rotation events,\n Coin-Master-style bonus windows, or otherwise touches client.timedEvent,\n TimedEventService, TimedEventDefinitions, ActiveEventInfo,\n EventTokenProgress, or MilestoneDefinition — even if they don't name the\n module explicitly.\n---\n\n# Timed event system (iDosGames TS SDK)\n\nThe TimedEvent module runs live-ops \"limited time events\" (LTEs): a title\nschedules an event (a single window, or a repeating chain of phases), players\nearn an **event token** by doing things the event tracks (`TokenSources`), and\nas their token balance crosses configured thresholds they unlock and claim\n**milestone** rewards. A single event can also carry a cyclic \"bonus window\"\n(Coin-Master-style boosted-reward periods). Everything is\n**server-authoritative**: the client asks to spend/grant tokens or claim a\nmilestone, the backend validates and applies it, and the SDK mirrors the\nconfirmed result into a local cache your UI reads. You never mutate event\nstate yourself — you call a method, check the result, and render from the\ncache.\n\nThis skill is for **using** the production `TimedEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (event not active, threshold not reached, already claimed, claim window\nexpired) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n every event that can run: schedule (`ScheduleSpec`, `Mode` = `Scheduled` |\n `Chained`), display content, the event's token definition, `TokenSources`\n (what earns it), milestones, and any bonus window. A `Chained` event also\n carries `Events`: an ordered list of phases, each with its own duration and\n `Content`. Fetched with `getDefinitions()`.\n2. **Active events + user state** (state, per player) — which event\n **instances** are currently live or still claimable for this player\n (`getActiveEvents()`) and this player's raw per-instance token progress\n (`getUserLteState()`). Both cache separately; the milestone UI should read\n from the **active-events cache**, not the raw token cache (see Gotchas).\n\nAn event is identified by `Type` (`ScheduleMode`: `\"Scheduled\"` |\n`\"Chained\"`) + `LteID` (the `TimedEventID`). A `Scheduled` event has exactly\none instance (its window). A `Chained` event repeats as a sequence of\nphase-instances, each keyed by `(CycleIndex, ChainedEventID)` — the same\n`LteID` produces a new token bucket, a new milestone-claim state, and a new\nbonus-window timeline **every time the chain advances**. Batch refs\n(`TimedEventInstanceRef`) carry all four fields (`Type`, `LteID`,\n`CycleIndex`, `ChainedEventID`) so you can address one specific instance, e.g.\nto claim a just-ended chain phase during its grace window while the next\nphase is already active.\n\nFor the full field-by-field shape of Definitions, milestones, the\nper-instance token progress cache, the composite-key scheme, and the\nself-heal behavior on stale milestone state, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config or to reason\nabout chain/cycle edge cases.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst timedEvents = client.timedEvent; // the TimedEventService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. `\"Event not\nfound or not active.\"`, `\"Milestone already claimed.\"`, `\"Claim window has\nexpired.\"`).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------- |\n| `getActiveEvents()` | Load the event instances currently live/claimable for this player. | `GetActiveEventsResponse` (`ActiveEvents[]`) |\n| `getDefinitions()` | Load the title's full event catalog (config). | `TimedEventDefinitions` |\n| `getUserLteState()` | Load this player's raw per-instance token balances (state). | `UserTimedEventStateResponse` (`Tokens`) |\n| `spendTokens(type, lteID, spendAmount)` | Spend tokens on the current (or an addressed) event instance. | `EventTokenSpendResponse` (`Resources`) |\n| `spendTokensBatch(spends)` | Spend tokens across several event instances in one atomic call. | `SpendTokensBatchResponse` |\n| `grantTokens(type, lteID, sourceType, outcome?, amountOverride?, sourceParams?, rollMultiplier?)` | Report a `TokenSources` trigger firing; server computes/validates the earn. | `ResourceOperation` |\n| `grantTokensBatch(grants)` | Grant tokens across several event instances in one atomic call. | `GrantTokensBatchResponse` |\n| `claimMilestone(type, lteID, milestoneID)` | Claim one reached milestone's reward. | `EventMilestoneClaimResponse` (`Rewards`) |\n| `claimMilestonesBatch(milestones)` | Claim several specific milestones in one atomic call. | `ClaimMilestonesBatchResponse` |\n| `claimAllMilestones(events?)` | Claim every reached-but-unclaimed milestone (all active events if omitted). | `ClaimMilestonesBatchResponse` |\n\n`spendTokens` requires `spendAmount > 0` (validated client-side before the\ncall, mirrored by the server). `grantTokens`/`grantTokensBatch` are\n**trigger-reporting** calls, not a raw \"give me N tokens\" endpoint — see\nGotchas below before wiring these up from arbitrary UI actions.\n\n`spendTokens`/`grantTokens` resolve to the event's **currently active\ninstance** by default (`Type` + `LteID` only). To target a specific\nalready-ended chain instance (e.g. claiming during its grace window while a\nnew phase is already active), pass `CycleIndex` + `ChainedEventID` — the\nsingle-call methods don't expose these directly, but the batch ref types\n(`TimedEventInstanceRef` and its `TimedEventSpendRef`/`TimedEventGrantRef`/\n`TimedEventMilestoneRef` subtypes) do. Read `CurrentCycleIndex` +\n`CurrentChainedEventID` off the relevant `ActiveEventInfo` (from\n`getActiveEvents()`) to get the right values — don't compute them yourself.\n\nOn success, each method mirrors the confirmed change into the cache and emits\nan event — you don't apply anything by hand. Rewards/consumed resources ride\nin `data.Resources` / `data.Rewards` as a `ResourceOperation` (see the\ncurrency-system skill for the shared `ResourceConsume`/`ResourceGrant`/\n`ResourceOperation` shapes) and are already applied to cached balances, so\nread updated balances straight from the cache.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Active event instances (only present after getActiveEvents()); this is the\n// milestone-UI source of truth — its Progress.Milestone.ClaimedIDs is kept\n// in sync by every claim method, batch or not, and is self-healed on read\n// (see Gotchas / references/data-model.md).\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst evt = active.find((e) => e.TimedEventID === \"summer_sale\");\nevt?.Progress; // UserEventTokenProgress: Balance / Milestone / Daily / Meta\nevt?.CanEarn; // server's \"tokens can still be earned\" flag\nevt?.CanClaim; // server's \"still inside the claim/grace window\" flag\nevt?.NextMilestone; // next unclaimed MilestoneDefinition, ordered by RequiredProgress\nevt?.BonusWindow; // computed BonusWindowState, if the event has one\nevt?.CurrentCycleIndex; // Chained only\nevt?.CurrentChainedEventID; // Chained only — which phase is active/ended-in-grace\n\n// Raw per-instance token state (only present after getUserLteState()):\nconst tokens = client.data.user.state?.EventToken?.TimedEvent ?? {};\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedEvent:activeEventsLoaded` → `GetActiveEventsResponse`\n- `timedEvent:definitionsLoaded` → `TimedEventDefinitions`\n- `timedEvent:userStateLoaded` → `UserTimedEventStateResponse`\n- `timedEvent:tokensSpent` → `EventTokenSpendResponse`\n- `timedEvent:tokensSpentBatch` → `SpendTokensBatchResponse`\n- `timedEvent:tokensGranted` → `ResourceOperation`\n- `timedEvent:tokensGrantedBatch` → `GrantTokensBatchResponse`\n- `timedEvent:milestoneClaimed` → `EventMilestoneClaimResponse`\n- `timedEvent:milestonesClaimedBatch` → `ClaimMilestonesBatchResponse`\n- `timedEvent:allMilestonesClaimed` → `ClaimMilestonesBatchResponse`\n\nThe coarse `user:timedEventUpdated` (and umbrella `user:anyUpdated`) also fire\non any timed-event cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedEvent:milestoneClaimed\", (r) => {\n console.log(`${r.LteID} milestone ${r.MilestoneID} claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show active events with a progress bar and milestone track\n\n```ts\nawait client.timedEvent.getDefinitions();\nawait client.timedEvent.getActiveEvents();\n\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\n\nfor (const evt of active) {\n const def = defs?.Definitions?.[evt.TimedEventID ?? \"\"];\n const earned = evt.Progress?.Balance?.TotalEarned ?? 0; // milestone math uses TotalEarned, not Current\n const claimedIDs = evt.Progress?.Milestone?.ClaimedIDs ?? [];\n const milestones = evt.Content?.Milestones ?? def?.Content?.Milestones ?? {};\n // Render each milestone: reached = earned >= milestone.RequiredProgress,\n // claimed = claimedIDs.includes(milestoneID). evt.NextMilestone is the\n // cheapest way to highlight the very next unclaimed one.\n}\n```\n\n`ActiveEventInfo.Content` already carries the resolved display/token/\nmilestone content for the current (or current chained) instance — you\nusually don't need to fall back to `def.Content`, but it's there for\nreference. Milestone thresholds (`RequiredProgress`) are checked against\n`Balance.TotalEarned` (lifetime earned in this instance), **not**\n`Balance.Current` (the spendable balance) — spending tokens never un-reaches\na milestone already earned.\n\n### Spend tokens, then claim a milestone\n\n```ts\nconst spend = await client.timedEvent.spendTokens(\n \"Scheduled\",\n \"summer_sale\",\n 50,\n);\nif (!spend.ok) return showError(spend.error);\n// balances already debited/credited in the cache via spend.data.Resources.\n\nconst claim = await client.timedEvent.claimMilestone(\n \"Scheduled\",\n \"summer_sale\",\n \"milestone_3\",\n);\nif (!claim.ok) return showError(claim.error); // e.g. \"Milestone already claimed.\", \"Not enough earned...\"\n// claim.data.Rewards already applied; ActiveEventInfo.Progress.Milestone.ClaimedIDs updated.\n```\n\n### Claim every reached milestone in one tap (\"claim all\")\n\n```ts\n// Sweeps all active + grace-window-claimable events:\nconst res = await client.timedEvent.claimAllMilestones();\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Scheduled:summer_sale:s202607010000:milestone_2\"\n else showItemError(item.Id, item.Error); // not reached / already claimed\n}\n\n// Or scope it to specific event instances:\nawait client.timedEvent.claimAllMilestones([\n { Type: \"Scheduled\", LteID: \"summer_sale\" },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n },\n]);\n```\n\n`claimAllMilestones` and `claimMilestonesBatch` share the same response shape\n(`ClaimMilestonesBatchResponse`) and the same cache-patch logic — pick\n`claimAllMilestones` for a \"claim everything\" button and\n`claimMilestonesBatch` when the player picked specific milestones. Both also\nrespect `Content.ClaimMode` (`Instant` | `AfterEventEnd` | `FeaturedAfterEnd`)\n— a milestone whose mode isn't satisfied yet is simply not discovered/offered\nby `claimAllMilestones`, or rejected with an explicit error by the targeted\ncalls.\n\n### Batch spend across several events\n\n```ts\nconst res = await client.timedEvent.spendTokensBatch([\n { Type: \"Scheduled\", LteID: \"summer_sale\", SpendAmount: 20 },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n SpendAmount: 10,\n },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (!item.Success) showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that item applied.\nThe resource charge across the whole batch is merged into one atomic backend\noperation — see the character-system skill's batch recipe for the same\nall-or-nothing charge semantics, which apply here too. Up to 50 entries per\nbatch call (`ClaimMilestonesBatch`/`SpendTokensBatch`/`GrantTokensBatch`);\nentries past that are silently dropped before processing — chunk larger sets\nyourself.\n\n### Handle a chained event's cycle rollover\n\n```ts\nawait client.timedEvent.getActiveEvents();\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst raid = active.filter((e) => e.TimedEventID === \"raid_rotation\");\n\n// getActiveEvents() can return TWO entries for the same chained LteID at once:\n// the currently active phase (CanEarn true) and a just-ended phase still\n// inside its ClaimGraceHours window (CanEarn false, CanClaim true). Render\n// both: a \"claim before it's gone\" card for the grace one, the live progress\n// bar for the active one.\nfor (const phase of raid) {\n if (phase.CanClaim && !phase.CanEarn) {\n // Ended, in grace — claim using ITS OWN CycleIndex/ChainedEventID so you\n // don't accidentally resolve to the newly-active phase instead.\n await client.timedEvent.claimAllMilestones([\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: phase.CurrentCycleIndex,\n ChainedEventID: phase.CurrentChainedEventID,\n },\n ]);\n }\n}\n```\n\nEach chain instance (a given `(CycleIndex, ChainedEventID)` pair) has its own\ntoken bucket, so progress and milestone-claimed state reset to zero when the\nchain advances to a new phase or cycle — that's expected, not a bug. See\n[references/data-model.md](references/data-model.md) for the exact key\nformat and the grace-window math.\n\n### Reporting a gameplay trigger that earns tokens\n\n```ts\n// e.g. the player finished a board-loop tile that this event's TokenSources\n// lists as a SourceType. The server validates the trigger against the\n// event's TokenSources filters and computes the actual earned amount.\nconst res = await client.timedEvent.grantTokens(\n \"Scheduled\",\n \"summer_sale\",\n \"CustomAction\",\n /* outcome */ undefined,\n /* amountOverride */ undefined,\n { ActionName: \"daily_puzzle_solved\" },\n);\nif (!res.ok) return showError(res.error);\n```\n\nOnly a handful of `SourceType` values are callable from the client at all\n(`CustomAction`, `DailyLogin`, `ReferralInvite`) — see Gotchas. Gameplay\nsources like board tiles, quest completions, or store purchases are reported\nby the corresponding server module internally; you don't call `grantTokens`\nfor those from client code.\n\n## Gotchas\n\n- **`grantTokens`/`grantTokensBatch` are trigger reports, not a free-mint\n endpoint, and most `SourceType`s are server-only.** The backend rejects\n `BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`,\n `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`, `QuestComplete`,\n `StorePurchase`, `MilestoneReward`, `MarketplaceSell`, and `MarketplaceBuy`\n outright with `\"Source '<type>' cannot be called from client.\"` — only\n `CustomAction`, `DailyLogin`, and `ReferralInvite` are callable from the\n client. Even for those, the server matches your `sourceType`/`outcome`/\n `sourceParams` against the event's configured `TokenSources` and computes\n the actual earned amount itself (`amountOverride` only takes effect if\n present and positive, and is still subject to caps) — never assume the\n client-requested amount is what lands in the balance.\n- **Two separate caches, one source of truth for milestones.** `getActiveEvents()`\n populates `client.data.user.getCachedActiveEvents()`; `getUserLteState()`\n populates `client.data.user.state?.EventToken?.TimedEvent`. Every claim\n method patches **both** (matched by composite key, see\n [references/data-model.md](references/data-model.md)), but the\n active-events cache is the one with `CanEarn`/`CanClaim`/`NextMilestone`/\n `BonusWindow` — build milestone/progress UI off that one, and treat the raw\n token cache as a secondary/debug view.\n- **Milestone state self-heals against `TotalEarned`, not against wall-clock\n cycle boundaries.** On every `getActiveEvents()` call, the server strips any\n `ClaimedIDs`/`UnlockedIDs` entry from a bucket whose milestone's\n `RequiredProgress` **exceeds that bucket's own `Balance.TotalEarned`** —\n i.e. an id that shouldn't be possible given the bucket's own recorded\n earnings (leftover/inconsistent state, e.g. from before per-instance\n keying). Because `TotalEarned` only ever increases, a milestone you\n legitimately claimed (earned >= threshold at claim time) can never be\n swept by this — the self-heal only removes ids that are inconsistent with\n their own bucket's balance. It is best-effort and silently\n self-corrects the DB in the background (a failed cleanup just retries on\n the next `getActiveEvents()` poll) — you don't need to handle it, but don't\n be surprised if a `NextMilestone` you saw as claimed reappears as\n claimable after a refresh if the state was stale.\n- **Chain instances are independent buckets — progress does not carry over.**\n A `Chained` event's token balance, milestone-claimed list, and bonus-window\n timeline are all keyed by `(LteID, CycleIndex, ChainedEventID)`. Moving to\n the next phase or cycle starts that instance's progress at zero; this is by\n design, not a reset bug to work around client-side.\n- **Grace windows keep an ended instance claimable, not earnable.** Once a\n `Scheduled` event's `EndUtc` or a chain phase's end passes, `CanEarn`\n becomes `false` immediately, but `CanClaim` stays `true` until\n `EndUtc + ClaimGraceHours` (`ClaimGraceHours` comes from the event's/phase's\n own config; 0 = no post-end claiming at all). `getActiveEvents()` will\n surface an ended-but-in-grace chain instance as its own entry alongside the\n newly active one — always claim it via its own `CycleIndex` +\n `ChainedEventID`, not the currently-active phase's.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Claim\" can\n attempt to claim twice (the second will simply fail with \"Milestone already\n claimed.\", but a double-clicked spend can genuinely spend twice). Disable\n the control while a call is in flight. Firing the same endpoint again\n within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the composite event-token key scheme, the milestone self-heal\nrule, grace-window math, and the bonus-window model. Read it when building\nconfig-driven UI (progress bars, milestone tracks, bonus-window countdowns)\nor when you need to understand how per-instance token progress is keyed and\nmatched.\n",
4
+ "content": "---\nname: timed-event-system\ndescription: >-\n Build a time-boxed live-ops event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.timedEvent (TimedEventService):\n load active events and their title-wide definitions, read the player's\n per-event-instance token progress, spend event tokens (single + batch),\n grant event tokens (single + batch, server/trigger-driven), and claim\n milestone rewards as token balances cross thresholds (single, batch, and\n claim-all). Use this whenever the user is working in the iDosGames TS SDK\n or its game templates (board-game, idle-rpg) and wants limited-time\n events, seasonal/live-ops event screens, event-token or event-currency\n progress bars, milestone/reward-track UIs, chain/rotation events,\n Coin-Master-style bonus windows, or otherwise touches client.timedEvent,\n TimedEventService, TimedEventDefinitions, ActiveEventInfo,\n EventTokenProgress, or MilestoneDefinition — even if they don't name the\n module explicitly.\n---\n\n# Timed event system (iDosGames TS SDK)\n\nThe TimedEvent module runs live-ops \"limited time events\" (LTEs): a title\nschedules an event (a single window, or a repeating chain of phases), players\nearn an **event token** by doing things the event tracks (`TokenSources`), and\nas their token balance crosses configured thresholds they unlock and claim\n**milestone** rewards. A single event can also carry a cyclic \"bonus window\"\n(Coin-Master-style boosted-reward periods). Everything is\n**server-authoritative**: the client asks to spend/grant tokens or claim a\nmilestone, the backend validates and applies it, and the SDK mirrors the\nconfirmed result into a local cache your UI reads. You never mutate event\nstate yourself — you call a method, check the result, and render from the\ncache.\n\nThis skill is for **using** the production `TimedEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (event not active, threshold not reached, already claimed, claim window\nexpired) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n every event that can run: schedule (`ScheduleSpec`, `Mode` = `Scheduled` |\n `Chained`), display content, the event's token definition, `TokenSources`\n (what earns it), milestones, and any bonus window. A `Chained` event also\n carries `Events`: an ordered list of phases, each with its own duration and\n `Content`. Fetched with `getDefinitions()`.\n2. **Active events + user state** (state, per player) — which event\n **instances** are currently live or still claimable for this player\n (`getActiveEvents()`) and this player's raw per-instance token progress\n (`getUserLteState()`). Both cache separately; the milestone UI should read\n from the **active-events cache**, not the raw token cache (see Gotchas).\n\nAn event is identified by `Type` (`ScheduleMode`: `\"Scheduled\"` |\n`\"Chained\"`) + `LteID` (the `TimedEventID`). A `Scheduled` event has exactly\none instance (its window). A `Chained` event repeats as a sequence of\nphase-instances, each keyed by `(CycleIndex, ChainedEventID)` — the same\n`LteID` produces a new token bucket, a new milestone-claim state, and a new\nbonus-window timeline **every time the chain advances**. Batch refs\n(`TimedEventInstanceRef`) carry all four fields (`Type`, `LteID`,\n`CycleIndex`, `ChainedEventID`) so you can address one specific instance, e.g.\nto claim a just-ended chain phase during its grace window while the next\nphase is already active.\n\nFor the full field-by-field shape of Definitions, milestones, the\nper-instance token progress cache, the composite-key scheme, and the\nself-heal behavior on stale milestone state, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config or to reason\nabout chain/cycle edge cases.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst timedEvents = client.timedEvent; // the TimedEventService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. `\"Event not\nfound or not active.\"`, `\"Milestone already claimed.\"`, `\"Claim window has\nexpired.\"`).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------- |\n| `getActiveEvents()` | Load the event instances currently live/claimable for this player. | `GetActiveEventsResponse` (`ActiveEvents[]`) |\n| `getDefinitions()` | Load the title's full event catalog (config). | `TimedEventDefinitions` |\n| `getUserLteState()` | Load this player's raw per-instance token balances (state). | `UserTimedEventStateResponse` (`Tokens`) |\n| `spendTokens(type, lteID, spendAmount)` | Spend tokens on the current (or an addressed) event instance. | `EventTokenSpendResponse` (`Resources`) |\n| `spendTokensBatch(spends)` | Spend tokens across several event instances in one atomic call. | `SpendTokensBatchResponse` |\n| `grantTokens(type, lteID, sourceType, outcome?, amountOverride?, sourceParams?, rollMultiplier?)` | Report a `TokenSources` trigger firing; server computes/validates the earn. | `ResourceOperation` |\n| `grantTokensBatch(grants)` | Grant tokens across several event instances in one atomic call. | `GrantTokensBatchResponse` |\n| `claimMilestone(type, lteID, milestoneID)` | Claim one reached milestone's reward. | `EventMilestoneClaimResponse` (`Rewards`) |\n| `claimMilestonesBatch(milestones)` | Claim several specific milestones in one atomic call. | `ClaimMilestonesBatchResponse` |\n| `claimAllMilestones(events?)` | Claim every reached-but-unclaimed milestone (all active events if omitted). | `ClaimMilestonesBatchResponse` |\n\n`spendTokens` requires `spendAmount > 0` (validated client-side before the\ncall, mirrored by the server). `grantTokens`/`grantTokensBatch` are\n**trigger-reporting** calls, not a raw \"give me N tokens\" endpoint — see\nGotchas below before wiring these up from arbitrary UI actions.\n\n`spendTokens`/`grantTokens` resolve to the event's **currently active\ninstance** by default (`Type` + `LteID` only). To target a specific\nalready-ended chain instance (e.g. claiming during its grace window while a\nnew phase is already active), pass `CycleIndex` + `ChainedEventID` — the\nsingle-call methods don't expose these directly, but the batch ref types\n(`TimedEventInstanceRef` and its `TimedEventSpendRef`/`TimedEventGrantRef`/\n`TimedEventMilestoneRef` subtypes) do. Read `CurrentCycleIndex` +\n`CurrentChainedEventID` off the relevant `ActiveEventInfo` (from\n`getActiveEvents()`) to get the right values — don't compute them yourself.\n\nOn success, each method mirrors the confirmed change into the cache and emits\nan event — you don't apply anything by hand. Rewards/consumed resources ride\nin `data.Resources` / `data.Rewards` as a `ResourceOperation` (see the\ncurrency-system skill for the shared `ResourceConsume`/`ResourceGrant`/\n`ResourceOperation` shapes) and are already applied to cached balances, so\nread updated balances straight from the cache.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Active event instances (only present after getActiveEvents()); this is the\n// milestone-UI source of truth — its Progress.Milestone.ClaimedIDs is kept\n// in sync by every claim method, batch or not, and is self-healed on read\n// (see Gotchas / references/data-model.md).\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst evt = active.find((e) => e.TimedEventID === \"summer_sale\");\nevt?.Progress; // UserEventTokenProgress: Balance / Milestone / Daily / Meta\nevt?.CanEarn; // server's \"tokens can still be earned\" flag\nevt?.CanClaim; // server's \"still inside the claim/grace window\" flag\nevt?.NextMilestone; // next unclaimed MilestoneDefinition, ordered by RequiredProgress\nevt?.BonusWindow; // computed BonusWindowState, if the event has one\nevt?.CurrentCycleIndex; // Chained only\nevt?.CurrentChainedEventID; // Chained only — which phase is active/ended-in-grace\n\n// Raw per-instance token state (only present after getUserLteState()):\nconst tokens = client.data.user.state?.EventToken?.TimedEvent ?? {};\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedEvent:activeEventsLoaded` → `GetActiveEventsResponse`\n- `timedEvent:definitionsLoaded` → `TimedEventDefinitions`\n- `timedEvent:userStateLoaded` → `UserTimedEventStateResponse`\n- `timedEvent:tokensSpent` → `EventTokenSpendResponse`\n- `timedEvent:tokensSpentBatch` → `SpendTokensBatchResponse`\n- `timedEvent:tokensGranted` → `ResourceOperation`\n- `timedEvent:tokensGrantedBatch` → `GrantTokensBatchResponse`\n- `timedEvent:milestoneClaimed` → `EventMilestoneClaimResponse`\n- `timedEvent:milestonesClaimedBatch` → `ClaimMilestonesBatchResponse`\n- `timedEvent:allMilestonesClaimed` → `ClaimMilestonesBatchResponse`\n\nThe coarse `user:timedEventUpdated` (and umbrella `user:anyUpdated`) also fire\non any timed-event cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedEvent:milestoneClaimed\", (r) => {\n console.log(`${r.LteID} milestone ${r.MilestoneID} claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show active events with a progress bar and milestone track\n\n```ts\nawait client.timedEvent.getDefinitions();\nawait client.timedEvent.getActiveEvents();\n\nconst defs = client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\");\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\n\nfor (const evt of active) {\n const def = defs?.Definitions?.[evt.TimedEventID ?? \"\"];\n const earned = evt.Progress?.Balance?.TotalEarned ?? 0; // milestone math uses TotalEarned, not Current\n const claimedIDs = evt.Progress?.Milestone?.ClaimedIDs ?? [];\n const milestones = evt.Content?.Milestones ?? def?.Content?.Milestones ?? {};\n // Render each milestone: reached = earned >= milestone.RequiredProgress,\n // claimed = claimedIDs.includes(milestoneID). evt.NextMilestone is the\n // cheapest way to highlight the very next unclaimed one.\n}\n```\n\n`ActiveEventInfo.Content` already carries the resolved display/token/\nmilestone content for the current (or current chained) instance — you\nusually don't need to fall back to `def.Content`, but it's there for\nreference. Milestone thresholds (`RequiredProgress`) are checked against\n`Balance.TotalEarned` (lifetime earned in this instance), **not**\n`Balance.Current` (the spendable balance) — spending tokens never un-reaches\na milestone already earned.\n\n### Spend tokens, then claim a milestone\n\n```ts\nconst spend = await client.timedEvent.spendTokens(\n \"Scheduled\",\n \"summer_sale\",\n 50,\n);\nif (!spend.ok) return showError(spend.error);\n// balances already debited/credited in the cache via spend.data.Resources.\n\nconst claim = await client.timedEvent.claimMilestone(\n \"Scheduled\",\n \"summer_sale\",\n \"milestone_3\",\n);\nif (!claim.ok) return showError(claim.error); // e.g. \"Milestone already claimed.\", \"Not enough earned...\"\n// claim.data.Rewards already applied; ActiveEventInfo.Progress.Milestone.ClaimedIDs updated.\n```\n\n### Claim every reached milestone in one tap (\"claim all\")\n\n```ts\n// Sweeps all active + grace-window-claimable events:\nconst res = await client.timedEvent.claimAllMilestones();\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Scheduled:summer_sale:s202607010000:milestone_2\"\n else showItemError(item.Id, item.Error); // not reached / already claimed\n}\n\n// Or scope it to specific event instances:\nawait client.timedEvent.claimAllMilestones([\n { Type: \"Scheduled\", LteID: \"summer_sale\" },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n },\n]);\n```\n\n`claimAllMilestones` and `claimMilestonesBatch` share the same response shape\n(`ClaimMilestonesBatchResponse`) and the same cache-patch logic — pick\n`claimAllMilestones` for a \"claim everything\" button and\n`claimMilestonesBatch` when the player picked specific milestones. Both also\nrespect `Content.ClaimMode` (`Instant` | `AfterEventEnd` | `FeaturedAfterEnd`)\n— a milestone whose mode isn't satisfied yet is simply not discovered/offered\nby `claimAllMilestones`, or rejected with an explicit error by the targeted\ncalls.\n\n### Batch spend across several events\n\n```ts\nconst res = await client.timedEvent.spendTokensBatch([\n { Type: \"Scheduled\", LteID: \"summer_sale\", SpendAmount: 20 },\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: 4,\n ChainedEventID: \"boss_phase\",\n SpendAmount: 10,\n },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (!item.Success) showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that item applied.\nThe resource charge across the whole batch is merged into one atomic backend\noperation — see the character-system skill's batch recipe for the same\nall-or-nothing charge semantics, which apply here too. Up to 50 entries per\nbatch call (`ClaimMilestonesBatch`/`SpendTokensBatch`/`GrantTokensBatch`);\nentries past that are silently dropped before processing — chunk larger sets\nyourself.\n\n### Handle a chained event's cycle rollover\n\n```ts\nawait client.timedEvent.getActiveEvents();\nconst active = client.data.user.getCachedActiveEvents()?.ActiveEvents ?? [];\nconst raid = active.filter((e) => e.TimedEventID === \"raid_rotation\");\n\n// getActiveEvents() can return TWO entries for the same chained LteID at once:\n// the currently active phase (CanEarn true) and a just-ended phase still\n// inside its ClaimGraceHours window (CanEarn false, CanClaim true). Render\n// both: a \"claim before it's gone\" card for the grace one, the live progress\n// bar for the active one.\nfor (const phase of raid) {\n if (phase.CanClaim && !phase.CanEarn) {\n // Ended, in grace — claim using ITS OWN CycleIndex/ChainedEventID so you\n // don't accidentally resolve to the newly-active phase instead.\n await client.timedEvent.claimAllMilestones([\n {\n Type: \"Chained\",\n LteID: \"raid_rotation\",\n CycleIndex: phase.CurrentCycleIndex,\n ChainedEventID: phase.CurrentChainedEventID,\n },\n ]);\n }\n}\n```\n\nEach chain instance (a given `(CycleIndex, ChainedEventID)` pair) has its own\ntoken bucket, so progress and milestone-claimed state reset to zero when the\nchain advances to a new phase or cycle — that's expected, not a bug. See\n[references/data-model.md](references/data-model.md) for the exact key\nformat and the grace-window math.\n\n### Reporting a gameplay trigger that earns tokens\n\n```ts\n// e.g. the player finished a board-loop tile that this event's TokenSources\n// lists as a SourceType. The server validates the trigger against the\n// event's TokenSources filters and computes the actual earned amount.\nconst res = await client.timedEvent.grantTokens(\n \"Scheduled\",\n \"summer_sale\",\n \"CustomAction\",\n /* outcome */ undefined,\n /* amountOverride */ undefined,\n { ActionName: \"daily_puzzle_solved\" },\n);\nif (!res.ok) return showError(res.error);\n```\n\nOnly TWO `SourceType` values are callable from the client at all: `CustomAction`\nand `DailyLogin` — see Gotchas. Everything else is credited by the server itself\nwhen the action really happens: board tiles, store purchases, quest claims,\nmarketplace deals, invites (`ReferralInvite`), currency spent/earned, real-money\npurchases (`IapPurchase`), lootbox opens, crypto deposit/withdraw, leaderboard\nrank rewards. Configure the event's `TokenSources` and do nothing in client code.\n\n## Gotchas\n\n- **`grantTokens`/`grantTokensBatch` are trigger reports, not a free-mint\n endpoint, and most `SourceType`s are server-only.** The backend rejects\n `BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`,\n `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`, `QuestComplete`,\n `StorePurchase`, `MilestoneReward`, `MarketplaceSell`, `MarketplaceBuy`,\n `ReferralInvite`, `CurrencySpent`, `CurrencyEarned`, `CryptoSpent`,\n `CryptoEarned`, `IapPurchase`, `LootboxOpened`, `CryptoDeposit`,\n `CryptoWithdraw`, and `LeaderboardRankReward` outright with\n `\"Source '<type>' cannot be called from client.\"` — only `CustomAction` and\n `DailyLogin` are callable from the client (a whitelist since 2026-09-28; the\n server credits the rest after the request). Even for those, the server matches your `sourceType`/`outcome`/\n `sourceParams` against the event's configured `TokenSources` and computes\n the actual earned amount itself (`amountOverride` only takes effect if\n present and positive, and is still subject to caps) — never assume the\n client-requested amount is what lands in the balance.\n- **Two separate caches, one source of truth for milestones.** `getActiveEvents()`\n populates `client.data.user.getCachedActiveEvents()`; `getUserLteState()`\n populates `client.data.user.state?.EventToken?.TimedEvent`. Every claim\n method patches **both** (matched by composite key, see\n [references/data-model.md](references/data-model.md)), but the\n active-events cache is the one with `CanEarn`/`CanClaim`/`NextMilestone`/\n `BonusWindow` — build milestone/progress UI off that one, and treat the raw\n token cache as a secondary/debug view.\n- **Milestone state self-heals against `TotalEarned`, not against wall-clock\n cycle boundaries.** On every `getActiveEvents()` call, the server strips any\n `ClaimedIDs`/`UnlockedIDs` entry from a bucket whose milestone's\n `RequiredProgress` **exceeds that bucket's own `Balance.TotalEarned`** —\n i.e. an id that shouldn't be possible given the bucket's own recorded\n earnings (leftover/inconsistent state, e.g. from before per-instance\n keying). Because `TotalEarned` only ever increases, a milestone you\n legitimately claimed (earned >= threshold at claim time) can never be\n swept by this — the self-heal only removes ids that are inconsistent with\n their own bucket's balance. It is best-effort and silently\n self-corrects the DB in the background (a failed cleanup just retries on\n the next `getActiveEvents()` poll) — you don't need to handle it, but don't\n be surprised if a `NextMilestone` you saw as claimed reappears as\n claimable after a refresh if the state was stale.\n- **Chain instances are independent buckets — progress does not carry over.**\n A `Chained` event's token balance, milestone-claimed list, and bonus-window\n timeline are all keyed by `(LteID, CycleIndex, ChainedEventID)`. Moving to\n the next phase or cycle starts that instance's progress at zero; this is by\n design, not a reset bug to work around client-side.\n- **Grace windows keep an ended instance claimable, not earnable.** Once a\n `Scheduled` event's `EndUtc` or a chain phase's end passes, `CanEarn`\n becomes `false` immediately, but `CanClaim` stays `true` until\n `EndUtc + ClaimGraceHours` (`ClaimGraceHours` comes from the event's/phase's\n own config; 0 = no post-end claiming at all). `getActiveEvents()` will\n surface an ended-but-in-grace chain instance as its own entry alongside the\n newly active one — always claim it via its own `CycleIndex` +\n `ChainedEventID`, not the currently-active phase's.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Claim\" can\n attempt to claim twice (the second will simply fail with \"Milestone already\n claimed.\", but a double-clicked spend can genuinely spend twice). Disable\n the control while a call is in flight. Firing the same endpoint again\n within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the composite event-token key scheme, the milestone self-heal\nrule, grace-window math, and the bonus-window model. Read it when building\nconfig-driven UI (progress bars, milestone tracks, bonus-window countdowns)\nor when you need to understand how per-instance token progress is keyed and\nmatched.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",