@idosgames/mcp 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +28 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/character-system.json +2 -2
- package/registry/skills/checkout-system.json +6 -0
- package/registry/skills/cloud-code.json +2 -2
- package/registry/skills/collection-system.json +2 -2
- package/registry/skills/coop-event-system.json +2 -2
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/localization-system.json +6 -0
- package/registry/skills/lootbox-system.json +2 -2
- package/registry/skills/marketplace-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +2 -2
- package/registry/skills/quest-system.json +1 -1
- package/registry/skills/referral-system.json +1 -1
- package/registry/skills/season-system.json +1 -1
- package/registry/skills/social-system.json +1 -1
- package/registry/skills/store-system.json +2 -2
- package/registry/skills/timed-boost-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
- package/registry/skills/tutorial-system.json +6 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Timed-event data model — reference\n\nFull shape of the config (Definitions) and player state, the composite\nevent-token key scheme, the milestone self-heal rule, grace-window math, and\nthe bonus-window model. All of these are **strictly typed in the SDK** —\n`TimedEventDefinitions` and every nested block (`TimedEventDefinition`,\n`ChainedEventDefinition`, `EventContent`, `BonusWindowConfig`,\n`ActiveEventInfo`, …) are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<TimedEventDefinitions>(\"TimedEvent\")` give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\nEvery claim in this file traces to a specific backend source line — cited\ninline as `(file:line)` against the iDos_Games_Engine repo.\n\n## Contents\n\n- [Config: TimedEventDefinitions](#config-timedeventdefinitions)\n- [TimedEventDefinition (Scheduled vs Chained)](#timedeventdefinition-scheduled-vs-chained)\n- [EventContent](#eventcontent)\n- [Player state: UserEventTokenProgress](#player-state-usereventtokenprogress)\n- [ActiveEventInfo (getActiveEvents response)](#activeeventinfo-getactiveevents-response)\n- [The composite instance-key scheme](#the-composite-instance-key-scheme)\n- [Grace windows and claim-only instances](#grace-windows-and-claim-only-instances)\n- [Milestone claim rules and the self-heal on read](#milestone-claim-rules-and-the-self-heal-on-read)\n- [Bonus window (Coin-Master-style)](#bonus-window-coin-master-style)\n- [Token sources, matching, and grant math](#token-sources-matching-and-grant-math)\n- [Server-side limits, batching, and idempotency](#server-side-limits-batching-and-idempotency)\n\n---\n\n## Config: TimedEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\")`.\n\n```ts\ninterface TimedEventDefinitions {\n Definitions?: Record<string, TimedEventDefinition>; // key = TimedEventID\n Settings?: LimitedTimeEventsGlobalSettings;\n}\n\ninterface LimitedTimeEventsGlobalSettings {\n MaxConcurrentEvents?: number; // config-mistake guard; default 5\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/TimedEventDefinitions.cs:27-58`)\n\n---\n\n## TimedEventDefinition (Scheduled vs Chained)\n\nOne dictionary holds both kinds; the mode lives in `Schedule.Mode`.\n\n```ts\ninterface TimedEventDefinition {\n TimedEventID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode: \"Scheduled\" | \"Chained\"\n Content?: EventContent; // used when Mode = Scheduled\n Events?: ChainedEventDefinition[]; // used when Mode = Chained\n Gate?: SegmentGate; // audience gate; null = everyone\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:72-130`)\n\n- **Scheduled**: one fixed window (`Schedule.Scheduled: ScheduledWindow` —\n `StartUtc`, `EndUtc`, `AllowEarningAfterEnd`, `ClaimGraceHours`). Content\n lives directly on `Content`.\n- **Chained**: a repeating ordered list of phases (`Events`), timed by\n `Schedule.Chain: ScheduleChain` (`AnchorUtc`, `MaxCycles`,\n `PauseBetweenPhasesSec`, `PauseBetweenCyclesSec`). Each phase has its own\n `Content`. After the last phase, the whole cycle restarts from phase 0\n (unless `MaxCycles` caps the number of repeats).\n\n```ts\ninterface ChainedEventDefinition {\n ChainedEventID?: string; // unique within the chain\n Order?: number; // 0-based position; defines phase sequence\n DurationSec?: number;\n Content?: EventContent;\n ClaimGraceHours?: number; // 0 = no claiming once this phase ends\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:138-180`)\n\n`Gate` is the standard `SegmentGate` (Core/Segment) — `Segments`,\n`MinPremiumTier`, `RequiredPremiumIDs`, `MinLevel`/`MaxLevel`, `Countries`,\n`RegisteredWithinDays`, `ActiveWithinDays`, `Experiment`. A player failing the\ngate does not see the event in `getActiveEvents()` and cannot earn or spend\nits tokens — `GrantTokensInternal` re-checks the gate server-side even if a\nstale client tries to call it directly\n(`IDosGamesSDK/API/Client/v2/TimedEvent/TimedEvent.cs:325-329`).\n\n---\n\n## EventContent\n\nShared shape used by both a `Scheduled` event's `Content` and each\n`ChainedEventDefinition.Content`.\n\n```ts\ninterface EventContent {\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Category?: string; // free-form UI grouping tag\n Token?: EventTokenDefinition; // the event token's own config\n TokenSources?: TriggerSource[]; // whitelist of what earns this token\n ClaimMode?: \"Instant\" | \"AfterEventEnd\" | \"FeaturedAfterEnd\";\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID\n BonusWindow?: BonusWindowConfig; // null = disabled for this event\n}\n```\n\n(`TimedEventDefinitions.cs:192-280`, `Core/Milestone/Models/MilestoneClaimMode.cs:14-36`)\n\n`EventTokenDefinition` (`_shared/EventTokenDefinitionModels.ts`, port of\n`Core/Event/Models/EventTokenModels.cs:399-453`):\n\n```ts\ninterface EventTokenDefinition {\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxBalance?: number; // 0 = unlimited spendable balance cap\n MaxPerGrant?: number; // per-grant clamp; default 1000 server-side\n DailyEarnCap?: number; // 0 = unlimited daily earn total\n BurnOnEventEnd?: boolean; // default true — balance zeroed at event end\n BurnConversion?: EventTokenConversion; // optional leftover→currency conversion\n}\n```\n\n`MilestoneDefinition` is the shared Core/Milestone primitive (also used by\nLeaderboard/Quest/CommunityChest/DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against Balance.TotalEarned\n Rewards?: ResourceGrant; // base reward\n BonusRewards?: ResourceGrant; // added/scaled in during an active bonus window\n SeasonTierRewards?: SeasonTierRewardSet; // not used by TimedEvent\n SortOrder?: number;\n IsFeatured?: boolean; // gates FeaturedAfterEnd behavior\n}\n```\n\n(`Core/Milestone/Models/MilestoneDefinition.cs` via `_shared/MilestoneModels.ts:125-138`)\n\n`TriggerSource` (shared `_shared/ScheduleModels.ts:83-96`, port of\n`Core/Scheduling/Models/TriggerSource.cs`):\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // EventTokenSourceType, e.g. \"BoardTileLanding\"\n BaseWeight?: number; // tokens granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's roll multiplier\n TileTypeFilter?: string[]; // BoardTileLanding only; empty = any\n TileIndexFilter?: number[]; // BoardTileLanding only; empty = any\n ChanceOutcomeFilter?: string[]; // BoardTileLanding Chance tiles only; empty = any\n OutcomeFilter?: string[]; // checked for every source type; empty = any\n Params?: Record<string, string>; // CustomAction: ActionName; Marketplace*: CatalogID/ItemID/OfferType\n Limits?: LimitSpec; // DailyCap / DailyWeightCap / CooldownSeconds\n}\n```\n\n---\n\n## Player state: UserEventTokenProgress\n\nReturned inside `getUserLteState()`'s `Tokens` map and inside each\n`ActiveEventInfo.Progress`.\n\n```ts\ninterface UserEventTokenProgress {\n Balance?: {\n Current: number; // spendable balance; rises on grant, falls on spend\n TotalEarned: number; // lifetime earned in THIS instance; monotonic; milestone math uses this\n TotalSpent: number; // lifetime spent in this instance; analytics only\n };\n Daily?: {\n Date: string; // UTC date the counters below apply to; lazy-reset on next grant\n TotalEarned: number;\n EarnedBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyWeightCap\n TriggersBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyCap\n LastTriggerBySource?: Record<string, string>; // vs TriggerSource.Limits.CooldownSeconds; NOT reset daily\n };\n Meta?: {\n JoinedAtUtc?: string; // first grant into this instance's bucket\n LastEarnedAtUtc?: string;\n };\n Milestone?: {\n ClaimedIDs?: string[];\n UnlockedIDs?: string[]; // reached but not yet claimable under AfterEventEnd/FeaturedAfterEnd\n };\n}\n```\n\n(`Core/Event/Models/EventTokenModels.cs:51-179`, mirrored in SDK\n`_shared/EventTokenState.ts:8-38`)\n\nImportant: **spending tokens never affects `TotalEarned`**\n(`EventTokenService.ComputeSpend`, `EventTokenService.cs:311-337` only\ntouches `Balance.Current`/`Balance.TotalSpent`), so a milestone earned and\nthen \"un-afforded\" by spending remains claimable/claimed — milestones track\nlifetime earning, not current balance.\n\n---\n\n## ActiveEventInfo (getActiveEvents response)\n\n```ts\ninterface ActiveEventInfo {\n Type?: \"Scheduled\" | \"Chained\";\n TimedEventID?: string;\n CurrentChainedEventID?: string | null; // null for Scheduled\n Content?: EventContent | null; // resolved content for the current/ended instance\n Progress?: UserEventTokenProgress | null;\n ComputedStartUtc?: string | null;\n ComputedEndUtc?: string | null;\n CanEarn?: boolean | null; // tokens can still be granted for this instance\n CanClaim?: boolean | null; // still inside claim/grace window\n NextMilestone?: MilestoneDefinition | null; // lowest RequiredProgress not yet in ClaimedIDs\n BonusWindow?: BonusWindowState | null; // computed; null = no window / disabled\n CurrentCycleIndex?: number | null; // Chained only\n CurrentEventOrder?: number | null; // Chained only: 1-based position... (see note)\n TotalEventsInChain?: number | null; // Chained only\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/UserTimedEventState.cs:23-78`)\n\nNote: the backend populates `CurrentEventOrder` from\n`ChainedEventDefinition.Order`, which is documented as 0-based\n(`TimedEventDefinitions.cs:148-152`) — the SDK's own doc-comment calling it\n\"1-based\" is aspirational UI framing, not a code guarantee; treat it as \"the\nphase's configured `Order` value\" and don't assume it starts at 1.\n\n`getActiveEvents()` can return **more than one `ActiveEventInfo` for the same\n`Chained` `TimedEventID`** in a single response: the currently active phase,\nplus any phase(s) that already ended but are still inside their\n`ClaimGraceHours` window (`CanEarn: false`, `CanClaim: true`)\n(`TimedEvent.cs:149-169`, `EnumerateEndedInGraceChainInstances`,\n`TimedEvent.cs:801-836`). Disambiguate them by `CurrentCycleIndex` +\n`CurrentChainedEventID`.\n\n---\n\n## The composite instance-key scheme\n\nEvery event **instance** — not just every event — gets its own progress\nbucket, milestone-claimed list, and (for chains) bonus-window timeline. The\nbucket key (`EventTokenAddress.EntityID`, stored under\n`UserDataDocument.EventToken.TimedEvent[EntityID]`) is:\n\n```\nEntityID = \"{TimedEventID}:{InstanceKey}\"\n```\n\n(`TimedEvent.cs:1044-1057`, `BuildTokenAddress`)\n\nWhere `InstanceKey` depends on the resolved mode\n(`Core/Scheduling/Services/ScheduleInstanceKey.cs:14-23`):\n\n| Mode | `InstanceKey` format | Example |\n| ----------- | ------------------------------ | --------------- |\n| `AlwaysOn` | `\"all\"` | `all` |\n| `Scheduled` | `\"s{yyyyMMddHHmm}\"` (StartUtc) | `s202607010000` |\n| `Chained` | `\"{cycleIndex}:{phaseID}\"` | `4:boss_phase` |\n\nSo a `Scheduled` event's `EntityID` is effectively\n`\"summer_sale:s202607010000\"`, and a `Chained` event's is\n`\"raid_rotation:4:boss_phase\"`. This is why re-running the same\n`TimedEventID` (a new Scheduled window with a different `StartUtc`, or the\nnext chain cycle) starts every player at a fresh `Balance`/`Milestone`\nbucket — nothing carries over, by design.\n\n### Addressing an event from title config (short form)\n\nThe composite key above is a **runtime** address — the cycle index and the\nwindow start are unknowable when a reward is authored. So a reward written in\ntitle config (a Special-mode choice on the board, a store offer, a quest\npayout…) addresses the event by name instead:\n\n| `Address.EntityID` in config | Meaning |\n| ---------------------------- | ---------------------------------------------------------- |\n| `\"raid_rotation\"` | whichever instance of that event is live at grant time |\n| `\"raid_rotation:boss_phase\"` | that chain phase, current cycle — skipped when it isn't live |\n\nThe backend expands it right before the grant (`EventTokenAddressResolver`,\ncalled from `ResourceService`), stamping the instance suffix that is active at\nthat moment. A grant whose event is paused, off, or currently in another phase\nis dropped rather than written to a bucket nobody reads; a *consume* keeps the\nshort address so the price can never silently become free. Already-composite\naddresses pass through untouched, so this is safe to re-apply.\n\nThe SDK's `UserTimedEventStateResponse.Tokens` map uses these same composite\nkeys. Cache helpers that need to find \"the bucket for this `LteID`, whatever\nits current instance suffix is\" use `matchesBase(key, lteID)`\n(`packages/core/src/util/eventTokenIds.ts:4-6`): a key belongs to a base id\nif it equals it exactly or starts with `\"{lteID}:\"`. `getUserLteState()` is a\nflat dump of every bucket the player has ever touched (including stale\nfinished instances) — don't assume one entry per `LteID`.\n\n---\n\n## Grace windows and claim-only instances\n\nOnce an instance's window ends, tokens can no longer be earned\n(`CanEarn` flips to `false`), but the milestone rewards already reached can\nstill be claimed until a grace deadline:\n\n```\nClaimDeadlineUtc = EndUtc + ClaimGraceHours\n```\n\n- `Scheduled`: `ClaimGraceHours` comes from `Schedule.Scheduled.ClaimGraceHours`\n (`TimedEvent.cs:728`). `AllowEarningAfterEnd` (also on `ScheduledWindow`)\n lets earning continue past `EndUtc` if set — independent of the grace\n window, which only governs _claiming_.\n- `Chained`: `ClaimGraceHours` comes from the specific\n `ChainedEventDefinition.ClaimGraceHours` (`TimedEvent.cs:718,773,826`) —\n each phase can have its own grace period. `AllowEarningAfterEnd` is always\n `false` for chain phases (`TimedEvent.cs:719`) — earning always stops the\n instant the phase ends.\n- `now > ClaimDeadlineUtc` ⇒ the instance is gone entirely: `ResolveScheduled`\n / `ScheduleResolver.ResolveChainInstance` return `null`\n (`Core/Scheduling/Services/ScheduleResolver.cs:127-145,361-406`), and any\n spend/grant/claim call against it fails with `\"Event not found or not\nactive.\"` / `\"...not in claim window.\"`.\n\n`EnumerateEndedInGraceChainInstances` walks backward through past chain\ncycles (hard-capped at 200 lookback instances,\n`ScheduleResolver.cs:414-484`) collecting every phase whose\n`now ∈ (EndUtc, EndUtc + ClaimGraceHours]`, **only for instances where the\nplayer has existing progress** (`TimedEvent.cs:156-159` — buckets with no\nprogress are skipped, so a phase the player never touched doesn't clutter\nthe active-events list). These are returned with `CanEarn: false,\nCanClaim: true` and must be addressed by their own `CycleIndex` +\n`ChainedEventID` when spending/claiming (`ResolveEventFromArgs`,\n`TimedEvent.cs:672-686`, only takes the explicit-instance path when **both**\n`CycleIndex` and `ChainedEventID` are supplied — omitting either resolves to\nwhatever instance is currently active instead).\n\n---\n\n## Milestone claim rules and the self-heal on read\n\n**Claim gate** (`ClaimMilestone`, `TimedEvent.cs:518-658`, and the batch\npaths mirror this via `CheckMilestoneClaimMode`, `TimedEvent.cs:1767-1775`):\n\n1. The resolved instance must have `CanClaim: true` (inside its window or\n grace), else `\"Claim window has expired.\"`.\n2. The milestone id must exist in the resolved content's `Milestones`, else\n `\"Milestone '<id>' not found.\"`.\n3. `Content.ClaimMode` gate:\n - `Instant` — always allowed once reached.\n - `AfterEventEnd` — rejected with `\"Milestone can only be claimed after\nevent ends.\"` until `now > EndUtc`.\n - `FeaturedAfterEnd` — same rejection (`\"Featured milestone can only be\nclaimed after event ends.\"`) but **only** when `MilestoneDefinition.IsFeatured\n=== true`; non-featured milestones under this mode behave like `Instant`.\n4. `EventTokenService.ComputeMilestoneClaim` (`EventTokenService.cs:343-366`):\n fails with `\"No progress for this event token.\"` if the bucket doesn't\n exist at all, `\"Not enough earned. Have: {X}, need: {Y}.\"` if\n `Balance.TotalEarned < RequiredProgress`, or `\"Milestone already\nclaimed.\"` if the id is already in `ClaimedIDs`.\n\n**Self-heal on `GetActiveEvents` read** (`SanitizeMilestoneState`,\n`TimedEvent.cs:1070-1131`, invoked from `BuildActiveEventInfo` at\n`TimedEvent.cs:1143` and staged as background `$pullAll` patches at\n`TimedEvent.cs:112-187`):\n\n- Trigger condition: for the **specific instance bucket being read**, any id\n present in that bucket's `Milestone.ClaimedIDs` or `Milestone.UnlockedIDs`\n whose corresponding `MilestoneDefinition.RequiredProgress` is **greater\n than that same bucket's own `Balance.TotalEarned`** is stale. An id with no\n matching entry in the resolved content's `Milestones` dictionary is also\n stripped (nothing to verify it against). The check is\n `totalEarned >= def.RequiredProgress` per id\n (`TimedEvent.cs:1076-1080`, local function `Reached`).\n- Why it's safe: `TotalEarned` is monotonically non-decreasing\n (`EventTokenService.ComputeGrant` only ever increments it,\n `EventTokenService.cs:226,271,283`), so a milestone legitimately claimed\n (earned had already reached the threshold _at claim time_) can never later\n have `TotalEarned` fall back below `RequiredProgress`. The only ids this\n can strip are ones inconsistent with their own bucket's recorded earnings\n — e.g. leftover data from before per-instance keying was introduced, not\n anything a normal claim flow can produce.\n- Effect: the returned `ActiveEventInfo.Progress.Milestone.ClaimedIDs`/\n `UnlockedIDs` (and therefore `NextMilestone`, which is computed from the\n sanitized `ClaimedIDs`) are already clean in the response you receive — you\n never see the stale ids. Separately, the same removals are persisted to\n the DB via `$pullAll` on `{entryPath}.Milestone.ClaimedIDs` /\n `...UnlockedIDs` (`TimedEvent.cs:1114-1131`) so the fix is permanent; this\n DB write is best-effort and wrapped in a swallowed try/catch\n (`TimedEvent.cs:177-187`) — a failed cleanup simply retries on the next\n `GetActiveEvents` call and never fails the read itself.\n- This only runs from `GetActiveEvents` (both the currently-active-instance\n path and the ended-in-grace path) — `GetUserLteState` returns the raw\n bucket as stored, unsanitized, which is one more reason to treat it as a\n secondary/debug view rather than the milestone UI's source of truth.\n\n---\n\n## Bonus window (Coin-Master-style)\n\n`EventContent.BonusWindow` (nullable) describes a repeating sequence of\nphases layered on top of the event's own timeline, used to scale milestone\nrewards during \"boosted\" windows:\n\n```ts\ninterface BonusWindowConfig {\n Schedule?: BonusWindowPhase[]; // ordered by Order; empty = disabled\n RepeatCycle?: boolean; // true: restart from phase 0 after the last phase\n MaxCycles?: number; // 0 = infinite (bounded only by the event's own end)\n}\n\ninterface BonusWindowPhase {\n Order: number; // 0-based, unique within Schedule\n Type: \"Cooldown\" | \"Bonus\" | \"MultipliedBonus\";\n DurationSec: number; // must be > 0\n BonusMultiplier?: number; // MultipliedBonus only; default 1.5\n}\n```\n\n(`TimedEventDefinitions.cs:315-395`)\n\nComputed per-request (never stored) by `BonusWindowHelpers.ComputePhase`\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Services/BonusWindowHelpers.cs:29-75`),\nanchored at the **event/phase's own start time** — so the phase schedule is\nidentical for every player and simply depends on wall-clock time since that\nstart:\n\n```ts\ninterface BonusWindowState {\n IsActive: boolean; // true only during Bonus/MultipliedBonus phases\n CurrentPhaseEndUtc: string;\n NextBonusStartUtc?: string; // null = no more bonus phases will occur\n CurrentCycleIndex: number; // 0-based pass through the whole Schedule\n CurrentPhaseIndex: number; // the active phase's Order\n ActiveBonusMultiplier: number; // Bonus=1.0, MultipliedBonus=phase.BonusMultiplier, else 0\n}\n```\n\n`ComputePhase` returns `null` when: `BonusWindow` is `null`/has an empty\n`Schedule`, the event hasn't started yet, or all cycles are exhausted\n(`RepeatCycle=false` and the one pass already completed, or `MaxCycles`\nreached) — treat a `null` `ActiveEventInfo.BonusWindow` as \"no boosted\nrewards available,\" not an error.\n\nAt claim time, the server independently recomputes the same\n`BonusWindowState` for the resolved instance's own `StartUtc`\n(`TimedEvent.cs:602-617`) — it is never trusted from a prior client read —\nand if `IsActive`, merges `MilestoneDefinition.BonusRewards` into the base\n`Rewards` via `MilestoneRewardResolver`/`BonusWindowHelpers.MergeRewards`,\nscaling the bonus part by `ActiveBonusMultiplier` when the phase type is\n`MultipliedBonus` (`BonusWindowHelpers.cs:136-176`, entries rounded via\n`Math.Round`). You cannot predict the exact reward amount client-side when a\n`MultipliedBonus` phase is active mid-window — read it from the claim\nresponse's `Rewards`.\n\n---\n\n## Token sources, matching, and grant math\n\nA grant (`grantTokens`/`grantTokensBatch`, and internally for board/quest/\nstore/marketplace triggers) resolves as follows\n(`GrantTokensInternal`, `TimedEvent.cs:273-435`):\n\n1. Resolve the target instance (current active, unless a chain ref supplies\n `CycleIndex`+`ChainedEventID`). Fails `\"Event not found or not active.\"`\n if unresolved, `\"Earning is not allowed.\"` if `CanEarn` is false.\n2. `TriggerMatcher.FindMatch` walks `Content.TokenSources` in order and\n returns the first `TriggerSource` whose `SourceType` matches and whose\n filters all pass (AND-ed) — see `TriggerMatcher.cs:25-59` for the exact\n per-`SourceType` filter rules (`BoardTileLanding` checks\n `TileTypeFilter`/`TileIndexFilter`/`ChanceOutcomeFilter`; `CustomAction`\n checks `Params[\"ActionName\"]`; `MarketplaceSell`/`MarketplaceBuy` check\n `Params[\"CatalogID\"]`/`[\"ItemID\"]`/`[\"OfferType\"]`; `OutcomeFilter` is\n checked for every source type). No match ⇒ `\"Source '<type>' is not\nallowed for this event.\"`.\n3. `baseAmount = amountOverride ?? source.BaseWeight`; must be `> 0` else\n `\"Base amount must be > 0.\"`.\n4. `adjustedAmount = ModifierService.Apply(baseAmount, ctx).FinalValue` where\n `ctx` only carries the roll multiplier, and only if\n `source.ScaleWithRollMultiplier` is true (`TimedEvent.cs:350-353`).\n5. `EventTokenService.ComputeGrant` (`EventTokenService.cs:156-305`) applies,\n **in order**: `DailyEarnCap` (global daily total) →\n `DailyCapFromSource`/`source.Limits.DailyWeightCap` (per-source daily\n amount) → `DailyTriggerCap`/`source.Limits.DailyCap` (per-source daily\n trigger _count_) → `CooldownSeconds` (per-source, not reset daily) →\n `MaxBalance` (spendable balance ceiling) — any of these can reject the\n grant outright (`EventTokenGrantFailure` reason string). If accepted, the\n amount is then **clamped** (not rejected) by `MaxPerGrant`, remaining\n daily headroom, and remaining balance headroom, in that order\n (`EventTokenService.cs:205-224`) — so a grant can silently apply for less\n than requested near a cap, rather than failing.\n\n`BuildBoardTokenOperations`/`BuildMarketplaceTokenOperations`\n(`TimedEvent.cs:900-995`) are the server-internal helpers other modules\n(GameLoop, Marketplace) use to fan a single gameplay action out to every\nmatching active event — not something client code calls directly, but useful\ncontext for why a single board roll can grant several different event\ntokens at once.\n\n---\n\n## Server-side limits, batching, and idempotency\n\n- **Max batch size: 50** entries per call (`BatchSupport.MaxBatchSize`,\n `IDosGamesSDK/API/Client/v2/_Shared/BatchSupport.cs:35`), enforced\n identically for `ClaimMilestonesBatch`, `SpendTokensBatch`, and\n `GrantTokensBatch` (`TimedEvent.cs:1200,1306,1475,1583`). Entries beyond 50\n are silently dropped during normalization — they never appear in the\n response at all, so chunk larger sets into multiple calls yourself.\n- **Dedup**: `ClaimMilestonesBatch` dedupes by `(instance key)|(MilestoneID)`\n (`TimedEvent.cs:1195-1201`); `SpendTokensBatch` dedupes by instance key\n (`TimedEvent.cs:1470-1477`); `GrantTokensBatch` dedupes by\n `(instance key)|SourceType|Outcome` on input, **and separately rejects a\n second grant to the same resolved token address** within one batch with\n `\"Duplicate event instance in grant batch — send it as a separate\nrequest.\"` (`TimedEvent.cs:1634-1637`) because two grants to one address\n in the same Mongo update would conflict.\n- **Atomicity**: each batch call resolves every entry, then applies **one**\n atomic `ResourceService.ApplyResourceOperationAtomicAsync` for the whole\n batch. For `SpendTokensBatch`/`GrantTokensBatch` this means the _entire_\n batch's resource change succeeds or fails together — a single\n insufficient-balance/over-cap item fails the whole apply and every\n successfully-resolved item in that batch reports the same `Error`\n (`TimedEvent.cs:1518-1552,1659-1695`). Items that failed to even _resolve_\n (bad instance ref, unknown milestone, claim-mode gate) are filtered out\n **before** the atomic apply and get their own independent preset error —\n those don't block the rest of the batch.\n `ClaimMilestonesBatch`/`ClaimAllMilestones` are slightly more granular:\n milestones are grouped **per resolved token address** so multiple\n milestones on the _same_ event instance share one `$push`, but the\n token-threshold/already-claimed check\n (`EventTokenService.ComputeMilestoneClaimBatch`) still runs per address\n before the shared atomic apply, so a milestone that fails its own\n threshold/already-claimed check is rejected independently of the others\n (`TimedEvent.cs:1372-1413`).\n- **Idempotency (`reason` / `RelatedEntityID`)**: every mutating call passes\n a `reason` string to `ApplyResourceOperationAtomicAsync` built from the\n action, the resolved `Type`+`EntityID` (and `MilestoneID`/`sourceKey`\n where relevant), and — for single-item calls — the caller's optional\n `RelatedEntityID` folded in via `ResourceService.ResolveRelatedEntityID`\n (e.g. `\"SpendTokens:spend_{Type}_{EntityID}_{RelatedEntityID}\"`,\n `TimedEvent.cs:484-489,619-624,405-413`). Including `Type` guards against a\n `Scheduled` and `Chained` event that happen to share an `LteID`; including\n the instance-keyed `EntityID` guards against collisions across chain\n instances or across unrelated events reusing the same `RelatedEntityID`\n string (e.g. `\"roll_42\"`). Batch calls build one shared reason from all\n included item keys (`BatchSupport.BuildBatchReason`) rather than one per\n item.\n- **Where `Resources` live in batch responses**: for `SpendTokensBatch`\n /`GrantTokensBatch`, the single merged `ResourceOperation` from the one\n atomic apply is attached to the **first successfully-applied item only**\n (`attached` flag, `TimedEvent.cs:1536-1552,1677-1693`) — every other\n successful item in that batch gets an **empty** `ResourceOperation` in its\n `Data.Resources`. The SDK's `spendTokensBatch`/`grantTokensBatch` already\n account for this: they scan for the first item with a non-empty\n `Resources` and apply that once to the cache\n (`TimedEventService.ts:209-221,238-250`) — don't assume every batch item\n carries its own independent `Resources`/`Rewards` payload; read cache\n balances after the call instead of summing per-item deltas.\n- **Rate limit**: the v2 pipeline's per-IP endpoint limit for\n `TimedEventV2` is 500 ms (`RateLimitMilliseconds`,\n `TimedEvent.cs:18`); per-user/action transaction lock is 10 s\n (`LockDurationMilliseconds`, `TimedEvent.cs:19`). The SDK's own client-side\n throttle is a separate, smaller 600 ms guard per endpoint\n (`packages/core/src/transport/throttle.ts:4`, `DEFAULT_THROTTLE_MS`).\n"
|
|
8
|
+
"content": "# Timed-event data model — reference\n\nFull shape of the config (Definitions) and player state, the composite\nevent-token key scheme, the milestone self-heal rule, grace-window math, and\nthe bonus-window model. All of these are **strictly typed in the SDK** —\n`TimedEventDefinitions` and every nested block (`TimedEventDefinition`,\n`ChainedEventDefinition`, `EventContent`, `BonusWindowConfig`,\n`ActiveEventInfo`, …) are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<TimedEventDefinitions>(\"TimedEvent\")` give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\nEvery claim in this file traces to a specific backend source line — cited\ninline as `(file:line)` against the iDos_Games_Engine repo.\n\n## Contents\n\n- [Config: TimedEventDefinitions](#config-timedeventdefinitions)\n- [TimedEventDefinition (Scheduled vs Chained)](#timedeventdefinition-scheduled-vs-chained)\n- [EventContent](#eventcontent)\n- [Player state: UserEventTokenProgress](#player-state-usereventtokenprogress)\n- [ActiveEventInfo (getActiveEvents response)](#activeeventinfo-getactiveevents-response)\n- [The composite instance-key scheme](#the-composite-instance-key-scheme)\n- [Grace windows and claim-only instances](#grace-windows-and-claim-only-instances)\n- [Milestone claim rules and the self-heal on read](#milestone-claim-rules-and-the-self-heal-on-read)\n- [Bonus window (Coin-Master-style)](#bonus-window-coin-master-style)\n- [Token sources, matching, and grant math](#token-sources-matching-and-grant-math)\n- [Server-side limits, batching, and idempotency](#server-side-limits-batching-and-idempotency)\n\n---\n\n## Config: TimedEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\")`.\n\n```ts\ninterface TimedEventDefinitions {\n Definitions?: Record<string, TimedEventDefinition>; // key = TimedEventID\n Settings?: LimitedTimeEventsGlobalSettings;\n}\n\ninterface LimitedTimeEventsGlobalSettings {\n MaxConcurrentEvents?: number; // config-mistake guard; default 5\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/TimedEventDefinitions.cs:27-58`)\n\n---\n\n## TimedEventDefinition (Scheduled vs Chained)\n\nOne dictionary holds both kinds; the mode lives in `Schedule.Mode`.\n\n```ts\ninterface TimedEventDefinition {\n TimedEventID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode: \"Scheduled\" | \"Chained\"\n Content?: EventContent; // used when Mode = Scheduled\n Events?: ChainedEventDefinition[]; // used when Mode = Chained\n Gate?: SegmentGate; // audience gate; null = everyone\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:72-130`)\n\n- **Scheduled**: one fixed window (`Schedule.Scheduled: ScheduledWindow` —\n `StartUtc`, `EndUtc`, `AllowEarningAfterEnd`, `ClaimGraceHours`). Content\n lives directly on `Content`.\n- **Chained**: a repeating ordered list of phases (`Events`), timed by\n `Schedule.Chain: ScheduleChain` (`AnchorUtc`, `MaxCycles`,\n `PauseBetweenPhasesSec`, `PauseBetweenCyclesSec`). Each phase has its own\n `Content`. After the last phase, the whole cycle restarts from phase 0\n (unless `MaxCycles` caps the number of repeats).\n\n```ts\ninterface ChainedEventDefinition {\n ChainedEventID?: string; // unique within the chain\n Order?: number; // 0-based position; defines phase sequence\n DurationSec?: number;\n Content?: EventContent;\n ClaimGraceHours?: number; // 0 = no claiming once this phase ends\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:138-180`)\n\n`Gate` is the standard `SegmentGate` (Core/Segment) — `Segments`,\n`MinPremiumTier`, `RequiredPremiumIDs`, `MinLevel`/`MaxLevel`, `Countries`,\n`RegisteredWithinDays`, `ActiveWithinDays`, `Experiment`. A player failing the\ngate does not see the event in `getActiveEvents()` and cannot earn or spend\nits tokens — `GrantTokensInternal` re-checks the gate server-side even if a\nstale client tries to call it directly\n(`IDosGamesSDK/API/Client/v2/TimedEvent/TimedEvent.cs:325-329`).\n\n---\n\n## EventContent\n\nShared shape used by both a `Scheduled` event's `Content` and each\n`ChainedEventDefinition.Content`.\n\n```ts\ninterface EventContent {\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Category?: string; // free-form UI grouping tag\n Token?: EventTokenDefinition; // the event token's own config\n TokenSources?: TriggerSource[]; // whitelist of what earns this token\n ClaimMode?: \"Instant\" | \"AfterEventEnd\" | \"FeaturedAfterEnd\";\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID\n BonusWindow?: BonusWindowConfig; // null = disabled for this event\n}\n```\n\n(`TimedEventDefinitions.cs:192-280`, `Core/Milestone/Models/MilestoneClaimMode.cs:14-36`)\n\n`EventTokenDefinition` (`_shared/EventTokenDefinitionModels.ts`, port of\n`Core/Event/Models/EventTokenModels.cs:399-453`):\n\n```ts\ninterface EventTokenDefinition {\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxBalance?: number; // 0 = unlimited spendable balance cap\n MaxPerGrant?: number; // per-grant clamp; default 1000 server-side\n DailyEarnCap?: number; // 0 = unlimited daily earn total\n BurnOnEventEnd?: boolean; // default true — balance zeroed at event end\n BurnConversion?: EventTokenConversion; // optional leftover→currency conversion\n}\n```\n\n`MilestoneDefinition` is the shared Core/Milestone primitive (also used by\nLeaderboard/Quest/CommunityChest/DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against Balance.TotalEarned\n Rewards?: ResourceGrant; // base reward\n BonusRewards?: ResourceGrant; // added/scaled in during an active bonus window\n SeasonTierRewards?: SeasonTierRewardSet; // not used by TimedEvent\n SortOrder?: number;\n IsFeatured?: boolean; // gates FeaturedAfterEnd behavior\n}\n```\n\n(`Core/Milestone/Models/MilestoneDefinition.cs` via `_shared/MilestoneModels.ts:125-138`)\n\n`TriggerSource` (shared `_shared/ScheduleModels.ts:83-96`, port of\n`Core/Scheduling/Models/TriggerSource.cs`):\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // EventTokenSourceType, e.g. \"BoardTileLanding\"\n BaseWeight?: number; // tokens granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's roll multiplier\n TileTypeFilter?: string[]; // BoardTileLanding only; empty = any\n TileIndexFilter?: number[]; // BoardTileLanding only; empty = any\n ChanceOutcomeFilter?: string[]; // BoardTileLanding Chance tiles only; empty = any\n OutcomeFilter?: string[]; // checked for every source type; empty = any\n Params?: Record<string, string>; // CustomAction: ActionName; Marketplace*: CatalogID/ItemID/OfferType\n Limits?: LimitSpec; // DailyCap / DailyWeightCap / CooldownSeconds\n}\n```\n\n---\n\n## Player state: UserEventTokenProgress\n\nReturned inside `getUserLteState()`'s `Tokens` map and inside each\n`ActiveEventInfo.Progress`.\n\n```ts\ninterface UserEventTokenProgress {\n Balance?: {\n Current: number; // spendable balance; rises on grant, falls on spend\n TotalEarned: number; // lifetime earned in THIS instance; monotonic; milestone math uses this\n TotalSpent: number; // lifetime spent in this instance; analytics only\n };\n Daily?: {\n Date: string; // UTC date the counters below apply to; lazy-reset on next grant\n TotalEarned: number;\n EarnedBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyWeightCap\n TriggersBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyCap\n LastTriggerBySource?: Record<string, string>; // vs TriggerSource.Limits.CooldownSeconds; NOT reset daily\n };\n Meta?: {\n JoinedAtUtc?: string; // first grant into this instance's bucket\n LastEarnedAtUtc?: string;\n };\n Milestone?: {\n ClaimedIDs?: string[];\n UnlockedIDs?: string[]; // reached but not yet claimable under AfterEventEnd/FeaturedAfterEnd\n };\n}\n```\n\n(`Core/Event/Models/EventTokenModels.cs:51-179`, mirrored in SDK\n`_shared/EventTokenState.ts:8-38`)\n\nImportant: **spending tokens never affects `TotalEarned`**\n(`EventTokenService.ComputeSpend`, `EventTokenService.cs:311-337` only\ntouches `Balance.Current`/`Balance.TotalSpent`), so a milestone earned and\nthen \"un-afforded\" by spending remains claimable/claimed — milestones track\nlifetime earning, not current balance.\n\n---\n\n## ActiveEventInfo (getActiveEvents response)\n\n```ts\ninterface ActiveEventInfo {\n Type?: \"Scheduled\" | \"Chained\";\n TimedEventID?: string;\n CurrentChainedEventID?: string | null; // null for Scheduled\n Content?: EventContent | null; // resolved content for the current/ended instance\n Progress?: UserEventTokenProgress | null;\n ComputedStartUtc?: string | null;\n ComputedEndUtc?: string | null;\n CanEarn?: boolean | null; // tokens can still be granted for this instance\n CanClaim?: boolean | null; // still inside claim/grace window\n NextMilestone?: MilestoneDefinition | null; // lowest RequiredProgress not yet in ClaimedIDs\n BonusWindow?: BonusWindowState | null; // computed; null = no window / disabled\n CurrentCycleIndex?: number | null; // Chained only\n CurrentEventOrder?: number | null; // Chained only: 1-based position... (see note)\n TotalEventsInChain?: number | null; // Chained only\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/UserTimedEventState.cs:23-78`)\n\nNote: the backend populates `CurrentEventOrder` from\n`ChainedEventDefinition.Order`, which is documented as 0-based\n(`TimedEventDefinitions.cs:148-152`) — the SDK's own doc-comment calling it\n\"1-based\" is aspirational UI framing, not a code guarantee; treat it as \"the\nphase's configured `Order` value\" and don't assume it starts at 1.\n\n`getActiveEvents()` can return **more than one `ActiveEventInfo` for the same\n`Chained` `TimedEventID`** in a single response: the currently active phase,\nplus any phase(s) that already ended but are still inside their\n`ClaimGraceHours` window (`CanEarn: false`, `CanClaim: true`)\n(`TimedEvent.cs:149-169`, `EnumerateEndedInGraceChainInstances`,\n`TimedEvent.cs:801-836`). Disambiguate them by `CurrentCycleIndex` +\n`CurrentChainedEventID`.\n\n---\n\n## The composite instance-key scheme\n\nEvery event **instance** — not just every event — gets its own progress\nbucket, milestone-claimed list, and (for chains) bonus-window timeline. The\nbucket key (`EventTokenAddress.EntityID`, stored under\n`UserDataDocument.EventToken.TimedEvent[EntityID]`) is:\n\n```\nEntityID = \"{TimedEventID}:{InstanceKey}\"\n```\n\n(`TimedEvent.cs:1044-1057`, `BuildTokenAddress`)\n\nWhere `InstanceKey` depends on the resolved mode\n(`Core/Scheduling/Services/ScheduleInstanceKey.cs:14-23`):\n\n| Mode | `InstanceKey` format | Example |\n| ----------- | ------------------------------ | --------------- |\n| `AlwaysOn` | `\"all\"` | `all` |\n| `Scheduled` | `\"s{yyyyMMddHHmm}\"` (StartUtc) | `s202607010000` |\n| `Chained` | `\"{cycleIndex}:{phaseID}\"` | `4:boss_phase` |\n\nSo a `Scheduled` event's `EntityID` is effectively\n`\"summer_sale:s202607010000\"`, and a `Chained` event's is\n`\"raid_rotation:4:boss_phase\"`. This is why re-running the same\n`TimedEventID` (a new Scheduled window with a different `StartUtc`, or the\nnext chain cycle) starts every player at a fresh `Balance`/`Milestone`\nbucket — nothing carries over, by design.\n\n### Addressing an event from title config (short form)\n\nThe composite key above is a **runtime** address — the cycle index and the\nwindow start are unknowable when a reward is authored. So a reward written in\ntitle config (a Special-mode choice on the board, a store offer, a quest\npayout…) addresses the event by name instead:\n\n| `Address.EntityID` in config | Meaning |\n| ---------------------------- | ------------------------------------------------------------ |\n| `\"raid_rotation\"` | whichever instance of that event is live at grant time |\n| `\"raid_rotation:boss_phase\"` | that chain phase, current cycle — skipped when it isn't live |\n\nThe backend expands it right before the grant (`EventTokenAddressResolver`,\ncalled from `ResourceService`), stamping the instance suffix that is active at\nthat moment. A grant whose event is paused, off, or currently in another phase\nis dropped rather than written to a bucket nobody reads; a _consume_ keeps the\nshort address so the price can never silently become free. Already-composite\naddresses pass through untouched, so this is safe to re-apply.\n\nThe SDK's `UserTimedEventStateResponse.Tokens` map uses these same composite\nkeys. Cache helpers that need to find \"the bucket for this `LteID`, whatever\nits current instance suffix is\" use `matchesBase(key, lteID)`\n(`packages/core/src/util/eventTokenIds.ts:4-6`): a key belongs to a base id\nif it equals it exactly or starts with `\"{lteID}:\"`. `getUserLteState()` is a\nflat dump of every bucket the player has ever touched (including stale\nfinished instances) — don't assume one entry per `LteID`.\n\n---\n\n## Grace windows and claim-only instances\n\nOnce an instance's window ends, tokens can no longer be earned\n(`CanEarn` flips to `false`), but the milestone rewards already reached can\nstill be claimed until a grace deadline:\n\n```\nClaimDeadlineUtc = EndUtc + ClaimGraceHours\n```\n\n- `Scheduled`: `ClaimGraceHours` comes from `Schedule.Scheduled.ClaimGraceHours`\n (`TimedEvent.cs:728`). `AllowEarningAfterEnd` (also on `ScheduledWindow`)\n lets earning continue past `EndUtc` if set — independent of the grace\n window, which only governs _claiming_.\n- `Chained`: `ClaimGraceHours` comes from the specific\n `ChainedEventDefinition.ClaimGraceHours` (`TimedEvent.cs:718,773,826`) —\n each phase can have its own grace period. `AllowEarningAfterEnd` is always\n `false` for chain phases (`TimedEvent.cs:719`) — earning always stops the\n instant the phase ends.\n- `now > ClaimDeadlineUtc` ⇒ the instance is gone entirely: `ResolveScheduled`\n / `ScheduleResolver.ResolveChainInstance` return `null`\n (`Core/Scheduling/Services/ScheduleResolver.cs:127-145,361-406`), and any\n spend/grant/claim call against it fails with `\"Event not found or not\nactive.\"` / `\"...not in claim window.\"`.\n\n`EnumerateEndedInGraceChainInstances` walks backward through past chain\ncycles (hard-capped at 200 lookback instances,\n`ScheduleResolver.cs:414-484`) collecting every phase whose\n`now ∈ (EndUtc, EndUtc + ClaimGraceHours]`, **only for instances where the\nplayer has existing progress** (`TimedEvent.cs:156-159` — buckets with no\nprogress are skipped, so a phase the player never touched doesn't clutter\nthe active-events list). These are returned with `CanEarn: false,\nCanClaim: true` and must be addressed by their own `CycleIndex` +\n`ChainedEventID` when spending/claiming (`ResolveEventFromArgs`,\n`TimedEvent.cs:672-686`, only takes the explicit-instance path when **both**\n`CycleIndex` and `ChainedEventID` are supplied — omitting either resolves to\nwhatever instance is currently active instead).\n\n---\n\n## Milestone claim rules and the self-heal on read\n\n**Claim gate** (`ClaimMilestone`, `TimedEvent.cs:518-658`, and the batch\npaths mirror this via `CheckMilestoneClaimMode`, `TimedEvent.cs:1767-1775`):\n\n1. The resolved instance must have `CanClaim: true` (inside its window or\n grace), else `\"Claim window has expired.\"`.\n2. The milestone id must exist in the resolved content's `Milestones`, else\n `\"Milestone '<id>' not found.\"`.\n3. `Content.ClaimMode` gate:\n - `Instant` — always allowed once reached.\n - `AfterEventEnd` — rejected with `\"Milestone can only be claimed after\nevent ends.\"` until `now > EndUtc`.\n - `FeaturedAfterEnd` — same rejection (`\"Featured milestone can only be\nclaimed after event ends.\"`) but **only** when `MilestoneDefinition.IsFeatured\n=== true`; non-featured milestones under this mode behave like `Instant`.\n4. `EventTokenService.ComputeMilestoneClaim` (`EventTokenService.cs:343-366`):\n fails with `\"No progress for this event token.\"` if the bucket doesn't\n exist at all, `\"Not enough earned. Have: {X}, need: {Y}.\"` if\n `Balance.TotalEarned < RequiredProgress`, or `\"Milestone already\nclaimed.\"` if the id is already in `ClaimedIDs`.\n\n**Self-heal on `GetActiveEvents` read** (`SanitizeMilestoneState`,\n`TimedEvent.cs:1070-1131`, invoked from `BuildActiveEventInfo` at\n`TimedEvent.cs:1143` and staged as background `$pullAll` patches at\n`TimedEvent.cs:112-187`):\n\n- Trigger condition: for the **specific instance bucket being read**, any id\n present in that bucket's `Milestone.ClaimedIDs` or `Milestone.UnlockedIDs`\n whose corresponding `MilestoneDefinition.RequiredProgress` is **greater\n than that same bucket's own `Balance.TotalEarned`** is stale. An id with no\n matching entry in the resolved content's `Milestones` dictionary is also\n stripped (nothing to verify it against). The check is\n `totalEarned >= def.RequiredProgress` per id\n (`TimedEvent.cs:1076-1080`, local function `Reached`).\n- Why it's safe: `TotalEarned` is monotonically non-decreasing\n (`EventTokenService.ComputeGrant` only ever increments it,\n `EventTokenService.cs:226,271,283`), so a milestone legitimately claimed\n (earned had already reached the threshold _at claim time_) can never later\n have `TotalEarned` fall back below `RequiredProgress`. The only ids this\n can strip are ones inconsistent with their own bucket's recorded earnings\n — e.g. leftover data from before per-instance keying was introduced, not\n anything a normal claim flow can produce.\n- Effect: the returned `ActiveEventInfo.Progress.Milestone.ClaimedIDs`/\n `UnlockedIDs` (and therefore `NextMilestone`, which is computed from the\n sanitized `ClaimedIDs`) are already clean in the response you receive — you\n never see the stale ids. Separately, the same removals are persisted to\n the DB via `$pullAll` on `{entryPath}.Milestone.ClaimedIDs` /\n `...UnlockedIDs` (`TimedEvent.cs:1114-1131`) so the fix is permanent; this\n DB write is best-effort and wrapped in a swallowed try/catch\n (`TimedEvent.cs:177-187`) — a failed cleanup simply retries on the next\n `GetActiveEvents` call and never fails the read itself.\n- This only runs from `GetActiveEvents` (both the currently-active-instance\n path and the ended-in-grace path) — `GetUserLteState` returns the raw\n bucket as stored, unsanitized, which is one more reason to treat it as a\n secondary/debug view rather than the milestone UI's source of truth.\n\n---\n\n## Bonus window (Coin-Master-style)\n\n`EventContent.BonusWindow` (nullable) describes a repeating sequence of\nphases layered on top of the event's own timeline, used to scale milestone\nrewards during \"boosted\" windows:\n\n```ts\ninterface BonusWindowConfig {\n Schedule?: BonusWindowPhase[]; // ordered by Order; empty = disabled\n RepeatCycle?: boolean; // true: restart from phase 0 after the last phase\n MaxCycles?: number; // 0 = infinite (bounded only by the event's own end)\n}\n\ninterface BonusWindowPhase {\n Order: number; // 0-based, unique within Schedule\n Type: \"Cooldown\" | \"Bonus\" | \"MultipliedBonus\";\n DurationSec: number; // must be > 0\n BonusMultiplier?: number; // MultipliedBonus only; default 1.5\n}\n```\n\n(`TimedEventDefinitions.cs:315-395`)\n\nComputed per-request (never stored) by `BonusWindowHelpers.ComputePhase`\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Services/BonusWindowHelpers.cs:29-75`),\nanchored at the **event/phase's own start time** — so the phase schedule is\nidentical for every player and simply depends on wall-clock time since that\nstart:\n\n```ts\ninterface BonusWindowState {\n IsActive: boolean; // true only during Bonus/MultipliedBonus phases\n CurrentPhaseEndUtc: string;\n NextBonusStartUtc?: string; // null = no more bonus phases will occur\n CurrentCycleIndex: number; // 0-based pass through the whole Schedule\n CurrentPhaseIndex: number; // the active phase's Order\n ActiveBonusMultiplier: number; // Bonus=1.0, MultipliedBonus=phase.BonusMultiplier, else 0\n}\n```\n\n`ComputePhase` returns `null` when: `BonusWindow` is `null`/has an empty\n`Schedule`, the event hasn't started yet, or all cycles are exhausted\n(`RepeatCycle=false` and the one pass already completed, or `MaxCycles`\nreached) — treat a `null` `ActiveEventInfo.BonusWindow` as \"no boosted\nrewards available,\" not an error.\n\nAt claim time, the server independently recomputes the same\n`BonusWindowState` for the resolved instance's own `StartUtc`\n(`TimedEvent.cs:602-617`) — it is never trusted from a prior client read —\nand if `IsActive`, merges `MilestoneDefinition.BonusRewards` into the base\n`Rewards` via `MilestoneRewardResolver`/`BonusWindowHelpers.MergeRewards`,\nscaling the bonus part by `ActiveBonusMultiplier` when the phase type is\n`MultipliedBonus` (`BonusWindowHelpers.cs:136-176`, entries rounded via\n`Math.Round`). You cannot predict the exact reward amount client-side when a\n`MultipliedBonus` phase is active mid-window — read it from the claim\nresponse's `Rewards`.\n\n---\n\n## Token sources, matching, and grant math\n\nA grant (`grantTokens`/`grantTokensBatch`, and internally for board/quest/\nstore/marketplace triggers) resolves as follows\n(`GrantTokensInternal`, `TimedEvent.cs:273-435`):\n\n1. Resolve the target instance (current active, unless a chain ref supplies\n `CycleIndex`+`ChainedEventID`). Fails `\"Event not found or not active.\"`\n if unresolved, `\"Earning is not allowed.\"` if `CanEarn` is false.\n2. `TriggerMatcher.FindMatch` walks `Content.TokenSources` in order and\n returns the first `TriggerSource` whose `SourceType` matches and whose\n filters all pass (AND-ed) — see `TriggerMatcher.cs:25-59` for the exact\n per-`SourceType` filter rules (`BoardTileLanding` checks\n `TileTypeFilter`/`TileIndexFilter`/`ChanceOutcomeFilter`; `CustomAction`\n checks `Params[\"ActionName\"]`; `MarketplaceSell`/`MarketplaceBuy` check\n `Params[\"CatalogID\"]`/`[\"ItemID\"]`/`[\"OfferType\"]`; `OutcomeFilter` is\n checked for every source type). No match ⇒ `\"Source '<type>' is not\nallowed for this event.\"`.\n3. `baseAmount = amountOverride ?? source.BaseWeight`; must be `> 0` else\n `\"Base amount must be > 0.\"`.\n4. `adjustedAmount = ModifierService.Apply(baseAmount, ctx).FinalValue` where\n `ctx` only carries the roll multiplier, and only if\n `source.ScaleWithRollMultiplier` is true (`TimedEvent.cs:350-353`).\n5. `EventTokenService.ComputeGrant` (`EventTokenService.cs:156-305`) applies,\n **in order**: `DailyEarnCap` (global daily total) →\n `DailyCapFromSource`/`source.Limits.DailyWeightCap` (per-source daily\n amount) → `DailyTriggerCap`/`source.Limits.DailyCap` (per-source daily\n trigger _count_) → `CooldownSeconds` (per-source, not reset daily) →\n `MaxBalance` (spendable balance ceiling) — any of these can reject the\n grant outright (`EventTokenGrantFailure` reason string). If accepted, the\n amount is then **clamped** (not rejected) by `MaxPerGrant`, remaining\n daily headroom, and remaining balance headroom, in that order\n (`EventTokenService.cs:205-224`) — so a grant can silently apply for less\n than requested near a cap, rather than failing.\n\n`BuildBoardTokenOperations`/`BuildMarketplaceTokenOperations`\n(`TimedEvent.cs:900-995`) are the server-internal helpers other modules\n(GameLoop, Marketplace) use to fan a single gameplay action out to every\nmatching active event — not something client code calls directly, but useful\ncontext for why a single board roll can grant several different event\ntokens at once.\n\n---\n\n## Server-side limits, batching, and idempotency\n\n- **Max batch size: 50** entries per call (`BatchSupport.MaxBatchSize`,\n `IDosGamesSDK/API/Client/v2/_Shared/BatchSupport.cs:35`), enforced\n identically for `ClaimMilestonesBatch`, `SpendTokensBatch`, and\n `GrantTokensBatch` (`TimedEvent.cs:1200,1306,1475,1583`). Entries beyond 50\n are silently dropped during normalization — they never appear in the\n response at all, so chunk larger sets into multiple calls yourself.\n- **Dedup**: `ClaimMilestonesBatch` dedupes by `(instance key)|(MilestoneID)`\n (`TimedEvent.cs:1195-1201`); `SpendTokensBatch` dedupes by instance key\n (`TimedEvent.cs:1470-1477`); `GrantTokensBatch` dedupes by\n `(instance key)|SourceType|Outcome` on input, **and separately rejects a\n second grant to the same resolved token address** within one batch with\n `\"Duplicate event instance in grant batch — send it as a separate\nrequest.\"` (`TimedEvent.cs:1634-1637`) because two grants to one address\n in the same Mongo update would conflict.\n- **Atomicity**: each batch call resolves every entry, then applies **one**\n atomic `ResourceService.ApplyResourceOperationAtomicAsync` for the whole\n batch. For `SpendTokensBatch`/`GrantTokensBatch` this means the _entire_\n batch's resource change succeeds or fails together — a single\n insufficient-balance/over-cap item fails the whole apply and every\n successfully-resolved item in that batch reports the same `Error`\n (`TimedEvent.cs:1518-1552,1659-1695`). Items that failed to even _resolve_\n (bad instance ref, unknown milestone, claim-mode gate) are filtered out\n **before** the atomic apply and get their own independent preset error —\n those don't block the rest of the batch.\n `ClaimMilestonesBatch`/`ClaimAllMilestones` are slightly more granular:\n milestones are grouped **per resolved token address** so multiple\n milestones on the _same_ event instance share one `$push`, but the\n token-threshold/already-claimed check\n (`EventTokenService.ComputeMilestoneClaimBatch`) still runs per address\n before the shared atomic apply, so a milestone that fails its own\n threshold/already-claimed check is rejected independently of the others\n (`TimedEvent.cs:1372-1413`).\n- **Idempotency (`reason` / `RelatedEntityID`)**: every mutating call passes\n a `reason` string to `ApplyResourceOperationAtomicAsync` built from the\n action, the resolved `Type`+`EntityID` (and `MilestoneID`/`sourceKey`\n where relevant), and — for single-item calls — the caller's optional\n `RelatedEntityID` folded in via `ResourceService.ResolveRelatedEntityID`\n (e.g. `\"SpendTokens:spend_{Type}_{EntityID}_{RelatedEntityID}\"`,\n `TimedEvent.cs:484-489,619-624,405-413`). Including `Type` guards against a\n `Scheduled` and `Chained` event that happen to share an `LteID`; including\n the instance-keyed `EntityID` guards against collisions across chain\n instances or across unrelated events reusing the same `RelatedEntityID`\n string (e.g. `\"roll_42\"`). Batch calls build one shared reason from all\n included item keys (`BatchSupport.BuildBatchReason`) rather than one per\n item.\n- **Where `Resources` live in batch responses**: for `SpendTokensBatch`\n /`GrantTokensBatch`, the single merged `ResourceOperation` from the one\n atomic apply is attached to the **first successfully-applied item only**\n (`attached` flag, `TimedEvent.cs:1536-1552,1677-1693`) — every other\n successful item in that batch gets an **empty** `ResourceOperation` in its\n `Data.Resources`. The SDK's `spendTokensBatch`/`grantTokensBatch` already\n account for this: they scan for the first item with a non-empty\n `Resources` and apply that once to the cache\n (`TimedEventService.ts:209-221,238-250`) — don't assume every batch item\n carries its own independent `Resources`/`Rewards` payload; read cache\n balances after the call instead of summing per-item deltas.\n- **Rate limit**: the v2 pipeline's per-IP endpoint limit for\n `TimedEventV2` is 500 ms (`RateLimitMilliseconds`,\n `TimedEvent.cs:18`); per-user/action transaction lock is 10 s\n (`LockDurationMilliseconds`, `TimedEvent.cs:19`). The SDK's own client-side\n throttle is a separate, smaller 600 ms guard per endpoint\n (`packages/core/src/transport/throttle.ts:4`, `DEFAULT_THROTTLE_MS`).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tutorial-system",
|
|
3
|
+
"description": "Build an onboarding / tutorial system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.tutorial (TutorialService): load tutorial flow and step definitions, load the player's progress, start a flow, report a step as shown, complete or skip a step, skip a whole flow, claim the completion reward, and replay a flow. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a first-time user experience, onboarding, tutorial overlays, guided first session, coach marks, hint bubbles anchored to UI elements, a \"teach the player the board\" sequence, or otherwise touches client.tutorial, TutorialService, TutorialDefinitions, UserTutorialState, TutorialFlowView, or TutorialStepCompletionMode — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: tutorial-system\ndescription: >-\n Build an onboarding / tutorial system in a game on the iDosGames TypeScript\n SDK (@idosgames/core) via client.tutorial (TutorialService): load tutorial\n flow and step definitions, load the player's progress, start a flow, report a\n step as shown, complete or skip a step, skip a whole flow, claim the\n completion reward, and replay a flow. Use this whenever the user is working\n in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and\n wants a first-time user experience, onboarding, tutorial overlays, guided\n first session, coach marks, hint bubbles anchored to UI elements, a \"teach\n the player the board\" sequence, or otherwise touches client.tutorial,\n TutorialService, TutorialDefinitions, UserTutorialState, TutorialFlowView, or\n TutorialStepCompletionMode — even if they don't name the module explicitly.\n---\n\n# Tutorial system (iDosGames TS SDK)\n\nThe Tutorial module runs a title's onboarding: **flows** of ordered **steps**,\neach teaching one mechanic. Everything is **server-authoritative** — the\nbackend owns which step is current, when a step closes, what it unlocks and\nwhat it pays. The client asks, checks the result, and renders from the cache.\n\nThis skill is for **using** the production `TutorialService`, not for porting or\nextending it. A rejected call is the backend enforcing a rule (wrong step, flow\nnot running, step not skippable) — surface the error, don't reproduce the check\nclient-side.\n\n## The two things you must understand first\n\nAlmost every bug in tutorial UI comes from getting one of these wrong.\n\n### 1. Not every step is yours to close\n\nA step declares **how** it completes, in `CurrentStep.Completion.Mode`:\n\n| Mode | Who closes it | What your UI does |\n| ------------- | -------------------------------------- | --------------------------------------- |\n| `ClientAck` | you, via `completeStep` | show a **Next** button |\n| `Auto` | closes on being shown | just call `reportStepShown` |\n| `SystemEvent` | the **backend**, off a real game event | show the hint, show **no** button, wait |\n| `Composite` | the backend, several events | same as `SystemEvent` |\n\nCalling `completeStep` on a `SystemEvent` step is **refused by the server**, and\nthat refusal is deliberate: the step's whole point is that the player actually\nrolled the dice / bought the thing. If you wire a Next button to every step,\nyour \"make a roll\" step becomes a button that hands out its reward for free —\nand the backend will stop you, so the player sees an error instead of a\ntutorial.\n\n```ts\nconst mode = view.CurrentStep?.Completion?.Mode ?? \"ClientAck\";\nconst canTapNext = mode === \"ClientAck\";\n```\n\n### 2. Progress can arrive on a call you didn't make\n\nWhen a `SystemEvent` step advances, the backend attaches the progress to the\nresponse of **whatever action caused it** — the board roll, the purchase. The\nSDK applies it to the cache and emits an event. So:\n\n```ts\nclient.on(\"tutorial:systemProgress\", (updates) => {\n // updates: [{ FlowID, StepID, Progress, Target, Completed }]\n // re-render the overlay; if Completed, ask for fresh state to get the next step\n});\n```\n\n**Do not poll** `getUserTutorialState` in a loop waiting for a step to close.\nSubscribe. Polling is how you get a tutorial that lags a second behind the\naction it just asked for.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — `client.tutorial\n.getTutorialDefinitions()` → `TutorialDefinitions.Flows[flowID]` with its\n `Steps`, each carrying `Identity` (title, body, `AnchorID`), `Completion`,\n `Effects`, `Policy`.\n2. **State + view** (per player) — `client.tutorial.getUserTutorialState()` →\n `{ State, Flows, UnlockedFeatures }`. `Flows` is a list of\n **`TutorialFlowView`**, and this is what your UI should render from: it\n already carries `CurrentStep` (the definition), `TotalSteps`,\n `CompletedSteps`, `CanSkip`, `RewardPending`. You do not have to join the\n two shapes yourself.\n\n## Recipes\n\n### Show the current step\n\n```ts\nconst res = await client.tutorial.getUserTutorialState();\nif (!res.ok) return; // surface res.error\n\nconst active = res.data.Flows?.find((f) => f.Status === \"InProgress\");\nif (!active?.CurrentStep) return; // nothing to teach right now\n\nconst id = active.CurrentStep.Identity;\nshowHint({\n title: id?.TitleKey ? t(id.TitleKey) : (id?.Title ?? \"\"),\n body: id?.BodyKey ? t(id.BodyKey) : (id?.Body ?? \"\"),\n anchor: id?.AnchorID, // your UI element id\n highlight: id?.HighlightTarget ?? id?.AnchorID,\n showNext:\n (active.CurrentStep.Completion?.Mode ?? \"ClientAck\") === \"ClientAck\",\n canSkip: active.CanSkip,\n});\n\nawait client.tutorial.reportStepShown(active.FlowID, active.CurrentStepID!);\n```\n\n`reportStepShown` is worth calling for **every** step, not only `Auto` ones: it\nis what the funnel measures time-on-step from, and that is the number that tells\nthe publisher which step is losing players.\n\n### Advance\n\n```ts\n// Only for ClientAck steps.\nconst r = await client.tutorial.completeStep(flowID, stepID);\nif (r.ok) render(r.data.Flow); // the view already has the NEXT step\n```\n\nThe response carries the updated `TutorialFlowView`, so you do not need a state\nround-trip after a step. When `Flow.Status` becomes `Completed`, the flow is\ndone.\n\n### Skip\n\n```ts\nif (view.CanSkip) await client.tutorial.skipFlow(flowID); // whole flow\nawait client.tutorial.skipStep(flowID, stepID); // one optional step\n```\n\n`CanSkip` already accounts for both the flow policy and the current step's\norder — don't recompute it. Skipping pays nothing: a skipped step grants no\nreward, by design.\n\n### Claim the reward\n\n```ts\nif (view.RewardPending) {\n const r = await client.tutorial.claimFlowReward(flowID);\n if (r.ok) showPayout(r.data.Granted); // ResourceOperation\n}\n```\n\nA flow configured with `Reward.AutoClaim` pays out on its last step and never\nreports `RewardPending` — so gating your payout screen on that flag is correct\nfor both configurations.\n\n### Replay from a settings screen\n\n```ts\n// Listing tutorials must NOT start one. That is what the flag is for.\nconst res = await client.tutorial.getUserTutorialState(false);\n\nawait client.tutorial.resetFlow(flowID); // only if RestartPolicy allows it\n```\n\n`resetFlow` never re-grants the reward — the server keeps the claimed flag\nthrough the reset. Don't build a UI that promises otherwise.\n\n## Things the server does that you should not duplicate\n\n- **Ordering.** Steps are ordered by `Order`, then `StepID` — but you never\n need that: read `CurrentStep`. Trying to close step 3 while 2 is open is\n refused.\n- **Auto-start.** Flows marked for it begin on the player's first request. You\n do not call `startFlow` for them. Use `startFlow` only for a flow the player\n chose (a replay, a \"show me again\" button), or one with `AutoStart` off.\n- **Gating.** Which flow a player may see (audience, A/B variant,\n prerequisites, schedule) is decided server-side. If a flow is not in the\n `Flows` list, it is not for this player right now.\n- **Feature unlocks.** `UnlockedFeatures` is **advisory** — a list of labels\n the game may use to decide what to show. It is not enforcement. Anything the\n publisher truly gates is gated on the backend and will be refused there.\n- **Scripted outcomes.** A step may predetermine a game outcome (e.g. the board\n roll lands on a Raid tile so the hint isn't lying). This is invisible to you:\n the roll comes back as a normal result. Do not try to detect or replicate it.\n\n## A/B testing onboarding\n\nTwo flows, each bound in the dashboard to a different experiment variant. From\nthe client there is nothing to do — the player simply receives the flow for\ntheir variant. `TutorialFlowView.VariantID` tells you which one they got, which\nis useful for your own analytics events but must not change your rendering.\n\n## Failure handling\n\nEvery method returns `OperationResult<T>`. On `!ok`, `result.reason` is one of\n`client` / `unauthorized` / `connection` / `server` / `validation` /\n`throttled`, and `result.error` is the message. The messages that matter:\n\n- _\"is completed by a game event, not by the client\"_ — you wired a Next button\n to a `SystemEvent` step. See the table at the top.\n- _\"is not the current step\"_ — your cached view is stale; re-read state.\n- _\"Flow is not in progress\"_ — it finished, was skipped, or expired.\n- _\"Finish the '<flow>' tutorial first\"_ — a **different** module refused\n because a mandatory tutorial is unfinished. Send the player to the tutorial,\n don't show a generic error.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|