@idosgames/mcp 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +23 -15
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +5 -5
- 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/purchase-system.json +11 -0
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/reward-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,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
|
}
|