@idosgames/mcp 0.1.5 → 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 +1 -1
- package/registry/index.json +19 -15
- 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/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 +1 -1
- 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/referral-system.json +1 -1
- package/registry/skills/season-system.json +1 -1
- package/registry/skills/store-system.json +2 -2
- package/registry/skills/timed-boost-system.json +2 -2
- package/registry/skills/tutorial-system.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "season-system",
|
|
3
3
|
"description": "Build a season / battle-pass-style meta-progression system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService): load season chain definitions, fetch the currently active season in a chain, load the player's per-chain season state, grant status tokens (season XP/points) that advance a tier track, and claim a reached tier's reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a season pass, battle pass, status track, tier-reward system, seasonal meta-progression, or otherwise touches client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition, SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: season-system\ndescription: >-\n Build a season / battle-pass-style meta-progression system in a game on the\n iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService):\n load season chain definitions, fetch the currently active season in a chain,\n load the player's per-chain season state, grant status tokens (season\n XP/points) that advance a tier track, and claim a reached tier's reward. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a season pass, battle pass, status\n track, tier-reward system, seasonal meta-progression, or otherwise touches\n client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition,\n SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they\n don't name the module explicitly.\n---\n\n# Season system (iDosGames TS SDK)\n\nThe Season module is a battle-pass-style meta-progression track: a title\ndefines one or more **season chains**, each chain runs a sequence of\n**seasons** back to back (and cycles again after the last one), and each\nseason has a ladder of **tiers** the player climbs by earning **status\ntokens** (season XP/points). Reaching a tier unlocks that tier's reward, which\nthe player then claims. Everything is **server-authoritative**: the client\nasks the backend to grant tokens or claim a reward, the backend validates and\napplies it, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate season state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `SeasonService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(already claimed, tier not reached, wrong access mode, not logged in) —\nsurface the error, don't try to reproduce the check client-side.\n\n## The three data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n season chains: `SeasonDefinitions.Chains`, keyed by `SeasonChainID`. Each\n chain (`SeasonChainDefinition`) has a `Schedule`, an optional segment\n `Gate`, and an ordered list of `Seasons` (`SeasonDefinition`), each with a\n `DurationSec` and its own `Tiers` (`SeasonTierDefinition[]`). Fetched with\n `getDefinitions()`.\n2. **Active season info** (config + a state slice, per chain) — which season\n in the chain is live _right now_, its computed start/end, seconds\n remaining, and the next tier the player hasn't reached. Fetched per chain\n with `getActiveSeason(seasonChainID)`.\n3. **User season state** (state, per player, per chain) — this player's\n progress in one chain: `CurrentTier`, `ClaimedTierRewards`, which season\n version they're on. Fetched with `getUserState(seasonChainID)`, and also\n embedded in `ActiveSeasonInfo.UserState`.\n\nA season chain is identified by a string `SeasonChainID`; a season inside it\nby `SeasonID`; a tier by its plain `Tier` number, where `1` is the always-on\nbase tier (reached with 0 tokens). There's a single reward track per tier\n(`SeasonTierDefinition.TierReachedReward`) — no separate free/premium track\nsplit in this module.\n\n**Status tokens** are the season's XP/points currency, tracked internally\nthrough the same Core/EventToken ledger every other event-token currency\nuses. Calling `grantStatusTokens` adds an amount and the backend recomputes\n`CurrentTier` from the new cumulative total against the season's `Tiers`\nladder (`RequiredTokens` per tier — highest tier whose threshold is met\nwins). Granting is a distinct step from claiming — advancing a tier does not\nauto-claim its reward; the player (or your UI) calls `claimTierReward`\nseparately for each tier they want to collect.\n\nOnly `SeasonDefinitions` (the root config type) is re-exported from the\npackage root; `SeasonChainDefinition` / `SeasonDefinition` /\n`SeasonTierDefinition` are not directly importable — read them off the\nresolved `SeasonDefinitions` tree instead. See\n[references/data-model.md](references/data-model.md) for the full shape, the\nexact tier-threshold algorithm, season-rollover (\"Wipe\") semantics, and how\nthe season-tier reward overlay used by _other_ modules (Leaderboard, Reward,\nReferral, Quest milestones) relates to (and is separate from) this module's\nown `TierReachedReward`. You do **not** need it to call the methods — only to\ndrive richer UI or understand a cross-module reward scaling feature.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst seasons = client.season; // the SeasonService\n```\n\nEvery season method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing `seasonChainID` or a non-positive amount/tier\nnumber), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside\nthe throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"No active\nseason in this chain.\", \"Tier 3 not reached yet. Current tier: 2.\", \"Tier 3\nreward already claimed.\").\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- |\n| `getDefinitions()` | Load the title's season chain catalog (config). | `SeasonDefinitions` |\n| `getActiveSeason(seasonChainID)` | Load the chain's currently-live season + this player's state in it. | `ActiveSeasonInfo` |\n| `getUserState(seasonChainID)` | Load this player's progress in one chain (state only). | `UserSeasonStateResponse` (= `UserSeasonState`) |\n| `grantStatusTokens(seasonChainID, amount)` | Add status tokens (season XP/points); may bump `CurrentTier`. | `GrantStatusTokensResponse` (`NewTier`, `TierUp`) |\n| `claimTierReward(seasonChainID, tierNumber)` | Claim a reached tier's reward (one-time per tier). | `ClaimTierRewardResponse` (`Resources`) |\n| `claimTierRewardsBatch(seasonChainID, tierNumbers)` | Claim several tiers in ONE atomic call — one token grant can raise the player through several tiers at once, so more than one reward is often pending. Merged `Resources` at the TOP level; per-item `Data.Resources` is null. | `ClaimTierRewardsBatchResponse` |\n\nThere are no batch methods on this module — each call operates on one season\nchain (and, for claims, one tier) at a time.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `claimTierReward`'s\ngranted resources ride along in `data.Resources` (a shared `ResourceOperation`\n— see `packages/core/src/models/_shared/ResourceModels.ts`) and are already\napplied to the cached currency/item balances, so read updated balances\nstraight from the cache.\n\n**`grantStatusTokens` is access-gated per chain**, not just by auth. Each\nchain's config sets `GrantTokensAccessMode` (`\"ServerOnly\"` | `\"ClientOnly\"` |\n`\"Both\"`, default `\"ServerOnly\"`). If a chain is `\"ServerOnly\"` — the typical\nproduction setup for tokens that should only come from tournament results,\nmatch wins, or quest completion — the client-facing call is rejected outright\nwith `\"GrantStatusTokens cannot be called from client for this chain.\"` before\nit even looks at your amount. A rejection here usually means \"wrong access\nmode for this chain's design,\" not a bug in your integration.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { SeasonDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<SeasonDefinitions>(\"Season\");\nconst chain = defs?.Chains?.[\"battle_pass_main\"];\nchain?.Seasons; // ordered SeasonDefinition[] for this chain\n\n// Per-chain user state (present after getUserState()/getActiveSeason()/a grant/claim):\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\nstate?.CurrentTier; // highest tier reached\nstate?.ClaimedTierRewards; // number[] of tier numbers already claimed\nstate?.CurrentSeasonID; // which season within the chain\n```\n\nThere's no separate cached \"active season\" slot — `ActiveSeasonInfo` (the\nlive season, its `Tiers`, computed dates, `NextTier`) is only available from\nthe `getActiveSeason` call's own return value; only its embedded `UserState`\ngets written into `client.data.user.state.Season`. Keep the last\n`ActiveSeasonInfo` you fetched in your own component/store if you need to\nrender the ladder alongside cached progress.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `season:definitionsLoaded` → `SeasonDefinitions`\n- `season:activeLoaded` → `ActiveSeasonInfo`\n- `season:userStateLoaded` → `UserSeasonStateResponse`\n- `season:statusTokensGranted` → `GrantStatusTokensResponse`\n- `season:tierRewardClaimed` → `ClaimTierRewardResponse`\n\nThe coarse `user:seasonUpdated` (and `user:anyUpdated`) also fire on any\nseason cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"season:statusTokensGranted\", (r) => {\n if (r.TierUp) console.log(`Reached tier ${r.NewTier}!`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load a chain, show the ladder, and claim a reached tier\n\n```ts\nawait client.season.getDefinitions();\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (!active.ok) return showError(active.error); // e.g. \"No active season in this chain.\"\n\nconst { Season, NextTier, SecondsRemaining } = active.data;\nconst currentTier =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]?.CurrentTier ??\n 0;\nconst claimed =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.ClaimedTierRewards ?? [];\n\nfor (const tier of Season?.Tiers ?? []) {\n const reached = (tier.Tier ?? 0) <= currentTier;\n const alreadyClaimed = claimed.includes(tier.Tier ?? -1);\n // reached && !alreadyClaimed -> show a \"Claim\" button for this tier.\n}\n\nif (currentTier >= 1 && !claimed.includes(1)) {\n const res = await client.season.claimTierReward(\"battle_pass_main\", 1);\n if (!res.ok) return showError(res.error); // e.g. \"Tier 1 reward already claimed.\"\n // res.data.Resources already applied to cached balances.\n}\n```\n\n`getActiveSeason` fails with `\"No active season in this chain.\"` both when the\nchain is fully inactive/misconfigured and when the chain is legitimately\n**paused** between two chained seasons (a configured gap) — treat both as\n\"nothing to show right now,\" not as an error worth retrying aggressively.\n\n### Grant status tokens (season XP/points)\n\n```ts\nconst res = await client.season.grantStatusTokens(\"battle_pass_main\", 250);\nif (!res.ok) return showError(res.error);\n\nres.data.NewTier; // tier after this grant\nres.data.TierUp; // true if this grant crossed into a new tier\nres.data.NewStatusTokens; // running cumulative token total for the current season\nres.data.OldTier; // tier before this grant, for a \"leveled up from X to Y\" toast\n```\n\nOnly wire this to a client button if the chain's `GrantTokensAccessMode` is\n`\"ClientOnly\"` or `\"Both\"` — see the Methods section above. For a title that\nawards status tokens purely from server-side triggers (match results,\ntournament placements), this call has nothing to do and should not be\nexposed in the UI at all for that chain.\n\n### Just show progress toward the next tier\n\n```ts\nawait client.season.getUserState(\"battle_pass_main\");\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\n\n// Combine with a previously-fetched ActiveSeasonInfo for the ladder:\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n active.data.NextTier?.RequiredTokens; // tokens needed for the next tier\n active.data.NextTier?.Tier;\n // NextTier is null once the player has reached the season's highest tier.\n}\n```\n\n### Handle claim edge cases\n\n```ts\nconst res = await client.season.claimTierReward(\"battle_pass_main\", 3);\nif (!res.ok) {\n switch (res.reason) {\n case \"server\":\n // e.g. \"Tier 3 not reached yet. Current tier: 2.\" or\n // \"Tier 3 reward already claimed.\" — read res.error and toast it\n showError(res.error);\n break;\n case \"unauthorized\":\n // session expired — re-auth then retry\n break;\n case \"connection\":\n // transient — offer a Retry button\n break;\n default:\n showError(res.error);\n }\n return;\n}\n```\n\n### Handle a season rollover on relaunch\n\n```ts\n// After a client relaunch or a long idle gap, don't trust a stale cached\n// CurrentTier/ClaimedTierRewards — the chain may have advanced to its next\n// season (or a new cycle) since the player last called in, which triggers a\n// server-side reset (see references/data-model.md#season-rollover-wipe).\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n // active.data.UserState now reflects the CURRENT season; the cache under\n // client.data.user.state.Season.States[\"battle_pass_main\"] was refreshed\n // as a side effect of this call.\n const seasonID = active.data.Season?.SeasonID;\n const stateSeasonID =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.CurrentSeasonID;\n // stateSeasonID === seasonID confirms you're looking at fresh progress.\n}\n```\n\n## Gotchas\n\n- **Granting tokens and claiming a reward are separate steps.**\n `grantStatusTokens` only advances `CurrentTier`; it does not claim anything.\n Your UI must call `claimTierReward` per tier — don't assume a `TierUp: true`\n response means the reward already landed in inventory.\n- **`grantStatusTokens` is access-gated per chain**, independent of whether\n the caller is logged in. `GrantTokensAccessMode: \"ServerOnly\"` (the default)\n rejects every client-initiated call for that chain outright — check which\n mode a given chain uses (via its `SeasonChainDefinition` in `Definitions`)\n before wiring a client button to this call.\n- **A season chain can be gated to a segment.** `SeasonChainDefinition.Gate`\n (Core/Segment `SegmentGate`) can restrict a chain to specific\n segments/levels/countries/premium tiers/experiment variants. A player\n failing the gate gets `\"This season is not available for you.\"` from both\n `getActiveSeason` and `grantStatusTokens` — this is audience targeting, not\n a bug.\n- **Season transitions silently reset progress server-side (\"Wipe\").** When\n the chain has moved on to its next season (or a new cycle) since the player\n last interacted with it, the very next call touching that chain resets\n `CurrentTier` to `1` and clears `ClaimedTierRewards` for the new season —\n this happens lazily on next access, not on a timer, so re-fetch\n (`getActiveSeason`/`getUserState`) rather than trusting a long-cached\n `CurrentTier` across relaunches. See\n [references/data-model.md](references/data-model.md#season-rollover-wipe).\n- **Claims are one-time per tier, tracked client-cache-side too.**\n `ClaimedTierRewards` is a de-duplicated list the SDK cache maintains\n locally (`patchSeasonClaimedTier` only pushes a tier number if it isn't\n already present) as well as the backend enforcing it server-side — expect a\n `reason: \"server\"` rejection (e.g. \"Tier N reward already claimed.\") on a\n repeat call, and use the cached list to gray out the button before the\n player even tries.\n- **`getActiveSeason`'s season/tier ladder isn't cached** — only its embedded\n `UserState` is written to `client.data.user.state.Season`. If you need the\n season's `Tiers`/dates/`NextTier` on a later screen, either refetch\n `getActiveSeason` or hold onto the last response yourself; don't expect it\n in `client.data`.\n- **A tier's reward is not the same thing as the season-tier reward overlay.**\n `SeasonTierDefinition.TierReachedReward` (what `claimTierReward` pays out)\n is a plain, unscaled `ResourceGrant`. The separate `SeasonTierRewardSet`\n overlay (used by Leaderboard/Reward/Quest-milestone rewards to scale _their\n own_ payout by the player's season tier) is not applied here and is not\n something you configure through this module — see\n [references/data-model.md](references/data-model.md#the-season-tier-reward-overlay-used-by-other-modules)\n if you run into it from another module's config.\n- **Only `SeasonDefinitions` is exported at the package root.**\n `SeasonChainDefinition` / `SeasonDefinition` / `SeasonTierDefinition` aren't\n directly importable from `@idosgames/core` — read them structurally off the\n resolved config tree instead of trying to import the type by name.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID` embeds a UUID), so two separate calls are two real\n operations — a double-clicked \"Claim\" can be rejected the second time as\n \"already claimed\" (harmless) but a double-clicked \"Grant\" really does grant\n twice. Disable the control while a call is in flight. Firing the same\n endpoint again within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Field names are PascalCase straight off the backend JSON**, and every\n schema keeps `.passthrough()`, so a field the backend adds later still\n round-trips even before the SDK's types are updated for it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the exact tier-threshold algorithm, chain/window resolution and pause\nsemantics, the season-rollover (\"Wipe\") rule, and the season-tier reward\noverlay mechanism other modules build on top of a player's season tier. Read\nit when building config-driven UI (a season selector, a tier ladder with\ncountdown) or when an error message points at a config rule you need to\nunderstand.\n",
|
|
4
|
+
"content": "---\nname: season-system\ndescription: >-\n Build a season / battle-pass-style meta-progression system in a game on the\n iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService):\n load season chain definitions, fetch the currently active season in a chain,\n load the player's per-chain season state, grant status tokens (season\n XP/points) that advance a tier track, and claim a reached tier's reward. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a season pass, battle pass, status\n track, tier-reward system, seasonal meta-progression, or otherwise touches\n client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition,\n SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they\n don't name the module explicitly.\n---\n\n# Season system (iDosGames TS SDK)\n\nThe Season module is a battle-pass-style meta-progression track: a title\ndefines one or more **season chains**, each chain runs a sequence of\n**seasons** back to back (and cycles again after the last one), and each\nseason has a ladder of **tiers** the player climbs by earning **status\ntokens** (season XP/points). Reaching a tier unlocks that tier's reward, which\nthe player then claims. Everything is **server-authoritative**: the client\nasks the backend to grant tokens or claim a reward, the backend validates and\napplies it, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate season state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `SeasonService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(already claimed, tier not reached, wrong access mode, not logged in) —\nsurface the error, don't try to reproduce the check client-side.\n\n## The three data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n season chains: `SeasonDefinitions.Chains`, keyed by `SeasonChainID`. Each\n chain (`SeasonChainDefinition`) has a `Schedule`, an optional segment\n `Gate`, and an ordered list of `Seasons` (`SeasonDefinition`), each with a\n `DurationSec` and its own `Tiers` (`SeasonTierDefinition[]`). Fetched with\n `getDefinitions()`.\n2. **Active season info** (config + a state slice, per chain) — which season\n in the chain is live _right now_, its computed start/end, seconds\n remaining, and the next tier the player hasn't reached. Fetched per chain\n with `getActiveSeason(seasonChainID)`.\n3. **User season state** (state, per player, per chain) — this player's\n progress in one chain: `CurrentTier`, `ClaimedTierRewards`, which season\n version they're on. Fetched with `getUserState(seasonChainID)`, and also\n embedded in `ActiveSeasonInfo.UserState`.\n\nA season chain is identified by a string `SeasonChainID`; a season inside it\nby `SeasonID`; a tier by its plain `Tier` number, where `1` is the always-on\nbase tier (reached with 0 tokens). There's a single reward track per tier\n(`SeasonTierDefinition.TierReachedReward`) — no separate free/premium track\nsplit in this module.\n\n**Status tokens** are the season's XP/points currency, tracked internally\nthrough the same Core/EventToken ledger every other event-token currency\nuses. Calling `grantStatusTokens` adds an amount and the backend recomputes\n`CurrentTier` from the new cumulative total against the season's `Tiers`\nladder (`RequiredTokens` per tier — highest tier whose threshold is met\nwins). Granting is a distinct step from claiming — advancing a tier does not\nauto-claim its reward; the player (or your UI) calls `claimTierReward`\nseparately for each tier they want to collect.\n\nOnly `SeasonDefinitions` (the root config type) is re-exported from the\npackage root; `SeasonChainDefinition` / `SeasonDefinition` /\n`SeasonTierDefinition` are not directly importable — read them off the\nresolved `SeasonDefinitions` tree instead. See\n[references/data-model.md](references/data-model.md) for the full shape, the\nexact tier-threshold algorithm, season-rollover (\"Wipe\") semantics, and how\nthe season-tier reward overlay used by _other_ modules (Leaderboard, Reward,\nReferral, Quest milestones) relates to (and is separate from) this module's\nown `TierReachedReward`. You do **not** need it to call the methods — only to\ndrive richer UI or understand a cross-module reward scaling feature.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst seasons = client.season; // the SeasonService\n```\n\nEvery season method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing `seasonChainID` or a non-positive amount/tier\nnumber), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside\nthe throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"No active\nseason in this chain.\", \"Tier 3 not reached yet. Current tier: 2.\", \"Tier 3\nreward already claimed.\").\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |\n| `getDefinitions()` | Load the title's season chain catalog (config). | `SeasonDefinitions` |\n| `getActiveSeason(seasonChainID)` | Load the chain's currently-live season + this player's state in it. | `ActiveSeasonInfo` |\n| `getUserState(seasonChainID)` | Load this player's progress in one chain (state only). | `UserSeasonStateResponse` (= `UserSeasonState`) |\n| `grantStatusTokens(seasonChainID, amount)` | Add status tokens (season XP/points); may bump `CurrentTier`. | `GrantStatusTokensResponse` (`NewTier`, `TierUp`) |\n| `claimTierReward(seasonChainID, tierNumber)` | Claim a reached tier's reward (one-time per tier). | `ClaimTierRewardResponse` (`Resources`) |\n| `claimTierRewardsBatch(seasonChainID, tierNumbers)` | Claim several tiers in ONE atomic call — one token grant can raise the player through several tiers at once, so more than one reward is often pending. Merged `Resources` at the TOP level; per-item `Data.Resources` is null. | `ClaimTierRewardsBatchResponse` |\n\nThere are no batch methods on this module — each call operates on one season\nchain (and, for claims, one tier) at a time.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `claimTierReward`'s\ngranted resources ride along in `data.Resources` (a shared `ResourceOperation`\n— see `packages/core/src/models/_shared/ResourceModels.ts`) and are already\napplied to the cached currency/item balances, so read updated balances\nstraight from the cache.\n\n**`grantStatusTokens` is access-gated per chain**, not just by auth. Each\nchain's config sets `GrantTokensAccessMode` (`\"ServerOnly\"` | `\"ClientOnly\"` |\n`\"Both\"`, default `\"ServerOnly\"`). If a chain is `\"ServerOnly\"` — the typical\nproduction setup for tokens that should only come from tournament results,\nmatch wins, or quest completion — the client-facing call is rejected outright\nwith `\"GrantStatusTokens cannot be called from client for this chain.\"` before\nit even looks at your amount. A rejection here usually means \"wrong access\nmode for this chain's design,\" not a bug in your integration.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { SeasonDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<SeasonDefinitions>(\"Season\");\nconst chain = defs?.Chains?.[\"battle_pass_main\"];\nchain?.Seasons; // ordered SeasonDefinition[] for this chain\n\n// Per-chain user state (present after getUserState()/getActiveSeason()/a grant/claim):\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\nstate?.CurrentTier; // highest tier reached\nstate?.ClaimedTierRewards; // number[] of tier numbers already claimed\nstate?.CurrentSeasonID; // which season within the chain\n```\n\nThere's no separate cached \"active season\" slot — `ActiveSeasonInfo` (the\nlive season, its `Tiers`, computed dates, `NextTier`) is only available from\nthe `getActiveSeason` call's own return value; only its embedded `UserState`\ngets written into `client.data.user.state.Season`. Keep the last\n`ActiveSeasonInfo` you fetched in your own component/store if you need to\nrender the ladder alongside cached progress.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `season:definitionsLoaded` → `SeasonDefinitions`\n- `season:activeLoaded` → `ActiveSeasonInfo`\n- `season:userStateLoaded` → `UserSeasonStateResponse`\n- `season:statusTokensGranted` → `GrantStatusTokensResponse`\n- `season:tierRewardClaimed` → `ClaimTierRewardResponse`\n\nThe coarse `user:seasonUpdated` (and `user:anyUpdated`) also fire on any\nseason cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"season:statusTokensGranted\", (r) => {\n if (r.TierUp) console.log(`Reached tier ${r.NewTier}!`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load a chain, show the ladder, and claim a reached tier\n\n```ts\nawait client.season.getDefinitions();\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (!active.ok) return showError(active.error); // e.g. \"No active season in this chain.\"\n\nconst { Season, NextTier, SecondsRemaining } = active.data;\nconst currentTier =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]?.CurrentTier ??\n 0;\nconst claimed =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.ClaimedTierRewards ?? [];\n\nfor (const tier of Season?.Tiers ?? []) {\n const reached = (tier.Tier ?? 0) <= currentTier;\n const alreadyClaimed = claimed.includes(tier.Tier ?? -1);\n // reached && !alreadyClaimed -> show a \"Claim\" button for this tier.\n}\n\nif (currentTier >= 1 && !claimed.includes(1)) {\n const res = await client.season.claimTierReward(\"battle_pass_main\", 1);\n if (!res.ok) return showError(res.error); // e.g. \"Tier 1 reward already claimed.\"\n // res.data.Resources already applied to cached balances.\n}\n```\n\n`getActiveSeason` fails with `\"No active season in this chain.\"` both when the\nchain is fully inactive/misconfigured and when the chain is legitimately\n**paused** between two chained seasons (a configured gap) — treat both as\n\"nothing to show right now,\" not as an error worth retrying aggressively.\n\n### Grant status tokens (season XP/points)\n\n```ts\nconst res = await client.season.grantStatusTokens(\"battle_pass_main\", 250);\nif (!res.ok) return showError(res.error);\n\nres.data.NewTier; // tier after this grant\nres.data.TierUp; // true if this grant crossed into a new tier\nres.data.NewStatusTokens; // running cumulative token total for the current season\nres.data.OldTier; // tier before this grant, for a \"leveled up from X to Y\" toast\n```\n\nOnly wire this to a client button if the chain's `GrantTokensAccessMode` is\n`\"ClientOnly\"` or `\"Both\"` — see the Methods section above. For a title that\nawards status tokens purely from server-side triggers (match results,\ntournament placements), this call has nothing to do and should not be\nexposed in the UI at all for that chain.\n\n### Just show progress toward the next tier\n\n```ts\nawait client.season.getUserState(\"battle_pass_main\");\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\n\n// Combine with a previously-fetched ActiveSeasonInfo for the ladder:\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n active.data.NextTier?.RequiredTokens; // tokens needed for the next tier\n active.data.NextTier?.Tier;\n // NextTier is null once the player has reached the season's highest tier.\n}\n```\n\n### Handle claim edge cases\n\n```ts\nconst res = await client.season.claimTierReward(\"battle_pass_main\", 3);\nif (!res.ok) {\n switch (res.reason) {\n case \"server\":\n // e.g. \"Tier 3 not reached yet. Current tier: 2.\" or\n // \"Tier 3 reward already claimed.\" — read res.error and toast it\n showError(res.error);\n break;\n case \"unauthorized\":\n // session expired — re-auth then retry\n break;\n case \"connection\":\n // transient — offer a Retry button\n break;\n default:\n showError(res.error);\n }\n return;\n}\n```\n\n### Handle a season rollover on relaunch\n\n```ts\n// After a client relaunch or a long idle gap, don't trust a stale cached\n// CurrentTier/ClaimedTierRewards — the chain may have advanced to its next\n// season (or a new cycle) since the player last called in, which triggers a\n// server-side reset (see references/data-model.md#season-rollover-wipe).\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n // active.data.UserState now reflects the CURRENT season; the cache under\n // client.data.user.state.Season.States[\"battle_pass_main\"] was refreshed\n // as a side effect of this call.\n const seasonID = active.data.Season?.SeasonID;\n const stateSeasonID =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.CurrentSeasonID;\n // stateSeasonID === seasonID confirms you're looking at fresh progress.\n}\n```\n\n## Gotchas\n\n- **Granting tokens and claiming a reward are separate steps.**\n `grantStatusTokens` only advances `CurrentTier`; it does not claim anything.\n Your UI must call `claimTierReward` per tier — don't assume a `TierUp: true`\n response means the reward already landed in inventory.\n- **`grantStatusTokens` is access-gated per chain**, independent of whether\n the caller is logged in. `GrantTokensAccessMode: \"ServerOnly\"` (the default)\n rejects every client-initiated call for that chain outright — check which\n mode a given chain uses (via its `SeasonChainDefinition` in `Definitions`)\n before wiring a client button to this call.\n- **A season chain can be gated to a segment.** `SeasonChainDefinition.Gate`\n (Core/Segment `SegmentGate`) can restrict a chain to specific\n segments/levels/countries/premium tiers/experiment variants. A player\n failing the gate gets `\"This season is not available for you.\"` from both\n `getActiveSeason` and `grantStatusTokens` — this is audience targeting, not\n a bug.\n- **Season transitions silently reset progress server-side (\"Wipe\").** When\n the chain has moved on to its next season (or a new cycle) since the player\n last interacted with it, the very next call touching that chain resets\n `CurrentTier` to `1` and clears `ClaimedTierRewards` for the new season —\n this happens lazily on next access, not on a timer, so re-fetch\n (`getActiveSeason`/`getUserState`) rather than trusting a long-cached\n `CurrentTier` across relaunches. See\n [references/data-model.md](references/data-model.md#season-rollover-wipe).\n- **Claims are one-time per tier, tracked client-cache-side too.**\n `ClaimedTierRewards` is a de-duplicated list the SDK cache maintains\n locally (`patchSeasonClaimedTier` only pushes a tier number if it isn't\n already present) as well as the backend enforcing it server-side — expect a\n `reason: \"server\"` rejection (e.g. \"Tier N reward already claimed.\") on a\n repeat call, and use the cached list to gray out the button before the\n player even tries.\n- **`getActiveSeason`'s season/tier ladder isn't cached** — only its embedded\n `UserState` is written to `client.data.user.state.Season`. If you need the\n season's `Tiers`/dates/`NextTier` on a later screen, either refetch\n `getActiveSeason` or hold onto the last response yourself; don't expect it\n in `client.data`.\n- **A tier's reward is not the same thing as the season-tier reward overlay.**\n `SeasonTierDefinition.TierReachedReward` (what `claimTierReward` pays out)\n is a plain, unscaled `ResourceGrant`. The separate `SeasonTierRewardSet`\n overlay (used by Leaderboard/Reward/Quest-milestone rewards to scale _their\n own_ payout by the player's season tier) is not applied here and is not\n something you configure through this module — see\n [references/data-model.md](references/data-model.md#the-season-tier-reward-overlay-used-by-other-modules)\n if you run into it from another module's config.\n- **Only `SeasonDefinitions` is exported at the package root.**\n `SeasonChainDefinition` / `SeasonDefinition` / `SeasonTierDefinition` aren't\n directly importable from `@idosgames/core` — read them structurally off the\n resolved config tree instead of trying to import the type by name.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID` embeds a UUID), so two separate calls are two real\n operations — a double-clicked \"Claim\" can be rejected the second time as\n \"already claimed\" (harmless) but a double-clicked \"Grant\" really does grant\n twice. Disable the control while a call is in flight. Firing the same\n endpoint again within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Field names are PascalCase straight off the backend JSON**, and every\n schema keeps `.passthrough()`, so a field the backend adds later still\n round-trips even before the SDK's types are updated for it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the exact tier-threshold algorithm, chain/window resolution and pause\nsemantics, the season-rollover (\"Wipe\") rule, and the season-tier reward\noverlay mechanism other modules build on top of a player's season tier. Read\nit when building config-driven UI (a season selector, a tier ladder with\ncountdown) or when an error message points at a config rule you need to\nunderstand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "store-system",
|
|
3
3
|
"description": "Build a store / shop system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load storefront and offer (SKU) definitions, load the player's purchase counters, and purchase one or many offers (currency/item packs, bundles, cosmetics) with virtual/item cost. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop/store screen, IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition, or offer purchasing — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: store-system\ndescription: >-\n Build a store / shop system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load storefront and offer\n (SKU) definitions, load the player's purchase counters, and purchase one or\n many offers (currency/item packs, bundles, cosmetics) with virtual/item\n cost. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a shop/store screen,\n IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise\n touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition,\n or offer purchasing — even if they don't name the module explicitly.\n---\n\n# Store system (iDosGames TS SDK)\n\nThe Store module lets a title sell **offers** (SKUs) — currency packs, item\nbundles, cosmetics, anything priced via `ResourceConsume` — grouped into one or\nmore **storefronts**. Everything is **server-authoritative**: the client asks\nthe backend to purchase, the backend validates cost, rules, and limits, and the\nSDK mirrors the confirmed result (resources + purchase counters) into a local\ncache your UI reads. You never mutate store state yourself — you call a\nmethod, check the result, and render from the cache.\n\nThis skill is for **using** the production `StoreService`, not for porting or\nextending it. If a purchase is rejected, that's the backend enforcing a rule\n(cost, time window, audience gate, purchase cap) — surface the error, don't try\nto reproduce the check client-side.\n\nStore's `Cost`/`Rewards` are virtual (`ResourceConsume`/`ResourceGrant`) —\ncurrency, items, event tokens, premium-tier grants. There is no real-money IAP\nreceipt flow inside this module; that lives entirely in the separate Purchase\nmodule (not covered here).\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n storefronts and offers: which offers live in which store, their `Cost` and\n `Rewards`, and their availability `Rules`. Fetched with `getDefinitions()`.\n2. **User store state** (state, per player) — this player's purchase counters\n per offer (`TotalPurchases`, `DailyPurchases`, reset time). Fetched with\n `getUserState()`.\n\nAn offer is identified by a string `OfferID`; a storefront by `StoreID`. An\noffer can be listed in multiple stores via `StoreIDs`, letting the same SKU\nappear in, say, both the main shop and a limited-time event shop. For the full\nfield-by-field shape of Definitions and state (purchase-limit reset math,\nbatch semantics, special-value rules), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Offer not found\nin the specified store.\", \"Offer is not yet available.\", \"Offer has expired.\",\n\"Offer is not available for you.\", \"Purchase limit reached for offer\n'<id>'. Max: <n>.\", \"Daily purchase limit reached for offer '<id>'. Max per\nday: <n>.\", or an `ApplyResourceOperationAtomicAsync` failure such as\ninsufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------- | ---------------------------------------------- | -------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's store/offer catalog (config). | `StoreDefinitions` |\n| `getUserState()` | Load this player's purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` (`Resources`) |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` (`BatchItemResult<StorePurchaseResponse>[]`) |\n\n`purchase` clamps `count` server-side to the range **1–100** (values ≤0 sent by\na caller are floored to 1 by the backend, but the SDK itself already rejects\n`count < 1` client-side as `reason: \"client\"`). `purchaseBatch` takes\n`StorePurchaseRef[]`: `{ OfferID, Count }[]` — deduped by `OfferID` (one entry\nper offer per call; use `Count` for multiple units), each `Count` clamped to\n1–100, and the list itself clamped to **50 refs per call** (entries past 50 are\nsilently dropped server-side — chunk larger sets yourself).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `purchase`/`purchaseBatch`\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\nsee the currency-system skill for the full `ResourceConsume`/`ResourceGrant`\nreference) to the cached currency/item balances, and bump the purchased\noffer's counters (`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`). Read\nupdated balances and counters straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { StoreDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst offer = defs?.StoreOffers?.[\"pack1\"];\noffer?.Cost; // ResourceConsume — what it costs\noffer?.Rewards; // ResourceGrant — what it grants\noffer?.Rules; // time window, Gate (SegmentGate), Limits (LimitSpec)\n\n// Purchase counters (only present after getUserState() or a purchase):\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\npurchases[\"pack1\"]?.TotalPurchases;\npurchases[\"pack1\"]?.DailyPurchases;\npurchases[\"pack1\"]?.DailyResetUtc; // ISO — next UTC-midnight reset\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"store:offerPurchased\", (r) => {\n console.log(`Bought ${r.Count}x ${r.OfferID}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show a storefront with purchase-limit UI\n\n```ts\nawait client.store.getDefinitions();\nawait client.store.getUserState();\n\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\n\nconst offersInMainStore = Object.values(defs?.StoreOffers ?? {}).filter((o) =>\n o.StoreIDs?.includes(\"main\"),\n);\n\nfor (const offer of offersInMainStore) {\n const counters = purchases[offer.OfferID];\n const limits = offer.Rules?.Limits;\n const totalLeft =\n limits?.TotalCap && limits.TotalCap > 0\n ? Math.max(0, limits.TotalCap - (counters?.TotalPurchases ?? 0))\n : null; // null = no lifetime cap\n const dailyLeft =\n limits?.DailyCap && limits.DailyCap > 0\n ? Math.max(0, limits.DailyCap - (counters?.DailyPurchases ?? 0))\n : null; // null = no daily cap\n // Disable the buy button when totalLeft === 0 or dailyLeft === 0.\n // Don't try to predict the daily reset instant yourself beyond display —\n // read counters.DailyResetUtc fresh after each purchase/getUserState().\n}\n```\n\n`Rules` (time window + `Gate` audience + `Limits` purchase caps) are enforced\nserver-side — use them client-side only to pre-filter/gray out what you\nalready know will be rejected, not as the source of truth.\n\n### Purchase an offer\n\n```ts\nconst res = await client.store.purchase(\"pack1\", 1);\nif (!res.ok) return showError(res.error); // e.g. \"Purchase limit reached...\", can't afford\n// cache now has updated balances + counters. UI re-renders from cache.\nres.data.Resources; // ResourceOperation actually applied (Consume + Grant)\n```\n\n### Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"pack1\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\neach element's `Success`/`Error` tells you whether that item applied. Offers\nrejected on their own merits (unknown id, outside window, gate failed, limit\nreached) are filtered out _before_ the merged charge is built and simply\nreport their own reason — they never affect other items in the batch. The\nremaining, valid offers are then charged as **one merged, all-or-nothing\ntransaction**: if the combined cost can't be paid, every one of those\nsurvivors comes back `Success: false` with an \"Atomic batch purchase failed\"\nerror, even though each was individually valid.\n\n### Cosmetic/bundle offer with only item rewards\n\nNothing offer-specific to do differently — `Rewards` is a `ResourceGrant` like\nany other, so an offer that only grants items (no currency) works through the\nsame `purchase()` call. Read the granted item instances back from\n`client.data.user.state?.InventoryV2` (see the item-system skill) after the\ncall, or from `res.data.Resources.Grant`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` call mints a fresh\n idempotency key (`store_buy_{offerID}_{userID}_{uuid}` client-side, further\n wrapped server-side), so two separate calls are two real operations — a\n double-clicked \"Buy\" can charge twice. Disable the control while a call is\n in flight. Firing the same endpoint again within the throttle window\n (default 600 ms) is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.\n- **`Cost` and `Rewards` can be discounted/boosted server-side.** `Cost` is a\n `ResourceConsume` (may carry `PremiumDiscounts`) and `Rewards` is a\n `ResourceGrant` (may carry `PremiumTiers`) — the backend auto-applies the\n player's best subscription tier (see the premium-system skill). Don't assume\n the displayed base price/reward equals what's actually charged/granted; read\n the actual amounts off `res.data.Resources`.\n- **`count` scales cost and rewards linearly, then premium is applied once.**\n Buying `count=3` multiplies every `Cost`/`Rewards` entry (including event\n tokens) by 3 before premium discounts/bonuses are resolved — it is not 3\n independent purchases, so per-purchase minimums/rounding don't compound.\n- **Only one `Resources` apply per batch call, but every item's own data is\n still correct.** `purchaseBatch` applies the first successful item's\n `Resources` to the cache (the batch charge is merged server-side into one\n operation, so attaching it to every item would double-count balances); the\n per-item `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc` are still correct\n for each offer and drive the purchase-counter patch for every successful\n item, not just the first.\n- **`DailyPurchases` resets on UTC midnight, compared as ISO strings.** The SDK\n mirrors the server's reset logic locally when patching after a purchase\n (`state.DailyResetUtc` becomes the next UTC midnight after\n `ServerTimeUtc`) — you don't need to compute it, just read\n `DailyResetUtc`/`DailyPurchases` from the cache after the call.\n- **Purchase-history writes are best-effort and don't affect the result.** The\n backend appends an audit-log row after a successful purchase; if that write\n fails it's swallowed silently and never surfaces to the client — don't\n expect a Store endpoint to expose purchase history.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the purchase-limit/reset rule matrix, batch all-or-nothing semantics,\nand special-value conventions. Read it when building config-driven UI (cap\npreviews, cooldown countdowns) or when an error message points at a config\nrule you need to understand.\n",
|
|
4
|
+
"content": "---\nname: store-system\ndescription: >-\n Build a store / shop system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load storefront and offer\n (SKU) definitions, load the player's purchase counters, and purchase one or\n many offers (currency/item packs, bundles, cosmetics) with virtual/item\n cost. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a shop/store screen,\n IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise\n touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition,\n or offer purchasing — even if they don't name the module explicitly.\n---\n\n# Store system (iDosGames TS SDK)\n\nThe Store module lets a title sell **offers** (SKUs) — currency packs, item\nbundles, cosmetics, anything priced via `ResourceConsume` — grouped into one or\nmore **storefronts**. Everything is **server-authoritative**: the client asks\nthe backend to purchase, the backend validates cost, rules, and limits, and the\nSDK mirrors the confirmed result (resources + purchase counters) into a local\ncache your UI reads. You never mutate store state yourself — you call a\nmethod, check the result, and render from the cache.\n\nThis skill is for **using** the production `StoreService`, not for porting or\nextending it. If a purchase is rejected, that's the backend enforcing a rule\n(cost, time window, audience gate, purchase cap) — surface the error, don't try\nto reproduce the check client-side.\n\nStore's `Cost`/`Rewards` are virtual (`ResourceConsume`/`ResourceGrant`) —\ncurrency, items, event tokens, premium-tier grants. There is no real-money IAP\nreceipt flow inside this module; that lives entirely in the separate Purchase\nmodule (not covered here).\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n storefronts and offers: which offers live in which store, their `Cost` and\n `Rewards`, and their availability `Rules`. Fetched with `getDefinitions()`.\n2. **User store state** (state, per player) — this player's purchase counters\n per offer (`TotalPurchases`, `DailyPurchases`, reset time). Fetched with\n `getUserState()`.\n\nAn offer is identified by a string `OfferID`; a storefront by `StoreID`. An\noffer can be listed in multiple stores via `StoreIDs`, letting the same SKU\nappear in, say, both the main shop and a limited-time event shop. For the full\nfield-by-field shape of Definitions and state (purchase-limit reset math,\nbatch semantics, special-value rules), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Offer not found\nin the specified store.\", \"Offer is not yet available.\", \"Offer has expired.\",\n\"Offer is not available for you.\", \"Purchase limit reached for offer\n'<id>'. Max: <n>.\", \"Daily purchase limit reached for offer '<id>'. Max per\nday: <n>.\", or an `ApplyResourceOperationAtomicAsync` failure such as\ninsufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's store/offer catalog (config). | `StoreDefinitions` |\n| `getUserState()` | Load this player's purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. `options` = `{ selectedOptionID?, payment? }`. | `StorePurchaseResponse` (`Resources`) |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` (`BatchItemResult<StorePurchaseResponse>[]`) |\n\n`purchase` clamps `count` server-side to the range **1–100** (values ≤0 sent by\na caller are floored to 1 by the backend, but the SDK itself already rejects\n`count < 1` client-side as `reason: \"client\"`). `purchaseBatch` takes\n`StorePurchaseRef[]`: `{ OfferID, Count }[]` — deduped by `OfferID` (one entry\nper offer per call; use `Count` for multiple units), each `Count` clamped to\n1–100, and the list itself clamped to **50 refs per call** (entries past 50 are\nsilently dropped server-side — chunk larger sets yourself).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `purchase`/`purchaseBatch`\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\nsee the currency-system skill for the full `ResourceConsume`/`ResourceGrant`\nreference) to the cached currency/item balances, and bump the purchased\noffer's counters (`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`). Read\nupdated balances and counters straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { StoreDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst offer = defs?.StoreOffers?.[\"pack1\"];\noffer?.PriceOptions; // ways to pay; render with client.checkout.availableOptions(...)\noffer?.Rewards; // ResourceGrant — what it grants\noffer?.Rules; // time window, Gate (SegmentGate), Limits (LimitSpec)\n\n// Purchase counters (only present after getUserState() or a purchase):\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\npurchases[\"pack1\"]?.TotalPurchases;\npurchases[\"pack1\"]?.DailyPurchases;\npurchases[\"pack1\"]?.DailyResetUtc; // ISO — next UTC-midnight reset\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"store:offerPurchased\", (r) => {\n console.log(`Bought ${r.Count}x ${r.OfferID}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show a storefront with purchase-limit UI\n\n```ts\nawait client.store.getDefinitions();\nawait client.store.getUserState();\n\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\n\nconst offersInMainStore = Object.values(defs?.StoreOffers ?? {}).filter((o) =>\n o.StoreIDs?.includes(\"main\"),\n);\n\nfor (const offer of offersInMainStore) {\n const counters = purchases[offer.OfferID];\n const limits = offer.Rules?.Limits;\n const totalLeft =\n limits?.TotalCap && limits.TotalCap > 0\n ? Math.max(0, limits.TotalCap - (counters?.TotalPurchases ?? 0))\n : null; // null = no lifetime cap\n const dailyLeft =\n limits?.DailyCap && limits.DailyCap > 0\n ? Math.max(0, limits.DailyCap - (counters?.DailyPurchases ?? 0))\n : null; // null = no daily cap\n // Disable the buy button when totalLeft === 0 or dailyLeft === 0.\n // Don't try to predict the daily reset instant yourself beyond display —\n // read counters.DailyResetUtc fresh after each purchase/getUserState().\n}\n```\n\n`Rules` (time window + `Gate` audience + `Limits` purchase caps) are enforced\nserver-side — use them client-side only to pre-filter/gray out what you\nalready know will be rejected, not as the source of truth.\n\n### Purchase an offer\n\n```ts\nconst res = await client.store.purchase(\"pack1\", 1);\nif (!res.ok) return showError(res.error); // e.g. \"Purchase limit reached...\", can't afford\n// cache now has updated balances + counters. UI re-renders from cache.\nres.data.Resources; // ResourceOperation actually applied (Consume + Grant)\n```\n\nWhen the offer has several ways to pay, render them with\n`client.checkout.availableOptions(offer.PriceOptions)` and pass the chosen one.\nAn option paid in a store needs the receipt too:\n\n```ts\nawait client.store.purchase(\"pack1\", 1, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"pack1\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\neach element's `Success`/`Error` tells you whether that item applied. Offers\nrejected on their own merits (unknown id, outside window, gate failed, limit\nreached) are filtered out _before_ the merged charge is built and simply\nreport their own reason — they never affect other items in the batch. The\nremaining, valid offers are then charged as **one merged, all-or-nothing\ntransaction**: if the combined cost can't be paid, every one of those\nsurvivors comes back `Success: false` with an \"Atomic batch purchase failed\"\nerror, even though each was individually valid.\n\n### Cosmetic/bundle offer with only item rewards\n\nNothing offer-specific to do differently — `Rewards` is a `ResourceGrant` like\nany other, so an offer that only grants items (no currency) works through the\nsame `purchase()` call. Read the granted item instances back from\n`client.data.user.state?.InventoryV2` (see the item-system skill) after the\ncall, or from `res.data.Resources.Grant`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` call mints a fresh\n idempotency key (`store_buy_{offerID}_{userID}_{uuid}` client-side, further\n wrapped server-side), so two separate calls are two real operations — a\n double-clicked \"Buy\" can charge twice. Disable the control while a call is\n in flight. Firing the same endpoint again within the throttle window\n (default 600 ms) is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.\n- **`Cost` and `Rewards` can be discounted/boosted server-side.** `Cost` is a\n `ResourceConsume` (may carry `PremiumDiscounts`) and `Rewards` is a\n `ResourceGrant` (may carry `PremiumTiers`) — the backend auto-applies the\n player's best subscription tier (see the premium-system skill). Don't assume\n the displayed base price/reward equals what's actually charged/granted; read\n the actual amounts off `res.data.Resources`.\n- **`count` scales cost and rewards linearly, then premium is applied once.**\n Buying `count=3` multiplies every `Cost`/`Rewards` entry (including event\n tokens) by 3 before premium discounts/bonuses are resolved — it is not 3\n independent purchases, so per-purchase minimums/rounding don't compound.\n- **Only one `Resources` apply per batch call, but every item's own data is\n still correct.** `purchaseBatch` applies the first successful item's\n `Resources` to the cache (the batch charge is merged server-side into one\n operation, so attaching it to every item would double-count balances); the\n per-item `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc` are still correct\n for each offer and drive the purchase-counter patch for every successful\n item, not just the first.\n- **`DailyPurchases` resets on UTC midnight, compared as ISO strings.** The SDK\n mirrors the server's reset logic locally when patching after a purchase\n (`state.DailyResetUtc` becomes the next UTC midnight after\n `ServerTimeUtc`) — you don't need to compute it, just read\n `DailyResetUtc`/`DailyPurchases` from the cache after the call.\n- **Purchase-history writes are best-effort and don't affect the result.** The\n backend appends an audit-log row after a successful purchase; if that write\n fails it's swallowed silently and never surfaces to the client — don't\n expect a Store endpoint to expose purchase history.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the purchase-limit/reset rule matrix, batch all-or-nothing semantics,\nand special-value conventions. Read it when building config-driven UI (cap\npreviews, cooldown countdowns) or when an error message points at a config\nrule you need to understand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Store data model — reference\n\nFull shape of the config (Definitions) and player state, the purchase-limit\nrule matrix, batch all-or-nothing semantics, and special-value conventions.\nAll of these are **strictly typed in the SDK** — `StoreDefinitions` and every\nnested block (`StoreDefinition`, `StoreRules`, `StoreOfferDefinition`,\n`StoreOfferRules`) are exported from `@idosgames/core`, so `getDefinitions()`\nand `getSection<StoreDefinitions>(\"Store\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: StoreDefinitions](#config-storedefinitions) — what `getDefinitions()` returns\n- [StoreDefinition (storefront)](#storedefinition-storefront)\n- [StoreOfferDefinition (SKU)](#storeofferdefinition-sku)\n- [Purchase-limit rule matrix](#purchase-limit-rule-matrix)\n- [Purchase flow, scaling, and idempotency](#purchase-flow-scaling-and-idempotency)\n- [Batch purchase semantics](#batch-purchase-semantics)\n- [Special values](#special-values)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Purchases?: Record<string, StorePurchaseState> }`\nand cached at `client.data.user.state?.Store?.Purchases`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/UserStoreState.cs`.\n\n```ts\ninterface StorePurchaseState {\n OfferID: string;\n TotalPurchases: number; // lifetime count, all-time\n DailyPurchases: number; // count since the last DailyResetUtc\n DailyResetUtc: string; // ISO — the next instant DailyPurchases resets to 0\n LastPurchasedAt: string; // ISO — server time of the last successful purchase\n}\n```\n\nThis is a **rate-limit counter store**, not a purchase-history log — it only\nholds what's needed to enforce `TotalCap`/`DailyCap` atomically. A separate\n`StorePurchaseHistoryDocument` audit-log collection exists server-side\n(`UserID`, `TitleID`, `OfferID`, `Count`, `Resources`, `PurchasedAt`) but it is\n**not exposed through any Store endpoint** — there is no \"purchase history\"\nclient call.\n\nA player with no purchase for a given `OfferID` simply has no entry in\n`Purchases` — treat a missing key as `TotalPurchases: 0`, `DailyPurchases: 0`,\nno active daily window.\n\n---\n\n## Config: StoreDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<StoreDefinitions>(\"Store\")`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreDefinitions {\n Stores?: Record<string, StoreDefinition> | null; // key = StoreID\n StoreOffers?: Record<string, StoreOfferDefinition> | null; // key = OfferID\n}\n```\n\nStorefronts and offers are deliberately separate catalogs: an offer references\nthe storefronts it appears in via `StoreIDs`, so the same SKU (price, rewards,\n`OfferID`, analytics) can be reused across the main shop, an event shop, a VIP\nshop, etc. without duplication.\n\n---\n\n## StoreDefinition (storefront)\n\nA logical shop screen (main / event / VIP). Does not embed offers.\n\n```ts\ninterface StoreDefinition {\n StoreID: string; // stable id; never rename after publication — offers reference it\n Type?: string; // segmentation/grouping tag, free-form\n Name?: string; // display name; optional for internal storefronts\n Description?: string;\n Rules?: StoreRules;\n AssetPaths?: Record<string, string>; // banner/icon/background, key = asset slug\n}\n\ninterface StoreRules {\n StartUtc?: string; // storefront opens at this UTC instant; absent = available from the start\n EndUtc?: string; // storefront closes at this UTC instant; absent = no expiration\n RequiredFlags?: string[]; // ALL must be set on the player for the storefront to show\n}\n```\n\n`StoreRules.RequiredFlags` is **not enforced by the `Store.Purchase` /\n`PurchaseBatch` endpoints** — the backend's purchase path (`Store.cs`) only\nvalidates the _offer's_ own `Rules` (window, `Gate`, `Limits`); it never looks\nup which storefront the purchase came through. Treat `StoreRules` purely as\nclient-side \"should I show this storefront\" filtering data, not as a\nserver-enforced purchase gate — the offer-level `Gate`/window/limits are the\nactual enforcement.\n\n---\n\n## StoreOfferDefinition (SKU)\n\nThe purchasable unit. Backend field-level docs from\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreOfferDefinition {\n OfferID: string; // stable id; used in analytics/purchase logs; never rename after publication\n StoreIDs?: string[]; // storefronts this offer appears in; empty/null = invisible everywhere\n Name?: string;\n Cost?: ResourceConsume; // debit-only; see currency-system skill for the shared shape\n Rewards?: ResourceGrant; // grant-only; see currency-system skill for the shared shape\n Rules?: StoreOfferRules;\n AssetPaths?: Record<string, string>;\n}\n\ninterface StoreOfferRules {\n StartUtc?: string; // offer becomes purchasable at this UTC instant; absent = from the start\n EndUtc?: string; // offer stops being purchasable at this UTC instant; absent = no expiration\n Gate?: SegmentGate; // \"who can buy this\" — premium tier/ID, segment, level, country, recency, experiment\n Limits?: LimitSpec; // purchase caps — see the matrix below\n}\n```\n\n`Gate` is the shared `SegmentGate` (Core/Segment) — all conditions AND-ed, an\nabsent/empty gate means available to everyone. Resolved server-side by\n`SegmentGateEvaluator.Passes` against the player's document at the moment of\npurchase (`Store.cs` line ~170: `\"Offer is not available for you.\"` on\nfailure).\n\n**Shape validation** (`StoreHelpers.ValidateOfferShape`, always run before a\npurchase is accepted): an offer with an empty `Cost` (no `Standard.Entries` and\nno `Standard.EventTokens`) fails with `\"Offer cost is empty.\"`; an offer with\nno `Rewards` at all (`Standard.Entries`, `Standard.EventTokens`, and\n`PremiumTiers` all empty) fails with `\"Offer rewards are empty.\"`. In other\nwords: **every real offer must both cost something and grant something** —\nthere is no free-claim or cost-only shape for Store offers (use the Reward or\nDealOffer module for pure-claim mechanics).\n\n---\n\n## Purchase-limit rule matrix\n\n`LimitSpec` (shared `Core/Limits` type; full field list in\n`packages/core/src/models/_shared/LimitModels.ts`) is reused across the SDK,\nbut Store's enforcement (`StoreHelpers.CheckPurchaseLimits` and\n`BuildPurchaseCounterPatches`, in\n`IDosGamesSDK/API/Client/v2/Store/Services/StoreHelpers.cs`) only reads two of\nits axes:\n\n| `LimitSpec` field | Meaning for Store | Enforcement |\n| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `TotalCap` | Lifetime purchase cap for the offer, summed over `Count` across all purchases | `TotalPurchases + count > TotalCap` → `\"Purchase limit reached for offer '<id>'. Max: <n>.\"` |\n| `DailyCap` | Per-UTC-day purchase cap | `DailyPurchases + count > DailyCap` (only while `now < DailyResetUtc`; otherwise treated as 0) → `\"Daily purchase limit reached for offer '<id>'. Max per day: <n>.\"` |\n\nOther `LimitSpec` axes (`DailyWeightCap`, `PerActivationCap`,\n`CooldownSeconds`, `MaxPerWindow`, `WindowSeconds`) exist on the shared type\nfor other modules but **Store does not read them** — configuring them on a\nStore offer's `Rules.Limits` has no effect on purchase behavior.\n\n**Daily reset timing.** `DailyResetUtc` is set to `now.Date.AddDays(1)` (the\nUTC midnight _after_ the purchase that (re)started the window) the first time\nan offer is bought, or whenever `now >= DailyResetUtc` on a subsequent\npurchase — i.e. the daily window is lazily rolled forward on the next\npurchase attempt, not on a schedule. If a player buys at 23:59 UTC and again\nat 00:01 UTC, the second purchase sees `now >= DailyResetUtc` from the first,\nresets `DailyPurchases` to the new `count`, and pushes `DailyResetUtc` to the\nfollowing midnight.\n\n**Race protection.** The fail-fast check in `CheckPurchaseLimits` runs before\nthe atomic write, but the real guarantee against concurrent double-spends past\nthe cap is an `extraFilter` attached to the same Mongo update\n(`BuildPurchaseCounterPatches`): the write only commits if\n`TotalPurchases <= TotalCap - count` (and the daily equivalent, tolerant of an\nexpired window) still holds at write time. If two concurrent requests would\nboth push a counter over its cap, only one commits — the loser's whole\n`ApplyResourceOperationAtomicAsync` call fails and the purchase is rejected,\nresources untouched.\n\n---\n\n## Purchase flow, scaling, and idempotency\n\nOrder of checks in `Store.StorePurchase` (`Store.cs`), all before any resource\nmutation:\n\n1. `OfferID` required; `count` clamped to **1–100**.\n2. Offer looked up by `OfferID` (optionally filtered by `storeID`, unused by\n the public `Purchase` action) — `\"Offer not found in the specified store.\"`\n if missing.\n3. Window check (`StartUtc`/`EndUtc`) — `\"Offer is not yet available.\"` /\n `\"Offer has expired.\"`.\n4. Shape check (`Cost` non-empty, `Rewards` non-empty) — see above.\n5. Player document read (single read, id/`InventoryV2`/`EventToken`/`Premium`/`Store` projection only).\n6. `Gate` check — `\"Offer is not available for you.\"`.\n7. Limit check (`CheckPurchaseLimits`) — see the matrix above.\n8. **Scaling**: `Cost` and `Rewards` are each scaled by `count` — every\n `ResourceEntry.Amount` and every `EventTokenOperation.Amount` is multiplied\n by `count` (a fresh object; the config definition itself is never mutated).\n `PremiumDiscounts`/`PremiumTiers` percentages are **not** scaled by count —\n only flat amounts are.\n9. The scaled `Cost`/`Rewards` become one `ResourceOperation { Grant, Consume }`\n applied via `ResourceService.ApplyResourceOperationAtomicAsync`, alongside\n the purchase-counter patches from step 7 and a `FeatureUsage` touch (see\n below), under one Mongo transaction with the `extraFilter` guard.\n10. On success, a best-effort audit-log row is appended\n (`StoreHelpers.AppendPurchaseHistoryAsync`) — failures here are swallowed\n and never affect the client response.\n\n**Idempotency.** The reason key is\n`\"StoreBuy:\" + ResourceService.ResolveRelatedEntityID(relatedEntityID, \"store_buy_{offerID}_{userID}\")`.\nThe SDK's `purchase()` always supplies a fresh, unique `RelatedEntityID`\n(`store_buy_{offerID}_{userID}_{uuid}`) per call — so from the client's\nperspective **every `purchase()` call is a brand-new charge**; the idempotency\nkey only protects against the transport layer's own internal retries within a\nsingle logical call, not against you calling `purchase()` twice.\n\n**`FeatureUsage` touch.** Every successful `Purchase` (regardless of `count`)\nincrements a `FeatureIDs.Store` usage touch exactly once — this is \"the player\nengaged the store,\" unrelated to and not a substitute for the per-offer\n`TotalPurchases`/`DailyPurchases` counters.\n\n---\n\n## Batch purchase semantics\n\n`PurchaseBatch` (`Store.PurchaseBatch` in `Store.cs`) trades N round-trips for\none, but keeps per-offer validation independent from the shared charge:\n\n**1. Normalization** — for each `StorePurchaseRef` in `args.Purchases`:\nblank/whitespace `OfferID` is dropped; `OfferID` is trimmed; duplicates by\n`OfferID` are dropped (first occurrence wins — **one offer per batch call**;\nuse `Count` for multiple units of the same offer, not repeated refs);\n`Count <= 0` is treated as `1`, then clamped to **1–100**; the list stops\ngrowing once it reaches `BatchSupport.MaxBatchSize` = **50** — refs beyond the\n50th are silently dropped and never appear in the result at all. An\nall-empty/invalid request (0 refs survive normalization) fails outright with\n`\"Purchases is required\"`.\n\n**2. One player read** for the whole batch (not per-offer).\n\n**3. Per-offer validation, outside the transaction** — for each surviving\n`(offerID, count)`, in order: offer exists → window → shape → `Gate` → purchase\nlimits (same checks and same error strings as the single-purchase path,\nkeyed per offer). Any failure here produces an immediate `BatchItemResult`\nwith `Success: false` and that specific `Error`, and **excludes the offer from\nthe merged charge** — it does not abort the batch.\n\nIf **zero** offers survive this stage, the call returns `Ok` with only the\nper-offer failure results (no atomic transaction is attempted).\n\n**4. Merge + one atomic charge** — for every surviving offer: `Cost`/`Rewards`\nare scaled by that offer's own `count`, then premium discounts/tiers are\nresolved and flattened per-offer (`ResourceService.FilterByPremium`) _before_\nmerging, so each offer's own premium tier is applied — the merge does not\ncreate a single blended discount. The flattened bundles from every surviving\noffer are summed into one `ResourceGrant`/`ResourceConsume`, together with the\npurchase-counter patches for every surviving offer and one shared\n`FeatureUsage` touch, and applied as a **single**\n`ApplyResourceOperationAtomicAsync` call with a combined `extraFilter`\n(AND of every offer's own race-protection filter).\n\n**All-or-nothing across survivors.** If the merged charge fails (e.g. can't\nafford the combined cost, or any one offer's `extraFilter` no longer holds),\n**every surviving offer** — even ones that were individually valid — comes\nback `Success: false` with `\"Atomic batch purchase failed: <reason>\"`. There is\nno partial application within the merged group; only the pre-filtered\nindividually-invalid offers were ever excluded.\n\n**5. Result shape and `Resources` placement.** The merged `ResourceOperation`\nreturned by the atomic call is attached to `Data.Resources` on **only the\nfirst successful item** in call order; every other successful item gets\n`Data.Resources = new ResourceOperation()` (empty, not null) — so summing\n`Resources` across all successful items double-counts nothing, but reading a\nnon-first item's `Resources` for balance info will show nothing. Read balances\nfrom the cache (which the SDK patches once per successful item's own\n`OfferID`/`Count`, so counters are correct for every item) rather than from\neach item's own `Resources`. `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc`\nare correct and independent for every successful item regardless of where\n`Resources` landed.\n\n**Reason key.** `BatchSupport.BuildBatchReason(\"StoreBuyBatch\", relatedEntityID, includedOfferIDs)`\n— one idempotency key covering the whole merged transaction, not one per\noffer.\n\n**Audit log.** On a successful merged charge, one best-effort history row is\nappended per surviving offer (same swallow-on-failure semantics as the single\npath).\n\n---\n\n## Special values\n\n- `Rules` absent entirely on a storefront or offer ⇒ no restriction on that\n axis (always visible / always purchasable / no gate / no limits).\n- `LimitSpec.TotalCap` / `DailyCap` `<= 0` (including absent, which the config\n default `LimitSpec` treats as `0`) ⇒ **unlimited** on that axis — the check\n is skipped entirely, not \"zero purchases allowed.\"\n- `StoreOfferDefinition.StoreIDs` empty or `null` ⇒ the offer exists in the\n catalog but is invisible in every storefront (it can still theoretically be\n purchased by `OfferID` directly, since `Purchase`'s `storeID` filter is\n unused by the public action — but there is no supported storefront UI path\n to reach it).\n- A player with no `Purchases[offerID]` entry is equivalent to\n `TotalPurchases: 0, DailyPurchases: 0`, with no active daily window (the\n `DailyExpired` check treats a missing state the same as an expired one).\n"
|
|
8
|
+
"content": "# Store data model — reference\n\nFull shape of the config (Definitions) and player state, the purchase-limit\nrule matrix, batch all-or-nothing semantics, and special-value conventions.\nAll of these are **strictly typed in the SDK** — `StoreDefinitions` and every\nnested block (`StoreDefinition`, `StoreRules`, `StoreOfferDefinition`,\n`StoreOfferRules`) are exported from `@idosgames/core`, so `getDefinitions()`\nand `getSection<StoreDefinitions>(\"Store\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: StoreDefinitions](#config-storedefinitions) — what `getDefinitions()` returns\n- [StoreDefinition (storefront)](#storedefinition-storefront)\n- [StoreOfferDefinition (SKU)](#storeofferdefinition-sku)\n- [Purchase-limit rule matrix](#purchase-limit-rule-matrix)\n- [Purchase flow, scaling, and idempotency](#purchase-flow-scaling-and-idempotency)\n- [Batch purchase semantics](#batch-purchase-semantics)\n- [Special values](#special-values)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Purchases?: Record<string, StorePurchaseState> }`\nand cached at `client.data.user.state?.Store?.Purchases`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/UserStoreState.cs`.\n\n```ts\ninterface StorePurchaseState {\n OfferID: string;\n TotalPurchases: number; // lifetime count, all-time\n DailyPurchases: number; // count since the last DailyResetUtc\n DailyResetUtc: string; // ISO — the next instant DailyPurchases resets to 0\n LastPurchasedAt: string; // ISO — server time of the last successful purchase\n}\n```\n\nThis is a **rate-limit counter store**, not a purchase-history log — it only\nholds what's needed to enforce `TotalCap`/`DailyCap` atomically. A separate\n`StorePurchaseHistoryDocument` audit-log collection exists server-side\n(`UserID`, `TitleID`, `OfferID`, `Count`, `Resources`, `PurchasedAt`) but it is\n**not exposed through any Store endpoint** — there is no \"purchase history\"\nclient call.\n\nA player with no purchase for a given `OfferID` simply has no entry in\n`Purchases` — treat a missing key as `TotalPurchases: 0`, `DailyPurchases: 0`,\nno active daily window.\n\n---\n\n## Config: StoreDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<StoreDefinitions>(\"Store\")`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreDefinitions {\n Stores?: Record<string, StoreDefinition> | null; // key = StoreID\n StoreOffers?: Record<string, StoreOfferDefinition> | null; // key = OfferID\n}\n```\n\nStorefronts and offers are deliberately separate catalogs: an offer references\nthe storefronts it appears in via `StoreIDs`, so the same SKU (price, rewards,\n`OfferID`, analytics) can be reused across the main shop, an event shop, a VIP\nshop, etc. without duplication.\n\n---\n\n## StoreDefinition (storefront)\n\nA logical shop screen (main / event / VIP). Does not embed offers.\n\n```ts\ninterface StoreDefinition {\n StoreID: string; // stable id; never rename after publication — offers reference it\n Type?: string; // segmentation/grouping tag, free-form\n Name?: string; // display name; optional for internal storefronts\n Description?: string;\n Rules?: StoreRules;\n AssetPaths?: Record<string, string>; // banner/icon/background, key = asset slug\n}\n\ninterface StoreRules {\n StartUtc?: string; // storefront opens at this UTC instant; absent = available from the start\n EndUtc?: string; // storefront closes at this UTC instant; absent = no expiration\n RequiredFlags?: string[]; // ALL must be set on the player for the storefront to show\n}\n```\n\n`StoreRules.RequiredFlags` is **not enforced by the `Store.Purchase` /\n`PurchaseBatch` endpoints** — the backend's purchase path (`Store.cs`) only\nvalidates the _offer's_ own `Rules` (window, `Gate`, `Limits`); it never looks\nup which storefront the purchase came through. Treat `StoreRules` purely as\nclient-side \"should I show this storefront\" filtering data, not as a\nserver-enforced purchase gate — the offer-level `Gate`/window/limits are the\nactual enforcement.\n\n---\n\n## StoreOfferDefinition (SKU)\n\nThe purchasable unit. Backend field-level docs from\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreOfferDefinition {\n OfferID: string; // stable id; used in analytics/purchase logs; never rename after publication\n StoreIDs?: string[]; // storefronts this offer appears in; empty/null = invisible everywhere\n Name?: string;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; see the checkout-system skill\n Rewards?: ResourceGrant; // grant-only; see currency-system skill for the shared shape\n Rules?: StoreOfferRules;\n AssetPaths?: Record<string, string>;\n}\n\n\nEvery price in this module is a **`PriceOptions` dictionary** (the platform-wide\nshape, see the `checkout-system` skill): the key is the `OptionID`, one option is\none way to pay, and the entries inside an option's `Cost` are charged together.\n`purchase()` takes the chosen id as `options.selectedOptionID`; omit it and the\nserver takes the first option available on the caller's platform, so a\nsingle-price offer needs no client change. An option whose `Cost` holds a\n`Purchase` entry is paid **in a store** — buy the product and pass the receipt as\n`options.payment`.\n\ninterface StoreOfferRules {\n StartUtc?: string; // offer becomes purchasable at this UTC instant; absent = from the start\n EndUtc?: string; // offer stops being purchasable at this UTC instant; absent = no expiration\n Gate?: SegmentGate; // \"who can buy this\" — premium tier/ID, segment, level, country, recency, experiment\n Limits?: LimitSpec; // purchase caps — see the matrix below\n}\n```\n\n`Gate` is the shared `SegmentGate` (Core/Segment) — all conditions AND-ed, an\nabsent/empty gate means available to everyone. Resolved server-side by\n`SegmentGateEvaluator.Passes` against the player's document at the moment of\npurchase (`Store.cs` line ~170: `\"Offer is not available for you.\"` on\nfailure).\n\n**Shape validation** (`StoreHelpers.ValidateOfferShape`, always run before a\npurchase is accepted): an offer with an empty `Cost` (no `Standard.Entries` and\nno `Standard.EventTokens`) fails with `\"Offer cost is empty.\"`; an offer with\nno `Rewards` at all (`Standard.Entries`, `Standard.EventTokens`, and\n`PremiumTiers` all empty) fails with `\"Offer rewards are empty.\"`. In other\nwords: **every real offer must both cost something and grant something** —\nthere is no free-claim or cost-only shape for Store offers (use the Reward or\nDealOffer module for pure-claim mechanics).\n\n---\n\n## Purchase-limit rule matrix\n\n`LimitSpec` (shared `Core/Limits` type; full field list in\n`packages/core/src/models/_shared/LimitModels.ts`) is reused across the SDK,\nbut Store's enforcement (`StoreHelpers.CheckPurchaseLimits` and\n`BuildPurchaseCounterPatches`, in\n`IDosGamesSDK/API/Client/v2/Store/Services/StoreHelpers.cs`) only reads two of\nits axes:\n\n| `LimitSpec` field | Meaning for Store | Enforcement |\n| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `TotalCap` | Lifetime purchase cap for the offer, summed over `Count` across all purchases | `TotalPurchases + count > TotalCap` → `\"Purchase limit reached for offer '<id>'. Max: <n>.\"` |\n| `DailyCap` | Per-UTC-day purchase cap | `DailyPurchases + count > DailyCap` (only while `now < DailyResetUtc`; otherwise treated as 0) → `\"Daily purchase limit reached for offer '<id>'. Max per day: <n>.\"` |\n\nOther `LimitSpec` axes (`DailyWeightCap`, `PerActivationCap`,\n`CooldownSeconds`, `MaxPerWindow`, `WindowSeconds`) exist on the shared type\nfor other modules but **Store does not read them** — configuring them on a\nStore offer's `Rules.Limits` has no effect on purchase behavior.\n\n**Daily reset timing.** `DailyResetUtc` is set to `now.Date.AddDays(1)` (the\nUTC midnight _after_ the purchase that (re)started the window) the first time\nan offer is bought, or whenever `now >= DailyResetUtc` on a subsequent\npurchase — i.e. the daily window is lazily rolled forward on the next\npurchase attempt, not on a schedule. If a player buys at 23:59 UTC and again\nat 00:01 UTC, the second purchase sees `now >= DailyResetUtc` from the first,\nresets `DailyPurchases` to the new `count`, and pushes `DailyResetUtc` to the\nfollowing midnight.\n\n**Race protection.** The fail-fast check in `CheckPurchaseLimits` runs before\nthe atomic write, but the real guarantee against concurrent double-spends past\nthe cap is an `extraFilter` attached to the same Mongo update\n(`BuildPurchaseCounterPatches`): the write only commits if\n`TotalPurchases <= TotalCap - count` (and the daily equivalent, tolerant of an\nexpired window) still holds at write time. If two concurrent requests would\nboth push a counter over its cap, only one commits — the loser's whole\n`ApplyResourceOperationAtomicAsync` call fails and the purchase is rejected,\nresources untouched.\n\n---\n\n## Purchase flow, scaling, and idempotency\n\nOrder of checks in `Store.StorePurchase` (`Store.cs`), all before any resource\nmutation:\n\n1. `OfferID` required; `count` clamped to **1–100**.\n2. Offer looked up by `OfferID` (optionally filtered by `storeID`, unused by\n the public `Purchase` action) — `\"Offer not found in the specified store.\"`\n if missing.\n3. Window check (`StartUtc`/`EndUtc`) — `\"Offer is not yet available.\"` /\n `\"Offer has expired.\"`.\n4. Shape check (`Cost` non-empty, `Rewards` non-empty) — see above.\n5. Player document read (single read, id/`InventoryV2`/`EventToken`/`Premium`/`Store` projection only).\n6. `Gate` check — `\"Offer is not available for you.\"`.\n7. Limit check (`CheckPurchaseLimits`) — see the matrix above.\n8. **Scaling**: `Cost` and `Rewards` are each scaled by `count` — every\n `ResourceEntry.Amount` and every `EventTokenOperation.Amount` is multiplied\n by `count` (a fresh object; the config definition itself is never mutated).\n `PremiumDiscounts`/`PremiumTiers` percentages are **not** scaled by count —\n only flat amounts are.\n9. The scaled `Cost`/`Rewards` become one `ResourceOperation { Grant, Consume }`\n applied via `ResourceService.ApplyResourceOperationAtomicAsync`, alongside\n the purchase-counter patches from step 7 and a `FeatureUsage` touch (see\n below), under one Mongo transaction with the `extraFilter` guard.\n10. On success, a best-effort audit-log row is appended\n (`StoreHelpers.AppendPurchaseHistoryAsync`) — failures here are swallowed\n and never affect the client response.\n\n**Idempotency.** The reason key is\n`\"StoreBuy:\" + ResourceService.ResolveRelatedEntityID(relatedEntityID, \"store_buy_{offerID}_{userID}\")`.\nThe SDK's `purchase()` always supplies a fresh, unique `RelatedEntityID`\n(`store_buy_{offerID}_{userID}_{uuid}`) per call — so from the client's\nperspective **every `purchase()` call is a brand-new charge**; the idempotency\nkey only protects against the transport layer's own internal retries within a\nsingle logical call, not against you calling `purchase()` twice.\n\n**`FeatureUsage` touch.** Every successful `Purchase` (regardless of `count`)\nincrements a `FeatureIDs.Store` usage touch exactly once — this is \"the player\nengaged the store,\" unrelated to and not a substitute for the per-offer\n`TotalPurchases`/`DailyPurchases` counters.\n\n---\n\n## Batch purchase semantics\n\n`PurchaseBatch` (`Store.PurchaseBatch` in `Store.cs`) trades N round-trips for\none, but keeps per-offer validation independent from the shared charge:\n\n**1. Normalization** — for each `StorePurchaseRef` in `args.Purchases`:\nblank/whitespace `OfferID` is dropped; `OfferID` is trimmed; duplicates by\n`OfferID` are dropped (first occurrence wins — **one offer per batch call**;\nuse `Count` for multiple units of the same offer, not repeated refs);\n`Count <= 0` is treated as `1`, then clamped to **1–100**; the list stops\ngrowing once it reaches `BatchSupport.MaxBatchSize` = **50** — refs beyond the\n50th are silently dropped and never appear in the result at all. An\nall-empty/invalid request (0 refs survive normalization) fails outright with\n`\"Purchases is required\"`.\n\n**2. One player read** for the whole batch (not per-offer).\n\n**3. Per-offer validation, outside the transaction** — for each surviving\n`(offerID, count)`, in order: offer exists → window → shape → `Gate` → purchase\nlimits (same checks and same error strings as the single-purchase path,\nkeyed per offer). Any failure here produces an immediate `BatchItemResult`\nwith `Success: false` and that specific `Error`, and **excludes the offer from\nthe merged charge** — it does not abort the batch.\n\nIf **zero** offers survive this stage, the call returns `Ok` with only the\nper-offer failure results (no atomic transaction is attempted).\n\n**4. Merge + one atomic charge** — for every surviving offer: `Cost`/`Rewards`\nare scaled by that offer's own `count`, then premium discounts/tiers are\nresolved and flattened per-offer (`ResourceService.FilterByPremium`) _before_\nmerging, so each offer's own premium tier is applied — the merge does not\ncreate a single blended discount. The flattened bundles from every surviving\noffer are summed into one `ResourceGrant`/`ResourceConsume`, together with the\npurchase-counter patches for every surviving offer and one shared\n`FeatureUsage` touch, and applied as a **single**\n`ApplyResourceOperationAtomicAsync` call with a combined `extraFilter`\n(AND of every offer's own race-protection filter).\n\n**All-or-nothing across survivors.** If the merged charge fails (e.g. can't\nafford the combined cost, or any one offer's `extraFilter` no longer holds),\n**every surviving offer** — even ones that were individually valid — comes\nback `Success: false` with `\"Atomic batch purchase failed: <reason>\"`. There is\nno partial application within the merged group; only the pre-filtered\nindividually-invalid offers were ever excluded.\n\n**5. Result shape and `Resources` placement.** The merged `ResourceOperation`\nreturned by the atomic call is attached to `Data.Resources` on **only the\nfirst successful item** in call order; every other successful item gets\n`Data.Resources = new ResourceOperation()` (empty, not null) — so summing\n`Resources` across all successful items double-counts nothing, but reading a\nnon-first item's `Resources` for balance info will show nothing. Read balances\nfrom the cache (which the SDK patches once per successful item's own\n`OfferID`/`Count`, so counters are correct for every item) rather than from\neach item's own `Resources`. `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc`\nare correct and independent for every successful item regardless of where\n`Resources` landed.\n\n**Reason key.** `BatchSupport.BuildBatchReason(\"StoreBuyBatch\", relatedEntityID, includedOfferIDs)`\n— one idempotency key covering the whole merged transaction, not one per\noffer.\n\n**Audit log.** On a successful merged charge, one best-effort history row is\nappended per surviving offer (same swallow-on-failure semantics as the single\npath).\n\n---\n\n## Special values\n\n- `Rules` absent entirely on a storefront or offer ⇒ no restriction on that\n axis (always visible / always purchasable / no gate / no limits).\n- `LimitSpec.TotalCap` / `DailyCap` `<= 0` (including absent, which the config\n default `LimitSpec` treats as `0`) ⇒ **unlimited** on that axis — the check\n is skipped entirely, not \"zero purchases allowed.\"\n- `StoreOfferDefinition.StoreIDs` empty or `null` ⇒ the offer exists in the\n catalog but is invisible in every storefront (it can still theoretically be\n purchased by `OfferID` directly, since `Purchase`'s `storeID` filter is\n unused by the public action — but there is no supported storefront UI path\n to reach it).\n- A player with no `Purchases[offerID]` entry is equivalent to\n `TotalPurchases: 0, DailyPurchases: 0`, with no active daily window (the\n `DailyExpired` check treats a missing state the same as an expired one).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "timed-boost-system",
|
|
3
3
|
"description": "Build temporary player boosts (XP/resource/stat multipliers) in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.timedBoost (TimedBoostService): load boost definitions (manual, scheduled \"happy hour\", chained, and auto-triggered), activate a manual boost, read the player's currently-active boost instances, read currently-active global boost windows, and clean up expired boosts. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants temporary multiplier/buff systems, double-XP or double-reward events, happy-hour style scheduled bonuses, boost stacking rules, or otherwise touches client.timedBoost, TimedBoostService, TimedBoostDefinitions, ActiveTimedBoost, or TimedBoostStackingPolicy — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: timed-boost-system\ndescription: >-\n Build temporary player boosts (XP/resource/stat multipliers) in a game on\n the iDosGames TypeScript SDK (@idosgames/core) via client.timedBoost\n (TimedBoostService): load boost definitions (manual, scheduled \"happy\n hour\", chained, and auto-triggered), activate a manual boost, read the\n player's currently-active boost instances, read currently-active global\n boost windows, and clean up expired boosts. Use this whenever the user is\n working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants temporary multiplier/buff systems, double-XP or\n double-reward events, happy-hour style scheduled bonuses, boost stacking\n rules, or otherwise touches client.timedBoost, TimedBoostService,\n TimedBoostDefinitions, ActiveTimedBoost, or TimedBoostStackingPolicy — even\n if they don't name the module explicitly.\n---\n\n# Timed boost system (iDosGames TS SDK)\n\nThe TimedBoost module grants players temporary numeric modifiers (XP\nmultipliers, resource-drop bonuses, stat buffs, …) that expire after a\nduration or run out of charges. Everything is **server-authoritative**: the\nclient asks the backend to activate a boost, the backend validates cost and\nstacking rules and stamps the expiry, and the SDK mirrors the confirmed\nresult into a local cache your UI reads.\n\nThis skill is for **using** the production `TimedBoostService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (cost, stacking cap) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Four kinds of boost, one effect shape\n\nAll boost effects share the same building block, `TimedBoostEffectSpec`\n(`{ Target, Operation, Value }` — `Operation` is `Multiply` | `AddPercent` |\n`AddFlat`, `Target` is a free-form modifier target string). What differs is\n_how_ the boost turns on:\n\n1. **Manual boosts** (`Definitions`) — player-activated via `activate(boostID)`.\n Have an `ActivationCost`, a single `Effect`, a duration and/or `Charges`,\n and a `StackingPolicy`. This is the only kind you activate yourself; the\n other three are server-driven and read-only from the client.\n2. **Scheduled boosts** (`ScheduledBoosts`) — global fixed windows (\"happy\n hour 6-7pm\"), driven by a `Schedule` (`ScheduleSpec`), no per-player state.\n Everyone online during the window gets the effect.\n3. **Boost chains** (`BoostChains`) — a cyclic sequence of `Phases`, each its\n own window with its own effects; also schedule-driven, global.\n4. **Triggered boosts** (`TriggeredBoosts`) — auto-granted to a player when a\n configured `Sources` event fires (e.g. completing a quest), becoming a\n per-player active boost identical in shape to a manual activation. The\n grant happens server-side, inside whichever module's action fired the\n trigger (e.g. a GameLoop roll) — there is no TimedBoost endpoint to invoke\n one, and no dedicated event for the grant itself. You only observe it by\n re-fetching `getActive()` after an action that could plausibly trigger one.\n\nKinds 2 and 3 are **global** and resolved server-side into \"what's active\nright now\" — read them with `getActiveWindows()`, not `getActive()`. Kinds 1\nand 4 are **per-player instances** with an `InstanceID` — read them with\n`getActive()`. See [references/data-model.md](references/data-model.md) for\nthe full config shape of all four, the stacking-policy resolution rules, and\nworked examples of overlapping boosts.\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 timedBoost = client.timedBoost; // the TimedBoostService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is\none of `\"client\"` (bad local args, e.g. an empty/invalid `BoostID`),\n`\"unauthorized\"`, `\"throttled\"` (600 ms default window), `\"connection\"`\n(transient, offer Retry), `\"validation\"`, or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. insufficient funds, unknown\nboost id, stacking cap reached).\n\n| Method | Purpose | `data` on success |\n| -------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------- |\n| `getDefinitions()` | Load the title's boost catalog (config: manual/scheduled/chained/triggered/settings). | `TimedBoostDefinitions` |\n| `getActive()` | Load this player's currently-active manual/triggered boost instances. | `GetActiveTimedBoostsResponse` (`Active`) |\n| `getActiveWindows()` | Load currently-active global scheduled/chain windows, resolved for \"now\". | `GetActiveBoostWindowsResponse` (`Windows`) |\n| `activate(boostID)` | Activate a manual boost (charges its `ActivationCost`). | `ActivateTimedBoostResponse` |\n| `cleanupExpired()` | Ask the server to purge expired active-boost entries, then refreshes `getActive()`. | `SuccessResponse` |\n\n`activate` trims and validates `boostID` client-side first (non-empty, no\n`.` or `$`) before making the request, returning `reason: \"client\"` locally\nif that fails — no round-trip wasted on an obviously bad id.\n\nOn success, each method mirrors the confirmed change into the cache and\nemits an event. `activate`'s consumed resources ride along in\n`data.Resources` and are already applied to cached balances.\n\n## Reading state and reacting to changes\n\n```ts\n// Currently-active per-player boost instances (present after getActive() or activate()):\nconst active = client.data.user.state?.TimedBoost?.Active ?? {};\nfor (const [instanceID, boost] of Object.entries(active)) {\n boost.BoostID;\n boost.ExpiresAtUtc;\n boost.RemainingCharges;\n boost.EffectSnapshot; // { Target, Operation, Value } captured at activation time\n}\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedBoostDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedBoost:definitionsLoaded` → `TimedBoostDefinitions`\n- `timedBoost:activeLoaded` → `GetActiveTimedBoostsResponse`\n- `timedBoost:activeWindowsLoaded` → `GetActiveBoostWindowsResponse`\n- `timedBoost:activated` → `ActivateTimedBoostResponse`\n- `timedBoost:expiredCleaned` → `void`\n\nThe coarse `user:timedBoostUpdated` (and umbrella `user:anyUpdated`) also\nfire on any TimedBoost cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedBoost:activated\", (r) => {\n console.log(`${r.BoostID} active until`, r.ActivatedBoost?.ExpiresAtUtc);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show available boosts and what's currently active\n\n```ts\nawait client.timedBoost.getDefinitions();\nawait client.timedBoost.getActive();\n\nconst defs = client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\");\nconst active = client.data.user.state?.TimedBoost?.Active ?? {};\n\nfor (const [boostID, def] of Object.entries(defs?.Definitions ?? {})) {\n const running = Object.values(active).find((b) => b.BoostID === boostID);\n // running present → show remaining time/charges + a \"already active\" state;\n // absent → show def.ActivationCost and an Activate button.\n}\n```\n\n### Activate a boost\n\n```ts\nconst res = await client.timedBoost.activate(\"double-xp-1h\");\nif (!res.ok) return showError(res.error); // e.g. can't afford, unknown boost id\nres.data.ActivatedBoost?.ExpiresAtUtc; // when it runs out\nres.data.StackingPolicy; // how it combined with any existing instance of this boost\n// cache now has the active instance; balances already debited.\n```\n\n### Show global \"happy hour\" / chain windows\n\n```ts\nconst res = await client.timedBoost.getActiveWindows();\nif (!res.ok) return showError(res.error);\nfor (const w of res.data.Windows ?? []) {\n w.Kind; // \"Scheduled\" | \"Chain\"\n w.DisplayName;\n w.EndUtc; // countdown target\n w.Effects; // effects live for everyone while this window is open\n}\n```\n\nThese are global — there's nothing to \"activate\"; just poll/refresh\nperiodically (or on screen focus) to reflect whether a window is currently\nopen, and use `EndUtc` to drive a countdown.\n\n### Clean up expired boosts\n\n```ts\nconst res = await client.timedBoost.cleanupExpired();\nif (!res.ok) return;\n// getActive() has already been re-run internally; client.data.user.state\n// ?.TimedBoost?.Active reflects the purge.\n```\n\nCall this on screen entry or session resume so a `RemainingCharges: 0` or\npast-`ExpiresAtUtc` entry doesn't linger in the UI. `getActive()` alone\ndoesn't purge server-side state — it can still return an expired-looking\nentry until `cleanupExpired()` (or the backend's own lazy cleanup) runs.\n\n## Gotchas\n\n- **`EffectSnapshot` is frozen at activation time.** If the title later\n edits a boost's definition, already-active instances keep whatever\n `Effect` was live when they were activated — don't re-derive the running\n effect from the current `Definitions` entry.\n- **Stacking is resolved by the server, mirrored simply on the client.** The\n SDK's local cache write (`patchActiveTimedBoost`) only special-cases\n `Replace`/`KeepBest` by deleting other instances of the _same_ `BoostID`\n before inserting the new one; `Refresh`/`Stack` just insert. The actual\n cost/cap enforcement (e.g. `Settings.StackingCaps` per target) is entirely\n server-side — don't assume the client cache alone tells you the effective\n combined modifier. See\n [references/data-model.md](references/data-model.md).\n- **Guard against double-submit.** Each `activate` call mints a fresh\n `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" can charge twice. Disable the control while a\n call is in flight.\n- **`getActive()` and `getActiveWindows()` are different data.** Manual/\n triggered boosts (per-player, `InstanceID`-keyed) never appear in\n `getActiveWindows()`'s `Windows` array, and scheduled/chain windows never\n appear in `getActive()`'s `Active` map. Query both if your UI needs to show\n \"everything boosting me right now.\"\n- **`cleanupExpired` re-triggers `getActive()` internally** — you don't need\n to call `getActive()` again right after; just read the cache once\n `cleanupExpired()` resolves.\n- **Never derive the boosted number yourself.** The server folds every live\n effect for a target (your active instances + open windows, already capped\n per `Settings.StackingCaps`) into one calculation in a fixed order —\n flat adds, then percent, then multiplies — inside the endpoint that performs\n the boosted action (e.g. GameLoop's roll resolution), not inside TimedBoost.\n Use `EffectSnapshot`/`Effects` only to describe a boost in a tooltip; read\n the actual outcome (reward amount, cost) from that action's own response.\n See [references/data-model.md](references/data-model.md) if you need the\n exact formula for a preview estimate.\n- **Triggered-boost grants have no client hook to react to precisely when they\n happen** — they ride inside another module's atomic write. If your UI wants\n to celebrate \"you got a bonus boost,\" refresh `getActive()` after actions\n that can plausibly grant one and diff against what you had before.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config shape for\nall four boost kinds, the stacking-policy semantics, how scheduled/chain\nwindows get resolved into \"active now,\" and the server's effect-blend formula.\nRead it when building a boost catalog screen, a countdown UI driven by chain\nphases, a numeric preview of what a boost will do, or anything that needs to\nreason about how multiple active boosts combine.\n",
|
|
4
|
+
"content": "---\nname: timed-boost-system\ndescription: >-\n Build temporary player boosts (XP/resource/stat multipliers) in a game on\n the iDosGames TypeScript SDK (@idosgames/core) via client.timedBoost\n (TimedBoostService): load boost definitions (manual, scheduled \"happy\n hour\", chained, and auto-triggered), activate a manual boost, read the\n player's currently-active boost instances, read currently-active global\n boost windows, and clean up expired boosts. Use this whenever the user is\n working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants temporary multiplier/buff systems, double-XP or\n double-reward events, happy-hour style scheduled bonuses, boost stacking\n rules, or otherwise touches client.timedBoost, TimedBoostService,\n TimedBoostDefinitions, ActiveTimedBoost, or TimedBoostStackingPolicy — even\n if they don't name the module explicitly.\n---\n\n# Timed boost system (iDosGames TS SDK)\n\nThe TimedBoost module grants players temporary numeric modifiers (XP\nmultipliers, resource-drop bonuses, stat buffs, …) that expire after a\nduration or run out of charges. Everything is **server-authoritative**: the\nclient asks the backend to activate a boost, the backend validates cost and\nstacking rules and stamps the expiry, and the SDK mirrors the confirmed\nresult into a local cache your UI reads.\n\nThis skill is for **using** the production `TimedBoostService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (cost, stacking cap) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Four kinds of boost, one effect shape\n\nAll boost effects share the same building block, `TimedBoostEffectSpec`\n(`{ Target, Operation, Value }` — `Operation` is `Multiply` | `AddPercent` |\n`AddFlat`, `Target` is a free-form modifier target string). What differs is\n_how_ the boost turns on:\n\n1. **Manual boosts** (`Definitions`) — player-activated via `activate(boostID)`.\n Have `PriceOptions`, a single `Effect`, a duration and/or `Charges`,\n and a `StackingPolicy`. This is the only kind you activate yourself; the\n other three are server-driven and read-only from the client.\n2. **Scheduled boosts** (`ScheduledBoosts`) — global fixed windows (\"happy\n hour 6-7pm\"), driven by a `Schedule` (`ScheduleSpec`), no per-player state.\n Everyone online during the window gets the effect.\n3. **Boost chains** (`BoostChains`) — a cyclic sequence of `Phases`, each its\n own window with its own effects; also schedule-driven, global.\n4. **Triggered boosts** (`TriggeredBoosts`) — auto-granted to a player when a\n configured `Sources` event fires (e.g. completing a quest), becoming a\n per-player active boost identical in shape to a manual activation. The\n grant happens server-side, inside whichever module's action fired the\n trigger (e.g. a GameLoop roll) — there is no TimedBoost endpoint to invoke\n one, and no dedicated event for the grant itself. You only observe it by\n re-fetching `getActive()` after an action that could plausibly trigger one.\n\nKinds 2 and 3 are **global** and resolved server-side into \"what's active\nright now\" — read them with `getActiveWindows()`, not `getActive()`. Kinds 1\nand 4 are **per-player instances** with an `InstanceID` — read them with\n`getActive()`. See [references/data-model.md](references/data-model.md) for\nthe full config shape of all four, the stacking-policy resolution rules, and\nworked examples of overlapping boosts.\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 timedBoost = client.timedBoost; // the TimedBoostService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is\none of `\"client\"` (bad local args, e.g. an empty/invalid `BoostID`),\n`\"unauthorized\"`, `\"throttled\"` (600 ms default window), `\"connection\"`\n(transient, offer Retry), `\"validation\"`, or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. insufficient funds, unknown\nboost id, stacking cap reached).\n\n| Method | Purpose | `data` on success |\n| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |\n| `getDefinitions()` | Load the title's boost catalog (config: manual/scheduled/chained/triggered/settings). | `TimedBoostDefinitions` |\n| `getActive()` | Load this player's currently-active manual/triggered boost instances. | `GetActiveTimedBoostsResponse` (`Active`) |\n| `getActiveWindows()` | Load currently-active global scheduled/chain windows, resolved for \"now\". | `GetActiveBoostWindowsResponse` (`Windows`) |\n| `activate(boostID, options?)` | Activate a manual boost (charges the selected `PriceOptions` option; `options` carries `selectedOptionID` / `payment`). | `ActivateTimedBoostResponse` |\n| `cleanupExpired()` | Ask the server to purge expired active-boost entries, then refreshes `getActive()`. | `SuccessResponse` |\n\n`activate` trims and validates `boostID` client-side first (non-empty, no\n`.` or `$`) before making the request, returning `reason: \"client\"` locally\nif that fails — no round-trip wasted on an obviously bad id.\n\nOn success, each method mirrors the confirmed change into the cache and\nemits an event. `activate`'s consumed resources ride along in\n`data.Resources` and are already applied to cached balances.\n\n## Reading state and reacting to changes\n\n```ts\n// Currently-active per-player boost instances (present after getActive() or activate()):\nconst active = client.data.user.state?.TimedBoost?.Active ?? {};\nfor (const [instanceID, boost] of Object.entries(active)) {\n boost.BoostID;\n boost.ExpiresAtUtc;\n boost.RemainingCharges;\n boost.EffectSnapshot; // { Target, Operation, Value } captured at activation time\n}\n\n// Definitions (cached after getDefinitions()):\nimport type { TimedBoostDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `timedBoost:definitionsLoaded` → `TimedBoostDefinitions`\n- `timedBoost:activeLoaded` → `GetActiveTimedBoostsResponse`\n- `timedBoost:activeWindowsLoaded` → `GetActiveBoostWindowsResponse`\n- `timedBoost:activated` → `ActivateTimedBoostResponse`\n- `timedBoost:expiredCleaned` → `void`\n\nThe coarse `user:timedBoostUpdated` (and umbrella `user:anyUpdated`) also\nfire on any TimedBoost cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"timedBoost:activated\", (r) => {\n console.log(`${r.BoostID} active until`, r.ActivatedBoost?.ExpiresAtUtc);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show available boosts and what's currently active\n\n```ts\nawait client.timedBoost.getDefinitions();\nawait client.timedBoost.getActive();\n\nconst defs = client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\");\nconst active = client.data.user.state?.TimedBoost?.Active ?? {};\n\nfor (const [boostID, def] of Object.entries(defs?.Definitions ?? {})) {\n const running = Object.values(active).find((b) => b.BoostID === boostID);\n // running present → show remaining time/charges + a \"already active\" state;\n // absent → show def.PriceOptions (via client.checkout.availableOptions) and an Activate button.\n}\n```\n\n### Activate a boost\n\n```ts\nconst res = await client.timedBoost.activate(\"double-xp-1h\");\nif (!res.ok) return showError(res.error); // e.g. can't afford, unknown boost id\nres.data.ActivatedBoost?.ExpiresAtUtc; // when it runs out\nres.data.StackingPolicy; // how it combined with any existing instance of this boost\n// cache now has the active instance; balances already debited.\n```\n\n### Show global \"happy hour\" / chain windows\n\n```ts\nconst res = await client.timedBoost.getActiveWindows();\nif (!res.ok) return showError(res.error);\nfor (const w of res.data.Windows ?? []) {\n w.Kind; // \"Scheduled\" | \"Chain\"\n w.DisplayName;\n w.EndUtc; // countdown target\n w.Effects; // effects live for everyone while this window is open\n}\n```\n\nThese are global — there's nothing to \"activate\"; just poll/refresh\nperiodically (or on screen focus) to reflect whether a window is currently\nopen, and use `EndUtc` to drive a countdown.\n\n### Clean up expired boosts\n\n```ts\nconst res = await client.timedBoost.cleanupExpired();\nif (!res.ok) return;\n// getActive() has already been re-run internally; client.data.user.state\n// ?.TimedBoost?.Active reflects the purge.\n```\n\nCall this on screen entry or session resume so a `RemainingCharges: 0` or\npast-`ExpiresAtUtc` entry doesn't linger in the UI. `getActive()` alone\ndoesn't purge server-side state — it can still return an expired-looking\nentry until `cleanupExpired()` (or the backend's own lazy cleanup) runs.\n\n## Gotchas\n\n- **`EffectSnapshot` is frozen at activation time.** If the title later\n edits a boost's definition, already-active instances keep whatever\n `Effect` was live when they were activated — don't re-derive the running\n effect from the current `Definitions` entry.\n- **Stacking is resolved by the server, mirrored simply on the client.** The\n SDK's local cache write (`patchActiveTimedBoost`) only special-cases\n `Replace`/`KeepBest` by deleting other instances of the _same_ `BoostID`\n before inserting the new one; `Refresh`/`Stack` just insert. The actual\n cost/cap enforcement (e.g. `Settings.StackingCaps` per target) is entirely\n server-side — don't assume the client cache alone tells you the effective\n combined modifier. See\n [references/data-model.md](references/data-model.md).\n- **Guard against double-submit.** Each `activate` call mints a fresh\n `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" can charge twice. Disable the control while a\n call is in flight.\n- **`getActive()` and `getActiveWindows()` are different data.** Manual/\n triggered boosts (per-player, `InstanceID`-keyed) never appear in\n `getActiveWindows()`'s `Windows` array, and scheduled/chain windows never\n appear in `getActive()`'s `Active` map. Query both if your UI needs to show\n \"everything boosting me right now.\"\n- **`cleanupExpired` re-triggers `getActive()` internally** — you don't need\n to call `getActive()` again right after; just read the cache once\n `cleanupExpired()` resolves.\n- **Never derive the boosted number yourself.** The server folds every live\n effect for a target (your active instances + open windows, already capped\n per `Settings.StackingCaps`) into one calculation in a fixed order —\n flat adds, then percent, then multiplies — inside the endpoint that performs\n the boosted action (e.g. GameLoop's roll resolution), not inside TimedBoost.\n Use `EffectSnapshot`/`Effects` only to describe a boost in a tooltip; read\n the actual outcome (reward amount, cost) from that action's own response.\n See [references/data-model.md](references/data-model.md) if you need the\n exact formula for a preview estimate.\n- **Triggered-boost grants have no client hook to react to precisely when they\n happen** — they ride inside another module's atomic write. If your UI wants\n to celebrate \"you got a bonus boost,\" refresh `getActive()` after actions\n that can plausibly grant one and diff against what you had before.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config shape for\nall four boost kinds, the stacking-policy semantics, how scheduled/chain\nwindows get resolved into \"active now,\" and the server's effect-blend formula.\nRead it when building a boost catalog screen, a countdown UI driven by chain\nphases, a numeric preview of what a boost will do, or anything that needs to\nreason about how multiple active boosts combine.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# TimedBoost data model — reference\n\nFull config shape for all four boost kinds, the runtime/state shape, and how\nstacking and global windows resolve. All types are **strictly typed in the\nSDK** — `TimedBoostDefinitions` and every nested block are exported from\n`@idosgames/core` with `.passthrough()` schemas, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Config: TimedBoostDefinitions](#config-timedboostdefinitions)\n- [TimedBoostEffectSpec (shared effect shape)](#timedboosteffectspec)\n- [Manual boosts (Definitions)](#manual-boosts-definitions)\n- [Scheduled boosts & boost chains (global windows)](#scheduled-boosts--boost-chains-global-windows)\n- [Triggered boosts](#triggered-boosts)\n- [Global settings & stacking caps](#global-settings--stacking-caps)\n- [Runtime state & responses](#runtime-state--responses)\n- [Stacking policy semantics](#stacking-policy-semantics)\n- [How effects resolve into a number (server-side)](#how-effects-resolve-into-a-number-server-side)\n- [Triggered boosts are granted by other modules, not TimedBoost itself](#triggered-boosts-are-granted-by-other-modules-not-timedboost-itself)\n\n---\n\n## Config: TimedBoostDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\")`.\n\n```ts\ninterface TimedBoostDefinitions {\n Definitions?: Record<string, TimedBoostDefinition>; // manual, key = BoostID\n ScheduledBoosts?: Record<string, ScheduledBoostDefinition>; // global fixed windows\n BoostChains?: Record<string, BoostChainDefinition>; // global cyclic chains\n TriggeredBoosts?: Record<string, TriggeredBoostDefinition>; // auto-granted\n Settings?: TimedBoostGlobalSettings; // per-target stacking caps\n}\n```\n\nAll four catalogs live side by side; a title can mix manual, scheduled,\nchained, and triggered boosts freely — they don't share IDs or interact\nexcept through the shared `Settings.StackingCaps`.\n\n---\n\n## TimedBoostEffectSpec\n\nThe one effect shape every boost kind uses, alone (`Effect`) or in a list\n(`Effects`).\n\n```ts\ninterface TimedBoostEffectSpec {\n Target?: string; // free-form modifier target (EventModifierTarget on the backend)\n Operation?: string; // \"Multiply\" | \"AddPercent\" | \"AddFlat\"\n Value?: number; // meaning depends on Operation\n}\n```\n\n`Operation` semantics: `Multiply` scales the target value by `Value` (e.g.\n`2` = double), `AddPercent` adds `Value` percent, `AddFlat` adds a flat\n`Value`. Multiple effects on the same `Target` combine per the stacking rules\nbelow and the title's `Settings.StackingCaps` for that target.\n\n---\n\n## Manual boosts (Definitions)\n\nThe only kind activated by the player, via `activate(boostID)`.\n\n```ts\ninterface TimedBoostDefinition {\n BoostID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n ActivationCost?: ResourceConsume; // charged by activate()\n Effect?: TimedBoostEffectSpec; // single effect (not a list, unlike the other 3 kinds)\n DurationSeconds?: number; // time-based expiry\n Charges?: number; // use-count-based expiry (independent of/alongside duration)\n StackingPolicy?: string; // \"Replace\" | \"Refresh\" | \"KeepBest\" | \"Stack\"\n MaxActiveInstances?: number; // cap on simultaneous instances of this BoostID\n Tags?: string[];\n}\n```\n\nA manual boost can expire by time (`DurationSeconds` → `ExpiresAtUtc`), by\nuse (`Charges` → `RemainingCharges` ticking down), or both — whichever runs\nout first ends it. `ActivationCost` follows the same `ResourceConsume` shape\nused across the SDK (see `_shared/ResourceModels.ts`), including\n`PremiumDiscounts` — the charged amount can be less than the displayed base\nif the player has a subscription tier.\n\n---\n\n## Scheduled boosts & boost chains (global windows)\n\nBoth are **global** — no per-player state, no `activate()` call. They're\nresolved server-side into \"what's open right now\" and read via\n`getActiveWindows()`.\n\n```ts\ninterface ScheduledBoostDefinition {\n ScheduledBoostID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tags?: string[];\n Schedule?: ScheduleSpec; // Mode = Scheduled — fixed windows, e.g. \"happy hour\"\n Effects?: TimedBoostEffectSpec[];\n Gate?: SegmentGate; // optional player-segment restriction\n CustomParams?: Record<string, string>;\n}\n\ninterface BoostChainDefinition {\n ChainID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode = Chained — cyclic sequence of phases\n Phases?: ChainedBoostDefinition[];\n Gate?: SegmentGate;\n CustomParams?: Record<string, string>;\n}\n\ninterface ChainedBoostDefinition {\n ChainedBoostID?: string;\n Order?: number; // position within the cycle\n DurationSec?: number; // how long this phase stays active\n Effects?: TimedBoostEffectSpec[];\n CustomParams?: Record<string, string>;\n}\n```\n\nA `BoostChainDefinition` cycles through its `Phases` in `Order`, each active\nfor its own `DurationSec`, then loops. `getActiveWindows()` tells you which\nphase (if any) is currently open, plus `CycleIndex`/`PhaseOrder` to locate it\nwithin the cycle. `Gate` (a `SegmentGate`) can restrict a scheduled boost or\nchain to specific player segments — a window can be \"open\" globally but not\napply to every player.\n\n---\n\n## Triggered boosts\n\nAuto-granted per-player when a configured source event fires — no manual\nactivation, but otherwise becomes a normal `ActiveTimedBoost` instance (same\nshape as a manual activation, appears in `getActive()`).\n\n```ts\ninterface TriggeredBoostDefinition {\n TriggeredBoostID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Tags?: string[];\n Sources?: TriggerSource[]; // what fires this (e.g. quest completion)\n Effects?: TimedBoostEffectSpec[];\n DurationSeconds?: number;\n Charges?: number;\n StackingPolicy?: string;\n MaxActiveInstances?: number;\n Gate?: SegmentGate;\n}\n```\n\nThere's no client method to invoke a triggered boost — it's granted\nserver-side as a consequence of another action (per `TriggerSource`). The\nclient only ever observes it appearing in `getActive()`'s `Active` map, with\n`SourceType: \"EventReward\"`-ish provenance recorded on `ActiveTimedBoost`\n(see below).\n\n---\n\n## Global settings & stacking caps\n\n```ts\ninterface TimedBoostGlobalSettings {\n StackingCaps?: Record<string, BoostStackCap>; // key = modifier Target\n}\n\ninterface BoostStackCap {\n MaxAddPercent?: number;\n MaxMultiply?: number;\n MaxAddFlat?: number;\n}\n```\n\nPer-`Target` ceilings on the _combined_ contribution across every\nsimultaneously-active effect touching that target (manual + triggered +\nscheduled + chain, all of it) — e.g. even if five boosts each add +50%\nsomewhere, the server clamps the effective total per `MaxAddPercent`. This is\nenforced entirely server-side; the client never computes the combined\nmodifier itself.\n\n---\n\n## Runtime state & responses\n\nPer-player active instances, cached at `client.data.user.state?.TimedBoost`:\n\n```ts\ninterface UserTimedBoostsState {\n Active?: Record<string, ActiveTimedBoost>; // key = InstanceID\n Version?: number;\n Triggers?: Record<string, BoostTriggerCounter>; // per-trigger daily counters (server-side bookkeeping)\n}\n\ninterface ActiveTimedBoost {\n InstanceID: string;\n BoostID: string;\n ActivatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RemainingCharges?: number;\n EffectSnapshot?: TimedBoostEffectSpec; // frozen copy of Effect at activation time\n SourceType?: string; // \"Activation\" | \"Admin\" | \"EventReward\"\n SourceRef?: string;\n}\n```\n\n`SourceType` tells you how an instance came to exist: `\"Activation\"` (player\ncalled `activate`), `\"Admin\"` (ops-granted), `\"EventReward\"` (a\n`TriggeredBoostDefinition` fired). All three share the same `Active` map and\n`ActiveTimedBoost` shape — the UI doesn't need to special-case triggered\nboosts once they're active.\n\nGlobal window read, not cached in `user.state` (returned directly by\n`getActiveWindows()`, re-fetch to refresh):\n\n```ts\ninterface ActiveBoostWindowInfo {\n Kind?: string; // \"Scheduled\" | \"Chain\"\n SourceID?: string; // ScheduledBoostID or ChainID\n PhaseID?: string; // set only for Kind = \"Chain\"\n CycleIndex?: number; // which cycle iteration, Chain only\n PhaseOrder?: number; // Order of the active phase, Chain only\n StartUtc?: string;\n EndUtc?: string;\n Effects?: TimedBoostEffectSpec[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n}\n```\n\n---\n\n## Stacking policy semantics\n\n`StackingPolicy` (on `TimedBoostDefinition`/`TriggeredBoostDefinition`)\ngoverns what happens when a **new instance of the same `BoostID`** would\nbecome active while one already is. It does **not** govern interaction\n_between different_ `BoostID`s targeting the same modifier — that's what\n`Settings.StackingCaps` is for.\n\n| Policy | Behavior when re-activated/re-triggered while already active |\n| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Replace` | Existing instance(s) of this `BoostID` are removed; the new one becomes the only one. |\n| `KeepBest` | Same removal behavior as `Replace` on the client cache — existing same-`BoostID` instances are dropped in favor of the new one. (Which one counts as \"best\" when cost/duration differ is a server decision; the client mirrors whatever the server returns as `ActivatedBoost`.) |\n| `Refresh` | The new instance is simply inserted alongside — in practice this is how a \"refresh the timer\" boost re-stamps its expiry, since the server is expected to reuse the same effective slot server-side; the client only ever inserts, it never predicts what the server did to existing entries. |\n| `Stack` | The new instance is inserted alongside existing ones with no removal — multiple concurrent instances of the same `BoostID`, each with its own `InstanceID`, `ExpiresAtUtc`, and `RemainingCharges`. |\n\nClient cache mechanics (`UserData.patchActiveTimedBoost`, exact behavior):\n\n- For `Replace` and `KeepBest`: every existing entry in `Active` whose\n `BoostID` matches the newly-activated boost's `BoostID` (and whose\n `InstanceID` differs from the new one) is deleted, then the new instance is\n inserted.\n- For `Refresh` and `Stack` (anything else): no deletion — the new instance\n is inserted directly, alongside whatever was already there.\n\nBecause this logic runs **only on `activate()`'s own response** (matching by\nthe just-activated boost's own `BoostID`), it never touches instances of a\n_different_ `BoostID`, and it never touches triggered-boost instances unless\nyou happen to activate a manual boost with the same `BoostID` (unlikely by\nconvention, but not enforced client-side). Always treat `ActivatedBoost` and\n`StackingPolicy` from the `activate()` response as authoritative for what the\nserver actually did — the client cache write is a straightforward mirror of\nthat decision, not an independent computation.\n\n### Worked example\n\nPlayer has `Active = { \"i1\": { BoostID: \"double-xp\", ExpiresAtUtc: T+1h } }`\nand calls `activate(\"double-xp\")` again:\n\n- `StackingPolicy: \"Replace\"` → `i1` is deleted, `Active` ends up with only\n the new instance (`i2`).\n- `StackingPolicy: \"Stack\"` → `Active` ends up with **both** `i1` and `i2`,\n each independently expiring; UI showing \"time remaining\" should sum or\n list them, not assume a single instance per `BoostID`.\n\nDesign UI around \"one boost can have N concurrent instances\" rather than\nassuming `BoostID` is unique in `Active` — only `Replace`/`KeepBest`-policy\nboosts are guaranteed unique.\n\n`KeepBest`'s \"better\" comparison is **magnitude-first**: it compares\n`|EffectSnapshot.Value|` between the candidate and the current best live\ninstance, and only falls back to comparing `ExpiresAtUtc` (longer TTL wins)\nwhen the magnitudes are equal. A `Replace`-style boost re-activated while\nalready active always produces a fresh `InstanceID` (the old one is deleted,\nnot reused) — don't key long-lived UI state off `InstanceID` surviving a\nreactivation.\n\n---\n\n## How effects resolve into a number (server-side)\n\nYou never compute this — it's documented here only so boost-preview UI\n(\"this will make your next roll worth X\") can explain what a multiplier does\nwithout inventing its own math. The blend of `AddFlat` / `AddPercent` /\n`Multiply` entries collected for a target (per-player boosts + active windows,\nalready through the `Settings.StackingCaps` clamp above) is applied by the\nshared `ModifierService` in a fixed order:\n\n1. `step1 = base + sum(AddFlat)`\n2. `step2 = step1 * max(0, 1 + sum(AddPercent))`\n3. `step3 = step2 * product(Multiply, Multiply, ...)`\n4. `final = Ceiling(step3)`, clamped to `[0, long.MaxValue]`\n\nE.g. `base=100` with one `AddFlat(10)`, one `AddPercent(0.5)`, one\n`Multiply(2.0)` → `(100+10) * 1.5 * 2.0 = 330`. Each individual `AddPercent`/\n`Multiply` entry is also clamped before entering the sum/product\n(`AddPercent` to `[-100%, +9900%]`, `Multiply` to `[0.01, 100]`) — a title\ncan't accidentally zero out or blow up a calculation with one bad config\nvalue. This whole pipeline runs inside the endpoint that actually performs\nthe boosted action (e.g. GameLoop's roll/attack resolution) — TimedBoost only\nsupplies the raw effect entries via `BuildModifierEntries`; it never runs the\nmath itself for a gameplay call, and neither should the client.\n\n---\n\n## Triggered boosts are granted by other modules, not TimedBoost itself\n\n`TimedBoostV2` (the HTTP surface this SDK talks to) only implements\n`GetDefinitions` / `GetActive` / `GetActiveWindows` / `Activate` /\n`CleanupExpired` — there is no endpoint to \"fire\" a trigger. The actual grant\nhappens inside whichever module's action produced the triggering event: that\nmodule's handler calls the shared `TimedBoostService.BuildTriggeredGrants(...)`\n(backend domain helper, not the client-facing `TimedBoostService.ts`) with the\nevent's `TriggerSource` context, folds the resulting patches into its own\natomic write, and — if anything was granted — consumes charges off any\nalready-active charge-based boosts that applied to the same action via\n`BuildChargeConsume`. The client's only visibility into any of this is the\n`Active` map changing between calls to `getActive()`; there's nothing to\nsubscribe to at the moment of the trigger itself, so poll/refresh\n`getActive()` after actions that plausibly grant a triggered boost (a quest\ncompletion, a board-loop roll, etc.) if your UI wants to surface \"you got a\nbonus boost!\" promptly.\n"
|
|
8
|
+
"content": "# TimedBoost data model — reference\n\nFull config shape for all four boost kinds, the runtime/state shape, and how\nstacking and global windows resolve. All types are **strictly typed in the\nSDK** — `TimedBoostDefinitions` and every nested block are exported from\n`@idosgames/core` with `.passthrough()` schemas, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Config: TimedBoostDefinitions](#config-timedboostdefinitions)\n- [TimedBoostEffectSpec (shared effect shape)](#timedboosteffectspec)\n- [Manual boosts (Definitions)](#manual-boosts-definitions)\n- [Scheduled boosts & boost chains (global windows)](#scheduled-boosts--boost-chains-global-windows)\n- [Triggered boosts](#triggered-boosts)\n- [Global settings & stacking caps](#global-settings--stacking-caps)\n- [Runtime state & responses](#runtime-state--responses)\n- [Stacking policy semantics](#stacking-policy-semantics)\n- [How effects resolve into a number (server-side)](#how-effects-resolve-into-a-number-server-side)\n- [Triggered boosts are granted by other modules, not TimedBoost itself](#triggered-boosts-are-granted-by-other-modules-not-timedboost-itself)\n\n---\n\n## Config: TimedBoostDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedBoostDefinitions>(\"TimedBoost\")`.\n\n```ts\ninterface TimedBoostDefinitions {\n Definitions?: Record<string, TimedBoostDefinition>; // manual, key = BoostID\n ScheduledBoosts?: Record<string, ScheduledBoostDefinition>; // global fixed windows\n BoostChains?: Record<string, BoostChainDefinition>; // global cyclic chains\n TriggeredBoosts?: Record<string, TriggeredBoostDefinition>; // auto-granted\n Settings?: TimedBoostGlobalSettings; // per-target stacking caps\n}\n```\n\nAll four catalogs live side by side; a title can mix manual, scheduled,\nchained, and triggered boosts freely — they don't share IDs or interact\nexcept through the shared `Settings.StackingCaps`.\n\n---\n\n## TimedBoostEffectSpec\n\nThe one effect shape every boost kind uses, alone (`Effect`) or in a list\n(`Effects`).\n\n```ts\ninterface TimedBoostEffectSpec {\n Target?: string; // free-form modifier target (EventModifierTarget on the backend)\n Operation?: string; // \"Multiply\" | \"AddPercent\" | \"AddFlat\"\n Value?: number; // meaning depends on Operation\n}\n```\n\n`Operation` semantics: `Multiply` scales the target value by `Value` (e.g.\n`2` = double), `AddPercent` adds `Value` percent, `AddFlat` adds a flat\n`Value`. Multiple effects on the same `Target` combine per the stacking rules\nbelow and the title's `Settings.StackingCaps` for that target.\n\n---\n\n## Manual boosts (Definitions)\n\nThe only kind activated by the player, via `activate(boostID)`.\n\n```ts\ninterface TimedBoostDefinition {\n BoostID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n PriceOptions?: Record<string, PriceOption>; // ways to pay; the selected one is charged by activate()\n Effect?: TimedBoostEffectSpec; // single effect (not a list, unlike the other 3 kinds)\n DurationSeconds?: number; // time-based expiry\n Charges?: number; // use-count-based expiry (independent of/alongside duration)\n StackingPolicy?: string; // \"Replace\" | \"Refresh\" | \"KeepBest\" | \"Stack\"\n MaxActiveInstances?: number; // cap on simultaneous instances of this BoostID\n Tags?: string[];\n}\n```\n\nA manual boost can expire by time (`DurationSeconds` → `ExpiresAtUtc`), by\nuse (`Charges` → `RemainingCharges` ticking down), or both — whichever runs\nout first ends it. Each option's `Cost` follows the same `ResourceConsume` shape\nused across the SDK (see `_shared/ResourceModels.ts`), including\n`PremiumDiscounts` — the charged amount can be less than the displayed base\nif the player has a subscription tier.\n\n`PriceOptions` is the platform-wide price shape: the dictionary key is the\n`OptionID`, `activate()` takes it as `selectedOptionID`, and omitting it takes the\nfirst option available on the caller's platform. An option whose `Cost` holds a\n`Purchase` entry is paid **in a store** — pass the receipt as `activate()`'s\n`payment`. See the `checkout-system` skill.\n\n---\n\n## Scheduled boosts & boost chains (global windows)\n\nBoth are **global** — no per-player state, no `activate()` call. They're\nresolved server-side into \"what's open right now\" and read via\n`getActiveWindows()`.\n\n```ts\ninterface ScheduledBoostDefinition {\n ScheduledBoostID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tags?: string[];\n Schedule?: ScheduleSpec; // Mode = Scheduled — fixed windows, e.g. \"happy hour\"\n Effects?: TimedBoostEffectSpec[];\n Gate?: SegmentGate; // optional player-segment restriction\n CustomParams?: Record<string, string>;\n}\n\ninterface BoostChainDefinition {\n ChainID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode = Chained — cyclic sequence of phases\n Phases?: ChainedBoostDefinition[];\n Gate?: SegmentGate;\n CustomParams?: Record<string, string>;\n}\n\ninterface ChainedBoostDefinition {\n ChainedBoostID?: string;\n Order?: number; // position within the cycle\n DurationSec?: number; // how long this phase stays active\n Effects?: TimedBoostEffectSpec[];\n CustomParams?: Record<string, string>;\n}\n```\n\nA `BoostChainDefinition` cycles through its `Phases` in `Order`, each active\nfor its own `DurationSec`, then loops. `getActiveWindows()` tells you which\nphase (if any) is currently open, plus `CycleIndex`/`PhaseOrder` to locate it\nwithin the cycle. `Gate` (a `SegmentGate`) can restrict a scheduled boost or\nchain to specific player segments — a window can be \"open\" globally but not\napply to every player.\n\n---\n\n## Triggered boosts\n\nAuto-granted per-player when a configured source event fires — no manual\nactivation, but otherwise becomes a normal `ActiveTimedBoost` instance (same\nshape as a manual activation, appears in `getActive()`).\n\n```ts\ninterface TriggeredBoostDefinition {\n TriggeredBoostID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n Tags?: string[];\n Sources?: TriggerSource[]; // what fires this (e.g. quest completion)\n Effects?: TimedBoostEffectSpec[];\n DurationSeconds?: number;\n Charges?: number;\n StackingPolicy?: string;\n MaxActiveInstances?: number;\n Gate?: SegmentGate;\n}\n```\n\nThere's no client method to invoke a triggered boost — it's granted\nserver-side as a consequence of another action (per `TriggerSource`). The\nclient only ever observes it appearing in `getActive()`'s `Active` map, with\n`SourceType: \"EventReward\"`-ish provenance recorded on `ActiveTimedBoost`\n(see below).\n\n---\n\n## Global settings & stacking caps\n\n```ts\ninterface TimedBoostGlobalSettings {\n StackingCaps?: Record<string, BoostStackCap>; // key = modifier Target\n}\n\ninterface BoostStackCap {\n MaxAddPercent?: number;\n MaxMultiply?: number;\n MaxAddFlat?: number;\n}\n```\n\nPer-`Target` ceilings on the _combined_ contribution across every\nsimultaneously-active effect touching that target (manual + triggered +\nscheduled + chain, all of it) — e.g. even if five boosts each add +50%\nsomewhere, the server clamps the effective total per `MaxAddPercent`. This is\nenforced entirely server-side; the client never computes the combined\nmodifier itself.\n\n---\n\n## Runtime state & responses\n\nPer-player active instances, cached at `client.data.user.state?.TimedBoost`:\n\n```ts\ninterface UserTimedBoostsState {\n Active?: Record<string, ActiveTimedBoost>; // key = InstanceID\n Version?: number;\n Triggers?: Record<string, BoostTriggerCounter>; // per-trigger daily counters (server-side bookkeeping)\n}\n\ninterface ActiveTimedBoost {\n InstanceID: string;\n BoostID: string;\n ActivatedAtUtc?: string;\n ExpiresAtUtc?: string;\n RemainingCharges?: number;\n EffectSnapshot?: TimedBoostEffectSpec; // frozen copy of Effect at activation time\n SourceType?: string; // \"Activation\" | \"Admin\" | \"EventReward\"\n SourceRef?: string;\n}\n```\n\n`SourceType` tells you how an instance came to exist: `\"Activation\"` (player\ncalled `activate`), `\"Admin\"` (ops-granted), `\"EventReward\"` (a\n`TriggeredBoostDefinition` fired). All three share the same `Active` map and\n`ActiveTimedBoost` shape — the UI doesn't need to special-case triggered\nboosts once they're active.\n\nGlobal window read, not cached in `user.state` (returned directly by\n`getActiveWindows()`, re-fetch to refresh):\n\n```ts\ninterface ActiveBoostWindowInfo {\n Kind?: string; // \"Scheduled\" | \"Chain\"\n SourceID?: string; // ScheduledBoostID or ChainID\n PhaseID?: string; // set only for Kind = \"Chain\"\n CycleIndex?: number; // which cycle iteration, Chain only\n PhaseOrder?: number; // Order of the active phase, Chain only\n StartUtc?: string;\n EndUtc?: string;\n Effects?: TimedBoostEffectSpec[];\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n}\n```\n\n---\n\n## Stacking policy semantics\n\n`StackingPolicy` (on `TimedBoostDefinition`/`TriggeredBoostDefinition`)\ngoverns what happens when a **new instance of the same `BoostID`** would\nbecome active while one already is. It does **not** govern interaction\n_between different_ `BoostID`s targeting the same modifier — that's what\n`Settings.StackingCaps` is for.\n\n| Policy | Behavior when re-activated/re-triggered while already active |\n| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Replace` | Existing instance(s) of this `BoostID` are removed; the new one becomes the only one. |\n| `KeepBest` | Same removal behavior as `Replace` on the client cache — existing same-`BoostID` instances are dropped in favor of the new one. (Which one counts as \"best\" when cost/duration differ is a server decision; the client mirrors whatever the server returns as `ActivatedBoost`.) |\n| `Refresh` | The new instance is simply inserted alongside — in practice this is how a \"refresh the timer\" boost re-stamps its expiry, since the server is expected to reuse the same effective slot server-side; the client only ever inserts, it never predicts what the server did to existing entries. |\n| `Stack` | The new instance is inserted alongside existing ones with no removal — multiple concurrent instances of the same `BoostID`, each with its own `InstanceID`, `ExpiresAtUtc`, and `RemainingCharges`. |\n\nClient cache mechanics (`UserData.patchActiveTimedBoost`, exact behavior):\n\n- For `Replace` and `KeepBest`: every existing entry in `Active` whose\n `BoostID` matches the newly-activated boost's `BoostID` (and whose\n `InstanceID` differs from the new one) is deleted, then the new instance is\n inserted.\n- For `Refresh` and `Stack` (anything else): no deletion — the new instance\n is inserted directly, alongside whatever was already there.\n\nBecause this logic runs **only on `activate()`'s own response** (matching by\nthe just-activated boost's own `BoostID`), it never touches instances of a\n_different_ `BoostID`, and it never touches triggered-boost instances unless\nyou happen to activate a manual boost with the same `BoostID` (unlikely by\nconvention, but not enforced client-side). Always treat `ActivatedBoost` and\n`StackingPolicy` from the `activate()` response as authoritative for what the\nserver actually did — the client cache write is a straightforward mirror of\nthat decision, not an independent computation.\n\n### Worked example\n\nPlayer has `Active = { \"i1\": { BoostID: \"double-xp\", ExpiresAtUtc: T+1h } }`\nand calls `activate(\"double-xp\")` again:\n\n- `StackingPolicy: \"Replace\"` → `i1` is deleted, `Active` ends up with only\n the new instance (`i2`).\n- `StackingPolicy: \"Stack\"` → `Active` ends up with **both** `i1` and `i2`,\n each independently expiring; UI showing \"time remaining\" should sum or\n list them, not assume a single instance per `BoostID`.\n\nDesign UI around \"one boost can have N concurrent instances\" rather than\nassuming `BoostID` is unique in `Active` — only `Replace`/`KeepBest`-policy\nboosts are guaranteed unique.\n\n`KeepBest`'s \"better\" comparison is **magnitude-first**: it compares\n`|EffectSnapshot.Value|` between the candidate and the current best live\ninstance, and only falls back to comparing `ExpiresAtUtc` (longer TTL wins)\nwhen the magnitudes are equal. A `Replace`-style boost re-activated while\nalready active always produces a fresh `InstanceID` (the old one is deleted,\nnot reused) — don't key long-lived UI state off `InstanceID` surviving a\nreactivation.\n\n---\n\n## How effects resolve into a number (server-side)\n\nYou never compute this — it's documented here only so boost-preview UI\n(\"this will make your next roll worth X\") can explain what a multiplier does\nwithout inventing its own math. The blend of `AddFlat` / `AddPercent` /\n`Multiply` entries collected for a target (per-player boosts + active windows,\nalready through the `Settings.StackingCaps` clamp above) is applied by the\nshared `ModifierService` in a fixed order:\n\n1. `step1 = base + sum(AddFlat)`\n2. `step2 = step1 * max(0, 1 + sum(AddPercent))`\n3. `step3 = step2 * product(Multiply, Multiply, ...)`\n4. `final = Ceiling(step3)`, clamped to `[0, long.MaxValue]`\n\nE.g. `base=100` with one `AddFlat(10)`, one `AddPercent(0.5)`, one\n`Multiply(2.0)` → `(100+10) * 1.5 * 2.0 = 330`. Each individual `AddPercent`/\n`Multiply` entry is also clamped before entering the sum/product\n(`AddPercent` to `[-100%, +9900%]`, `Multiply` to `[0.01, 100]`) — a title\ncan't accidentally zero out or blow up a calculation with one bad config\nvalue. This whole pipeline runs inside the endpoint that actually performs\nthe boosted action (e.g. GameLoop's roll/attack resolution) — TimedBoost only\nsupplies the raw effect entries via `BuildModifierEntries`; it never runs the\nmath itself for a gameplay call, and neither should the client.\n\n---\n\n## Triggered boosts are granted by other modules, not TimedBoost itself\n\n`TimedBoostV2` (the HTTP surface this SDK talks to) only implements\n`GetDefinitions` / `GetActive` / `GetActiveWindows` / `Activate` /\n`CleanupExpired` — there is no endpoint to \"fire\" a trigger. The actual grant\nhappens inside whichever module's action produced the triggering event: that\nmodule's handler calls the shared `TimedBoostService.BuildTriggeredGrants(...)`\n(backend domain helper, not the client-facing `TimedBoostService.ts`) with the\nevent's `TriggerSource` context, folds the resulting patches into its own\natomic write, and — if anything was granted — consumes charges off any\nalready-active charge-based boosts that applied to the same action via\n`BuildChargeConsume`. The client's only visibility into any of this is the\n`Active` map changing between calls to `getActive()`; there's nothing to\nsubscribe to at the moment of the trigger itself, so poll/refresh\n`getActive()` after actions that plausibly grant a triggered boost (a quest\ncompletion, a board-loop roll, etc.) if your UI wants to surface \"you got a\nbonus boost!\" promptly.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tutorial-system",
|
|
3
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
|
|
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
5
|
"references": []
|
|
6
6
|
}
|