@idosgames/mcp 0.1.16 → 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 (34) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +19 -7
  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 +98 -41
  26. package/registry/modules/workshop.json +3 -3
  27. package/registry/skills/authentication.json +1 -1
  28. package/registry/skills/blockchain-system.json +1 -1
  29. package/registry/skills/idosgames-module-contract.json +1 -1
  30. package/registry/skills/idosgames-ui-kit.json +6 -0
  31. package/registry/skills/leaderboard-system.json +1 -1
  32. package/registry/skills/multiplayer-realtime.json +3 -3
  33. package/registry/skills/referral-system.json +2 -2
  34. package/registry/skills/timed-event-system.json +1 -1
@@ -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",