@idosgames/mcp 0.1.4 → 0.1.5

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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "localization-system",
3
+ "description": "Translate a game built on the iDosGames TypeScript SDK (@idosgames/core) via client.localization (LocalizationService): translate a key with t(), read the resolved locale, list the languages the title offers, switch the player's language, handle plurals and placeholders, and react to the localization:changed event. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants translations, multiple languages, i18n, a language picker, localized item/quest/store names, plural forms, or otherwise touches client.localization, LocalizationService, LocalizationState, LocalizationDefinitions, or t() — even if they don't name the module explicitly.",
4
+ "content": "---\nname: localization-system\ndescription: >-\n Translate a game built on the iDosGames TypeScript SDK (@idosgames/core) via\n client.localization (LocalizationService): translate a key with t(), read the\n resolved locale, list the languages the title offers, switch the player's\n language, handle plurals and placeholders, and react to the\n localization:changed event. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and\n wants translations, multiple languages, i18n, a language picker, localized\n item/quest/store names, plural forms, or otherwise touches\n client.localization, LocalizationService, LocalizationState,\n LocalizationDefinitions, or t() — even if they don't name the module\n explicitly.\n---\n\n# Localization (iDosGames TS SDK)\n\nTranslations live **outside the title config**, in their own store, delivered\n**one file per locale**. The config only says which languages exist and which\none is the fallback. The client never loads them by hand: tables arrive with\nthe player's state at login, and `t()` reads them synchronously.\n\n## The one rule that explains everything\n\n```\nt(x) = your table → fallback table → x itself\n```\n\nThe last step is not error handling — it is the design. A title whose config\nholds literal names (`DisplayName: \"Iron Sword\"`) works **unchanged**: a\nliteral is simply a key with no translation. So you can wrap *everything* in\n`t()`, including strings that came out of the title config, and nothing breaks\nbefore a single word has been translated.\n\n```ts\nt(item.DisplayName); // \"Железный меч\" if translated, \"Iron Sword\" if not\n```\n\n## Basic use\n\n```ts\nconst { localization } = client;\n\nlocalization.t(\"quest.daily.title\"); // → \"Ежедневное задание\"\nlocalization.locale; // → \"ru\" (what the server RESOLVED, not what you asked for)\nlocalization.has(\"store.button.buy\"); // → true\n```\n\n**Nothing to load.** `client.user.getClientState*` (and therefore login)\nbrings the tables in. Do not call anything at startup to \"initialize\"\nlocalization.\n\n### Placeholders\n\n```ts\nlocalization.t(\"shop.greeting\", { name: player.name }); // \"Привет, Аня!\"\n```\n\nA parameter you didn't pass stays visible as `{name}`. That is deliberate: a\nplaceholder on screen gets noticed and fixed; a silently dropped fragment of a\nsentence does not.\n\n### Plurals\n\nStore one entry per CLDR category, suffixed:\n\n```\nitems.count.one = \"{count} предмет\"\nitems.count.few = \"{count} предмета\"\nitems.count.many = \"{count} предметов\"\n```\n\n```ts\nlocalization.t(\"items.count\", { count: 7 }); // \"7 предметов\"\n```\n\nPass `count` and the SDK picks the category with `Intl.PluralRules` **for the\ncurrent locale**, falling back to `.other` and then to the bare key. Don't\nhand-roll plural rules: Russian has three forms, Polish four, Arabic six.\n\n## Two tables, not one\n\nThe player's own locale is always downloaded. The **fallback** (the title's\ndefault language) is downloaded *only* when the player's language is not fully\ntranslated — the server computes coverage and says so:\n\n```ts\nlocalization.fallbackLocale; // \"en\" → partially translated, or null → complete\n```\n\nA fully translated language therefore carries **one** file, and edits to the\ndefault language cost that player nothing. This is why coverage is computed\nserver-side and why you should not merge tables yourself.\n\n## Language picker\n\n```ts\nlocalization.locales;\n// [{ Locale: \"en\", DisplayName: \"English\", Order: 0 },\n// { Locale: \"ru\", DisplayName: \"Русский\", Order: 1 }]\n\nawait localization.setLocale(\"ru\");\n```\n\n`DisplayName` is an **endonym** — the language's name in that language. The\npicker is read by someone who may not know the language currently on screen,\nso never translate it.\n\n`setLocale` fetches through the API rather than the CDN, caches the result, and\nemits `localization:changed`. It resolves what the server actually gave you:\n\n```ts\nconst result = await localization.setLocale(\"pt-BR\");\nif (result.ok) console.log(result.data); // \"pt-br\", or \"pt\", or \"en\"\n```\n\n## Redraw on change\n\n`t()` is synchronous, so labels you already drew will not update themselves:\n\n```ts\nclient.on(\"localization:changed\", ({ locale, fallbackLocale }) => {\n redrawAllLabels();\n});\n```\n\nIt fires on login, on `setLocale`, and whenever the tables change.\n\n## Anything shown BEFORE login is not translatable\n\nTables arrive with the player's state, and that call needs a session. So the login screen —\nand any splash, consent gate or error shown before the player is authenticated — **cannot** get\nits text from the localization tables. Wrapping those strings in `t()` compiles fine and then\nrenders the key.\n\nShip those strings in the build (a plain object in the game's source, keyed by device language).\nThis is a deliberate decision, not a gap waiting to be filled: serving them would need an\nanonymous endpoint, and the owner chose baked-in strings instead.\n\nEverything after `client.auth.login*` resolves normally — including the very first screen the\nplayer sees once logged in.\n\n## Things that will bite you\n\n- **`settings.locale` is a wish, `localization.locale` is the fact.** The\n server resolves `pt-BR` → `pt` → `en` against what the title actually has.\n Cache and compare against the resolved value.\n- **`Version: 0` means the language exists but has no translations yet.** Not\n an error — `t()` returns keys, and the game runs.\n- **A missing table never fails the game.** Unlike the title config (no config\n = no game), losing translations degrades to keys and keeps playing.\n- **Don't read `config.Localization.Locales` for text.** That section holds\n settings only; the translations are not in the config and never will be.\n- **Don't poll.** There is nothing to poll — the tables change only when the\n publisher edits them, and the server tells you via the version handshake.\n\n## Registering keys as you write code\n\nIf you are connected to the platform's title-data MCP, you have two tools for this:\n\n- `get_localization([prefix])` — the keys that already exist, with their default-language text.\n **Call it before inventing a key.** Two keys for the same label means the publisher translates\n the same words twice and one copy silently goes stale.\n- `save_localization({ keys })` — create or update keys in the title's **default** language.\n Partial: only the keys you send are written, the rest of the table is left alone (people\n translate it too). Do not write other locales — translators and machine translation fill those,\n and a string that exists only in a target language never shows up in the coverage report.\n\nWrite the key and register it **in the same turn** as the code that uses it. Writing\n`t('shop.button.buy')` without registering the key is not broken — it renders the key — but it\nleaves the publisher a label they cannot find in the dashboard.\n\nNamespace by module (`shop.`, `quest.`, `board.`); a flat table of a thousand unprefixed keys\ncannot be filtered by anyone.\n\n## Where the strings come from\n\nThe publisher edits them in the dashboard (LiveOps → Localization), imports a\nCSV/XLIFF, or has them machine-translated. Nothing in the game writes\ntranslations — they are shared by every player of the title, exactly like the\nrest of its config.\n",
5
+ "references": []
6
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lootbox-system",
3
3
  "description": "Build a lootbox / gacha / loot-crate system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load lootbox definitions (reward slots, weighted pools, price options, pity rules) and open one or many boxes for randomized rewards, including hard-pity tracking. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants loot crate / gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or bad-luck-protection systems, or otherwise touches client.lootbox, LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or UserLootboxState — even if they don't name the module explicitly.",
4
- "content": "---\nname: lootbox-system\ndescription: >-\n Build a lootbox / gacha / loot-crate system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load\n lootbox definitions (reward slots, weighted pools, price options, pity\n rules) and open one or many boxes for randomized rewards, including\n hard-pity tracking. Use this whenever the user is working in the iDosGames\n TS SDK or its game templates (board-game, idle-rpg) and wants loot crate /\n gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or\n bad-luck-protection systems, or otherwise touches client.lootbox,\n LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or\n UserLootboxState — even if they don't name the module explicitly.\n---\n\n# Lootbox system (iDosGames TS SDK)\n\nThe Lootbox module lets a title define reward crates: each box has one or more\n**reward slots**, each slot rolls a configurable number of times over a\nweighted **pool** of possible rewards, and boxes can carry **pity rules** that\ngrant an extra guaranteed roll from the rule's own pool every `Threshold`\nopens of that box. It's **server-authoritative**: the client asks the backend\nto open N boxes, the backend rolls every reward, applies pity, and returns the\nfull breakdown; the SDK mirrors granted resources and pity counters into the\nlocal cache. You never roll the loot yourself — you call `open()`, check the\nresult, and render from the response + cache.\n\nThis skill is for **using** the production `LootboxService`, not for porting\nor extending it. If an open is rejected, that's the backend enforcing a rule\n(cost, unknown box/option) — surface the error, don't try to reproduce the\nroll or the pity math client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n lootboxes: price options, reward slots + weighted pools, and pity rules.\n Fetched with `getDefinitions()`.\n2. **Pity state** (state, per player) — how many times each pity rule has\n fired and its running open-counter. There's no dedicated getter for this;\n it rides in on `open()`'s response and on the general user-state bootstrap\n (`client.user.getClientState()`).\n\nA lootbox is identified by a string `LootboxID`. Reward slots and pity rules\nroll over a shared **weighted pool** primitive (`LootboxRewardRoll`) — the same\nshape used by the Collection module's bonus slots. For the full formulas\n(weighted-pick algorithm, pity threshold math, the catalog pre-filter, the\noptional reward-progression multiplier), read\n[references/data-model.md](references/data-model.md). You don't need it to\ncall the two methods below — only to drive richer config-preview UI (odds,\npity countdowns) or to reason about an edge case.\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 lootbox = client.lootbox; // the LootboxService\n```\n\nEvery lootbox method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — missing\n`LootboxID` or `count < 1`), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the 600ms client-side throttle window), `\"connection\"`\n(transient, offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` carries the human-readable reason,\ne.g. `\"Lootbox config not found.\"`, `\"Price option {id} not found.\"`,\n`\"Price option has empty RequiredResources.\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ---------------------------------------------- | ---------------------------- |\n| `getDefinitions()` | Load the title's lootbox catalog (config). | `LootboxDefinitionsResponse` |\n| `open(lootboxID, count, selectedOptionID)` | Pay and open `count` boxes in one atomic call. | `LootboxOpenResponse` |\n\n`selectedOptionID` is a **number** key into the box's `PriceOptions` map (each\noption is a distinct price, e.g. one gem price and one real-money-currency\nprice) — there's no default, you must pick one. `count` opens that many boxes\nat once; the server clamps it to `[1, 100]` regardless of what you send, then\ncharges `count` times the selected option's price (grouped/summed per\ncurrency and item, not one charge per box) and rolls each box independently —\npity can trigger more than once mid-batch if `count` is large enough.\n\nOn success, `open()`:\n\n- applies any `data.TriggeredPity` entries into the cached per-rule pity\n counters (`client.data.user.state?.Lootbox?.Pity`), resetting\n `OpensSinceLastTrigger` to `0` and stamping `LastTriggeredAtUtc` — this also\n fires `user:lootboxUpdated`;\n- applies `data.Resources` (consumed price / granted rewards) to the cached\n currency and item balances via the shared resource-operation pipeline —\n this fires `user:inventoryUpdated`/`user:virtualCurrencyUpdated`/\n `user:eventTokenUpdated` as appropriate, **not** `user:lootboxUpdated`.\n\nRead updated balances and pity state from the cache as usual.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LootboxDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\n// Pity counters (only present once an open() has triggered pity at least once,\n// or after a full client.user.getClientState() bootstrap):\nconst pity = client.data.user.state?.Lootbox?.Pity ?? {};\npity[\"box1:pity1\"]?.OpensSinceLastTrigger;\npity[\"box1:pity1\"]?.LastTriggeredAtUtc;\n```\n\nThe pity cache key is `` `${lootboxID}:${ruleID}` ``. There is no\n`getUserLootboxState()` — the module has no state-fetch method of its own, and\nthe local cache only ever resets a counter to `0` on a trigger; it does not\nlocally increment it on non-triggering opens. The **server** does persist the\ntrue incremented counter on every open, and that authoritative `Lootbox.Pity`\nmap comes down as part of the user-profile bootstrap\n(`client.user.getClientState()` → `UserState.Lootbox`). So: treat the\nlocally-patched cache as \"when did this rule last fire,\" and refresh via\n`getClientState()` when you need a live \"N opens until pity\" countdown.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `lootbox:definitionsLoaded` → `LootboxDefinitions`\n- `lootbox:opened` → `LootboxOpenResponse`\n\nThe coarse `user:lootboxUpdated` fires specifically when pity state is\nwritten (i.e. only on calls whose response included `TriggeredPity`) — an\n`open()` that didn't trigger any pity rule won't fire it, even though\nbalances still changed (via `user:inventoryUpdated` etc.). The umbrella\n`user:anyUpdated` fires on both paths, so prefer that for a generic\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"lootbox:opened\", (r) => {\n console.log(`Opened ${r.OpenedCount}x ${r.LootboxID}`);\n r.TriggeredPity?.forEach((p) => console.log(`Pity fired: ${p.RuleID}`));\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and preview a box's odds\n\n```ts\nawait client.lootbox.getDefinitions();\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\nfor (const [lootboxID, def] of Object.entries(defs?.Definitions ?? {})) {\n def.PriceOptions; // Record<optionID (stringified number), { PriceOptionID, RequiredResources }>\n def.RewardSlots; // [{ SlotID, MinRolls, MaxRolls, Pool: [{ Reward, Weight, AmountRange }] }]\n def.PityRules; // [{ RuleID, Threshold, Pool }]\n}\n```\n\nEach `RewardSlot` rolls a random number of times uniformly in\n`[MinRolls, MaxRolls]`; each roll independently picks one entry from `Pool`\nweighted by `Weight` (optionally randomizing the granted `Amount` within\n`AmountRange`). Use this to show odds/rates in a UI, but the actual roll\nalways happens server-side — never let the client compute or pre-determine\nthe outcome.\n\n### Open a single box\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 1, 1);\nif (!res.ok) return showError(res.error); // e.g. can't afford\nres.data.Resources; // aggregated grant (already applied to cache)\nres.data.Results; // per-box ResourceOperation breakdown (one entry, for count=1)\n```\n\n### Open in bulk and surface pity\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 10, 1);\nif (!res.ok) return showError(res.error);\n\nconsole.log(`Opened ${res.data.OpenedCount} boxes`);\nfor (const trigger of res.data.TriggeredPity ?? []) {\n showPityToast(trigger.RuleID, trigger.BoxIndex); // BoxIndex = which box in Results triggered it\n}\n// balances/items already reflected in client.data.user.*\n```\n\nThe charge is atomic across the whole batch (one merged debit for all `count`\nboxes), but each box still rolls independently — some boxes in the batch can\ntrigger pity while others don't, and with a large `count` a single rule can\ntrigger more than once.\n\n### Display a pity progress bar\n\n```ts\nawait client.user.getClientState(); // refresh authoritative pity counters\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\nconst counter =\n client.data.user.state?.Lootbox?.Pity?.[\"box1:guaranteed_legendary\"];\nconst threshold = defs?.Definitions?.[\"box1\"]?.PityRules?.find(\n (r) => r.RuleID === \"guaranteed_legendary\",\n)?.Threshold;\n\nconst opensSince = counter?.OpensSinceLastTrigger ?? 0;\nconst remaining = threshold ? threshold - opensSince : undefined; // opens left until guaranteed\n```\n\nDon't derive `remaining` from the locally-patched cache after an `open()`\ncall unless that call's response included this exact `RuleID` in\n`TriggeredPity` (which resets it to `0`) — otherwise the local cache is stale\nfor non-triggering opens and you should re-fetch via `getClientState()`.\n\n### Show the \"what did I get\" reveal for one open() call\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 5, 2);\nif (!res.ok) return showError(res.error);\n\nres.data.Results?.forEach((box, i) => {\n const items = box.Grant?.Standard?.Entries ?? []; // this box's granted currencies/items\n const wasPityBox = res.data.TriggeredPity?.some((p) => p.BoxIndex === i);\n renderBoxReveal(items, wasPityBox);\n});\n```\n\n`Results[i]` already has the pity reward folded in for the box that triggered\nit, and is pre-filtered for the player's premium tier — so `Results` sums to\n`Resources`. Use `Results` for the per-box reveal animation, and the cache\n(post-`open()`) for running totals/balances.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Open\" can\n charge twice. Disable the control while a call is in flight.\n- **`selectedOptionID` is required and numeric**, unlike Craft's string\n `selectedOptionID` — don't confuse the two modules' option-id types.\n- **Pity is a plain opens-counter, not \"opens since last rare.\"** It counts\n every open of that `LootboxID` regardless of what was rolled from\n `RewardSlots`, and fires in addition to (never instead of) the normal roll.\n It's also keyed per box _and_ per rule (`lootboxID:ruleID`), so a box with\n both a soft-pity and a hard-pity rule tracks them fully independently.\n- **A stale/removed item in a reward pool can't break an open.** The backend\n pre-filters every pool against the title's active item catalogs before\n rolling (`RewardSlotHelpers.SanitizePool`); pool entries that only grant a\n since-deleted item are dropped and their weight redistributes to the rest.\n You don't need client-side defenses against a \"broken\" roll.\n- **`Results` is a list of `ResourceOperation`, not a list of named items** —\n if you need a flattened list of \"what did I get,\" derive it from\n `data.Resources.Grant.Standard.Entries` (and/or walk `Results`) rather than\n expecting a pre-flattened reward array.\n- **An optional `RewardMultiplier` can scale rewards with no visible signal.**\n If a lootbox config has one set, opened rewards are already scaled\n server-side before you see them — there's no getter to preview the current\n multiplier (unlike Reward's `getMilestoneRewardMultiplier()`), so don't\n build a \"boosted rewards\" indicator that tries to recompute it; see\n [references/data-model.md](references/data-model.md).\n- **`user:lootboxUpdated` only fires on a pity write**, not on every\n successful open — a box with no `PityRules` (or one that just didn't\n trigger) updates balances via `user:inventoryUpdated`/\n `user:virtualCurrencyUpdated`/`user:eventTokenUpdated` instead. Use\n `user:anyUpdated` if you want one hook that covers both.\n- **Render from the cache for balances/pity, from the response for the\n \"reward reveal\" animation** — the response is the only place you get the\n full roll breakdown for a single `open()` call as a discrete unit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config field,\nthe weighted-roll and pity-threshold formulas transcribed from the backend,\nthe catalog pre-filter, cost scaling for `count > 1`, and the\nreward-progression multiplier shape. Read it when building config-driven UI\n(odds previews, pity countdowns) or when you need to reason precisely about a\nbatch-open edge case.\n",
4
+ "content": "---\nname: lootbox-system\ndescription: >-\n Build a lootbox / gacha / loot-crate system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load\n lootbox definitions (reward slots, weighted pools, price options, pity\n rules) and open one or many boxes for randomized rewards, including\n hard-pity tracking. Use this whenever the user is working in the iDosGames\n TS SDK or its game templates (board-game, idle-rpg) and wants loot crate /\n gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or\n bad-luck-protection systems, or otherwise touches client.lootbox,\n LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or\n UserLootboxState — even if they don't name the module explicitly.\n---\n\n# Lootbox system (iDosGames TS SDK)\n\nThe Lootbox module lets a title define reward crates: each box has one or more\n**reward slots**, each slot rolls a configurable number of times over a\nweighted **pool** of possible rewards, and boxes can carry **pity rules** that\ngrant an extra guaranteed roll from the rule's own pool every `Threshold`\nopens of that box. It's **server-authoritative**: the client asks the backend\nto open N boxes, the backend rolls every reward, applies pity, and returns the\nfull breakdown; the SDK mirrors granted resources and pity counters into the\nlocal cache. You never roll the loot yourself — you call `open()`, check the\nresult, and render from the response + cache.\n\nThis skill is for **using** the production `LootboxService`, not for porting\nor extending it. If an open is rejected, that's the backend enforcing a rule\n(cost, unknown box/option) — surface the error, don't try to reproduce the\nroll or the pity math client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n lootboxes: price options, reward slots + weighted pools, and pity rules.\n Fetched with `getDefinitions()`.\n2. **Pity state** (state, per player) — how many times each pity rule has\n fired and its running open-counter. There's no dedicated getter for this;\n it rides in on `open()`'s response and on the general user-state bootstrap\n (`client.user.getClientState()`).\n\nA lootbox is identified by a string `LootboxID`. Reward slots and pity rules\nroll over a shared **weighted pool** primitive (`LootboxRewardRoll`) — the same\nshape used by the Collection module's bonus slots. For the full formulas\n(weighted-pick algorithm, pity threshold math, the catalog pre-filter, the\noptional reward-progression multiplier), read\n[references/data-model.md](references/data-model.md). You don't need it to\ncall the two methods below — only to drive richer config-preview UI (odds,\npity countdowns) or to reason about an edge case.\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 lootbox = client.lootbox; // the LootboxService\n```\n\nEvery lootbox method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — missing\n`LootboxID` or `count < 1`), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the 600ms client-side throttle window), `\"connection\"`\n(transient, offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` carries the human-readable reason,\ne.g. `\"Lootbox config not found.\"`, `\"Price option {id} not found.\"`,\n`\"Price option has empty RequiredResources.\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ---------------------------------------------- | ---------------------------- |\n| `getDefinitions()` | Load the title's lootbox catalog (config). | `LootboxDefinitionsResponse` |\n| `open(lootboxID, count, selectedOptionID)` | Pay and open `count` boxes in one atomic call. `count` is clamped server-side to the lootbox's `MaxOpenCount` → `Settings.MaxOpenCount` → platform default (100); `OpenedCount` reports what actually happened. | `LootboxOpenResponse` |\n\n`selectedOptionID` is a **number** key into the box's `PriceOptions` map (each\noption is a distinct price, e.g. one gem price and one real-money-currency\nprice) — there's no default, you must pick one. `count` opens that many boxes\nat once; the server clamps it to `[1, 100]` regardless of what you send, then\ncharges `count` times the selected option's price (grouped/summed per\ncurrency and item, not one charge per box) and rolls each box independently —\npity can trigger more than once mid-batch if `count` is large enough.\n\nOn success, `open()`:\n\n- applies any `data.TriggeredPity` entries into the cached per-rule pity\n counters (`client.data.user.state?.Lootbox?.Pity`), resetting\n `OpensSinceLastTrigger` to `0` and stamping `LastTriggeredAtUtc` — this also\n fires `user:lootboxUpdated`;\n- applies `data.Resources` (consumed price / granted rewards) to the cached\n currency and item balances via the shared resource-operation pipeline —\n this fires `user:inventoryUpdated`/`user:virtualCurrencyUpdated`/\n `user:eventTokenUpdated` as appropriate, **not** `user:lootboxUpdated`.\n\nRead updated balances and pity state from the cache as usual.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LootboxDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\n// Pity counters (only present once an open() has triggered pity at least once,\n// or after a full client.user.getClientState() bootstrap):\nconst pity = client.data.user.state?.Lootbox?.Pity ?? {};\npity[\"box1:pity1\"]?.OpensSinceLastTrigger;\npity[\"box1:pity1\"]?.LastTriggeredAtUtc;\n```\n\nThe pity cache key is `` `${lootboxID}:${ruleID}` ``. There is no\n`getUserLootboxState()` — the module has no state-fetch method of its own, and\nthe local cache only ever resets a counter to `0` on a trigger; it does not\nlocally increment it on non-triggering opens. The **server** does persist the\ntrue incremented counter on every open, and that authoritative `Lootbox.Pity`\nmap comes down as part of the user-profile bootstrap\n(`client.user.getClientState()` → `UserState.Lootbox`). So: treat the\nlocally-patched cache as \"when did this rule last fire,\" and refresh via\n`getClientState()` when you need a live \"N opens until pity\" countdown.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `lootbox:definitionsLoaded` → `LootboxDefinitions`\n- `lootbox:opened` → `LootboxOpenResponse`\n\nThe coarse `user:lootboxUpdated` fires specifically when pity state is\nwritten (i.e. only on calls whose response included `TriggeredPity`) — an\n`open()` that didn't trigger any pity rule won't fire it, even though\nbalances still changed (via `user:inventoryUpdated` etc.). The umbrella\n`user:anyUpdated` fires on both paths, so prefer that for a generic\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"lootbox:opened\", (r) => {\n console.log(`Opened ${r.OpenedCount}x ${r.LootboxID}`);\n r.TriggeredPity?.forEach((p) => console.log(`Pity fired: ${p.RuleID}`));\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and preview a box's odds\n\n```ts\nawait client.lootbox.getDefinitions();\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\nfor (const [lootboxID, def] of Object.entries(defs?.Definitions ?? {})) {\n def.PriceOptions; // Record<optionID (stringified number), { PriceOptionID, RequiredResources }>\n def.RewardSlots; // [{ SlotID, MinRolls, MaxRolls, Pool: [{ Reward, Weight, AmountRange }] }]\n def.PityRules; // [{ RuleID, Threshold, Pool }]\n}\n```\n\nEach `RewardSlot` rolls a random number of times uniformly in\n`[MinRolls, MaxRolls]`; each roll independently picks one entry from `Pool`\nweighted by `Weight` (optionally randomizing the granted `Amount` within\n`AmountRange`). Use this to show odds/rates in a UI, but the actual roll\nalways happens server-side — never let the client compute or pre-determine\nthe outcome.\n\n### Open a single box\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 1, 1);\nif (!res.ok) return showError(res.error); // e.g. can't afford\nres.data.Resources; // aggregated grant (already applied to cache)\nres.data.Results; // per-box ResourceOperation breakdown (one entry, for count=1)\n```\n\n### Open in bulk and surface pity\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 10, 1);\nif (!res.ok) return showError(res.error);\n\nconsole.log(`Opened ${res.data.OpenedCount} boxes`);\nfor (const trigger of res.data.TriggeredPity ?? []) {\n showPityToast(trigger.RuleID, trigger.BoxIndex); // BoxIndex = which box in Results triggered it\n}\n// balances/items already reflected in client.data.user.*\n```\n\nThe charge is atomic across the whole batch (one merged debit for all `count`\nboxes), but each box still rolls independently — some boxes in the batch can\ntrigger pity while others don't, and with a large `count` a single rule can\ntrigger more than once.\n\n### Display a pity progress bar\n\n```ts\nawait client.user.getClientState(); // refresh authoritative pity counters\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\nconst counter =\n client.data.user.state?.Lootbox?.Pity?.[\"box1:guaranteed_legendary\"];\nconst threshold = defs?.Definitions?.[\"box1\"]?.PityRules?.find(\n (r) => r.RuleID === \"guaranteed_legendary\",\n)?.Threshold;\n\nconst opensSince = counter?.OpensSinceLastTrigger ?? 0;\nconst remaining = threshold ? threshold - opensSince : undefined; // opens left until guaranteed\n```\n\nDon't derive `remaining` from the locally-patched cache after an `open()`\ncall unless that call's response included this exact `RuleID` in\n`TriggeredPity` (which resets it to `0`) — otherwise the local cache is stale\nfor non-triggering opens and you should re-fetch via `getClientState()`.\n\n### Show the \"what did I get\" reveal for one open() call\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 5, 2);\nif (!res.ok) return showError(res.error);\n\nres.data.Results?.forEach((box, i) => {\n const items = box.Grant?.Standard?.Entries ?? []; // this box's granted currencies/items\n const wasPityBox = res.data.TriggeredPity?.some((p) => p.BoxIndex === i);\n renderBoxReveal(items, wasPityBox);\n});\n```\n\n`Results[i]` already has the pity reward folded in for the box that triggered\nit, and is pre-filtered for the player's premium tier — so `Results` sums to\n`Resources`. Use `Results` for the per-box reveal animation, and the cache\n(post-`open()`) for running totals/balances.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Open\" can\n charge twice. Disable the control while a call is in flight.\n- **`selectedOptionID` is required and numeric**, unlike Craft's string\n `selectedOptionID` — don't confuse the two modules' option-id types.\n- **Pity is a plain opens-counter, not \"opens since last rare.\"** It counts\n every open of that `LootboxID` regardless of what was rolled from\n `RewardSlots`, and fires in addition to (never instead of) the normal roll.\n It's also keyed per box _and_ per rule (`lootboxID:ruleID`), so a box with\n both a soft-pity and a hard-pity rule tracks them fully independently.\n- **A stale/removed item in a reward pool can't break an open.** The backend\n pre-filters every pool against the title's active item catalogs before\n rolling (`RewardSlotHelpers.SanitizePool`); pool entries that only grant a\n since-deleted item are dropped and their weight redistributes to the rest.\n You don't need client-side defenses against a \"broken\" roll.\n- **`Results` is a list of `ResourceOperation`, not a list of named items** —\n if you need a flattened list of \"what did I get,\" derive it from\n `data.Resources.Grant.Standard.Entries` (and/or walk `Results`) rather than\n expecting a pre-flattened reward array.\n- **An optional `RewardMultiplier` can scale rewards with no visible signal.**\n If a lootbox config has one set, opened rewards are already scaled\n server-side before you see them — there's no getter to preview the current\n multiplier (unlike Reward's `getMilestoneRewardMultiplier()`), so don't\n build a \"boosted rewards\" indicator that tries to recompute it; see\n [references/data-model.md](references/data-model.md).\n- **`user:lootboxUpdated` only fires on a pity write**, not on every\n successful open — a box with no `PityRules` (or one that just didn't\n trigger) updates balances via `user:inventoryUpdated`/\n `user:virtualCurrencyUpdated`/`user:eventTokenUpdated` instead. Use\n `user:anyUpdated` if you want one hook that covers both.\n- **Render from the cache for balances/pity, from the response for the\n \"reward reveal\" animation** — the response is the only place you get the\n full roll breakdown for a single `open()` call as a discrete unit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config field,\nthe weighted-roll and pity-threshold formulas transcribed from the backend,\nthe catalog pre-filter, cost scaling for `count > 1`, and the\nreward-progression multiplier shape. Read it when building config-driven UI\n(odds previews, pity countdowns) or when you need to reason precisely about a\nbatch-open edge case.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Quest data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nresolution rules, the points-track (\"Achievements\") plumbing, group-completion\nmath, and the server-side limits/idempotency rules. All of these are **strictly\ntyped in the SDK** — `QuestDefinitions` and every nested block (`QuestDefinition`\nand its `QuestIdentity`/`QuestLinking`/`QuestAvailability`/`QuestReward` blocks,\n`QuestCycleDefinition`, `QuestPhaseDefinition`, `QuestObjectiveDefinition`,\n`QuestGroupCompletionDefinition`, `QuestPresetRegistry`/`QuestPresetBindings`,\nthe shared `ScheduleSpec`/`SegmentGate`/`LimitSpec`/`MilestoneDefinition`/\n`EventTokenDefinition` blocks, …) are exported from `@idosgames/core`, so\n`getQuestDefinitions()` and `getSection<QuestDefinitions>(\"Quest\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight from\nthe backend JSON).\n\nBackend source of truth for everything below:\n`IDosGamesSDK/API/Client/v2/Quest/Quest.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/QuestDefinitions.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/UserQuestState.cs`,\n`IDosGamesSDK/API/Core/Scheduling/Services/ScheduleResolver.cs`,\n`IDosGamesSDK/API/Core/Event/Services/EventTokenService.cs`.\n\n## Contents\n\n- [Player state](#player-state) — what `getUserQuestState()` returns\n- [Config: QuestDefinitions](#config-questdefinitions) — what `getQuestDefinitions()` returns\n- [QuestCycleDefinition](#questcycledefinition)\n- [QuestDefinition](#questdefinition)\n- [Presets](#presets--authoring-n-days--m-tasks-without-nm-copies) — authoring N days × M tasks without N×M copies\n- [QuestObjectiveDefinition + progress aggregation](#questobjectivedefinition--progress-aggregation)\n- [Prerequisites (`RequiredQuestIDs`)](#prerequisites-requiredquestids)\n- [Cycle schedule resolution](#cycle-schedule-resolution)\n- [Per-quest schedule (\"staged unlock\" / Achievements)](#per-quest-schedule-staged-unlock--achievements)\n- [Points track (\"Achievements\") — the Quest event-token](#points-track-achievements--the-quest-event-token)\n- [Group-completion (grand reward) math](#group-completion-grand-reward-math)\n- [`AddQuestProgress` server-side rules](#addquestprogress-server-side-rules)\n- [Idempotency, atomicity, batch limits](#idempotency-atomicity-batch-limits)\n\n---\n\n## Player state\n\nReturned by `getUserQuestState()` as `{ State, PointsTracks }` and cached at\n`client.data.user.state?.Quest` (progress) + `client.data.user.state?.EventToken?.Quest`\n(points track — see below). Hand-written interfaces (not `z.infer`) because the\ncache-patch methods mutate these objects in place.\n\n```ts\ninterface UserQuestState {\n Cycles?: Record<string, UserQuestCycleState>; // key = CycleID\n PermanentQuests?: Record<string, UserQuestProgress>; // key = QuestID\n LastUpdatedUtc?: string;\n}\n\ninterface UserQuestCycleState {\n CycleID?: string;\n CycleStartUtc?: string; // current window start, UTC\n CycleEndUtc?: string; // current window end, UTC\n Quests?: Record<string, UserQuestProgress>; // key = QuestID, THIS window only\n ClaimedGroupCompletionIDs?: string[]; // CompletionIDs already claimed this window\n}\n\ninterface UserQuestProgress {\n QuestID: string;\n Status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n ActivatedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n ClaimedAtUtc?: string | null;\n Objectives?: Record<string, UserQuestObjectiveProgress>; // key = ObjectiveID\n}\n\ninterface UserQuestObjectiveProgress {\n ObjectiveID: string;\n CurrentValue: number;\n Completed: boolean;\n CompletedAtUtc?: string | null;\n}\n```\n\n**Lazy initialization** (`Quest.cs` `RefreshQuestCycles` / `CreateQuestProgressFromDefinition`):\na quest's `UserQuestProgress` (and each objective's `UserQuestObjectiveProgress`)\nis created only the first time progress is reported for it — the server does\n**not** pre-populate every configured quest/objective with zeros. A quest absent\nfrom `Cycles[cycleID].Quests` (or `PermanentQuests`) simply has zero progress on\nevery objective; render it as `\"Active\"`, not as an error or \"unknown\" state.\n`Expired` is only ever set for cyclic quests (never for permanent quests) and\nonly via the same lazy path — in practice you will see `Active` → `Completed` →\n`Claimed` for anything you've touched; a truly stale quest from a rolled-over\ncycle is simply absent (the whole cycle bucket gets replaced on rollover, see\nbelow), not flagged `Expired` in current code paths.\n\n---\n\n## Config: QuestDefinitions\n\nReturned by `getQuestDefinitions()`; cached via\n`client.data.config.getSection<QuestDefinitions>(\"Quest\")`.\n\n```ts\ninterface QuestDefinitions {\n Cycles?: Record<string, QuestCycleDefinition>; // key = CycleID\n Quests?: Record<string, QuestDefinition>; // key = QuestID\n Presets?: QuestPresetRegistry; // reusable blocks, one registry per QuestDefinition block\n}\n```\n\n**Quests arrive already assembled.** The config is *authored* compactly — a field left unset on a\nquest comes from the preset bound to that block — but the backend resolves it once when it\nmaterializes the title config, so what `getQuestDefinitions()` returns already has every quest's\nblocks filled in. `Presets` rides along for editors; a game client never merges anything.\n\nAssembled is not the same as flattened: the **shape** stays blocked. A quest's name is at\n`Identity.DisplayName`, its cycles at `Linking.CycleIDs`, its window at `Availability.Schedule`,\nits payout at `Reward.Grant`.\n\nA quest is **permanent** iff its `Linking.CycleIDs` is null/empty; otherwise it is\n**cyclic** and belongs to every cycle listed there (a quest can appear\nin more than one cycle definition, each with independent progress/claim state).\nCycle IDs and quest/objective IDs must all be Mongo-safe (no `.` or `$`) —\nthe server rejects unsafe keys outright (`\"Invalid CycleID (mongo-unsafe): …\"`,\n`\"QuestID is mongo-unsafe\"`, etc.); this only matters if you let players or\nremote config drive raw ids into these fields.\n\n---\n\n## QuestCycleDefinition\n\n`Quest.cs` / `QuestDefinitions.cs`. One recurring or one-off \"board\" (dailies,\nweeklies, a scheduled event window, …). Quests do **not** get listed here —\neach `QuestDefinition` points back at the cycle via `Linking.CycleIDs`.\n\n```ts\ninterface QuestCycleDefinition {\n CycleID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // cycle window/reset — see below\n Milestones?: Record<string, MilestoneDefinition>; // points-track rungs, key = MilestoneID\n Phases?: Record<string, QuestPhaseDefinition>; // chain phases, only for Schedule.Mode = \"Chained\"\n Presets?: { Milestones?: PresetBinding }; // cycle-level preset wiring (Core/Presets)\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // audience gate for the whole cycle; ANDed with each quest's own Gate\n PointsToken?: EventTokenDefinition; // global caps/burn for the points track (see below)\n GroupCompletions?: Record<string, QuestGroupCompletionDefinition>; // key = CompletionID\n}\n```\n\nBackend default when a cycle is authored without an explicit `Schedule`:\n`Mode: \"Cyclic\"`, `Cyclic: {}` (i.e. daily calendar reset) —\n`QuestCycleDefinition.Schedule` in `QuestDefinitions.cs`.\n\n---\n\n## QuestDefinition\n\nOnly the ID lives at the root; everything else is a named block, exactly like\n`CharacterDefinition` (`Identity` / `Classification` / `Unlock` / `Stats` / …).\n\n```ts\ninterface QuestDefinition {\n QuestID?: string;\n Identity?: QuestIdentity;\n Linking?: QuestLinking;\n Availability?: QuestAvailability;\n Objectives?: Record<string, QuestObjectiveDefinition>; // key = ObjectiveID; ALL must complete\n Reward?: QuestReward;\n Presets?: QuestPresetBindings; // one binding per block — see Presets\n}\n\n/** Display part — analogous to CharacterIdentity. */\ninterface QuestIdentity {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number; // lower = earlier in UI; default 0\n AssetPaths?: Record<string, string>; // task icon and other client assets\n CustomParams?: Record<string, string>; // passed to the client untouched\n}\n\n/** Links — analogous to CharacterClassification. */\ninterface QuestLinking {\n CycleIDs?: string[]; // null/empty => permanent; else one entry per cycle it appears in\n GroupID?: string; // plain label: UI sections + QuestGroupCompletionDefinition matching\n PhaseIDs?: string[]; // chain phases this quest lives in; empty = all\n RequiredQuestIDs?: string[]; // prerequisite QuestIDs — see below\n PrerequisiteMode?: \"BlockProgressAndClaim\" | \"BlockClaimOnly\"; // default BlockProgressAndClaim\n}\n\n/** Access rules — analogous to CharacterUnlock. */\ninterface QuestAvailability {\n Schedule?: ScheduleSpec; // per-quest unlock window; unset = the cycle's window (see below)\n AccrueProgressWhenLocked?: boolean; // default false — see per-quest schedule section\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Limits?: LimitSpec; // per-source caps on POINTS grants only (not on objective progress)\n}\n\n/** Claim payout: the grant plus points into the cycle track. */\ninterface QuestReward {\n Grant?: ResourceGrant; // claimed via claimQuestReward\n PointsReward?: number; // points into the cycle's track on claim; ignored for permanent quests\n}\n```\n\nThere is **no group entity.** `Linking.GroupID` is a plain string: it groups quests into UI\nsections and it is what `QuestGroupCompletionDefinition` matches on. Nothing has to declare it,\nand nothing inherits through it.\n\nIn the **stored** config every block, and every field inside it, is optional in the strong sense —\nabsent means \"take it from the preset bound to this block\" (see\n[Presets](#presets--authoring-n-days--m-tasks-without-nm-copies)). By the time this reaches a\nclient the backend has already assembled them.\n\n`PrerequisiteMode` (`QuestDefinitions.cs` comment, verbatim intent):\n\n| Mode | Effect on `RequiredQuestIDs` |\n| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `BlockProgressAndClaim` (default) | The quest does not start accruing progress at all until every listed prerequisite reaches `Completed`/`Claimed`; consequently it also can't be claimed. |\n| `BlockClaimOnly` | Progress accrues immediately; only the final reward claim is blocked until prerequisites are met. |\n\nA prerequisite is looked up \"where its own progress lives\": permanent →\n`PermanentQuests`; cyclic → the same `cycleID` if the prerequisite also belongs\nto it, otherwise the prerequisite's own first `Linking.CycleIDs` entry\n(`ArePrerequisitesMet`, `Quest.cs`). Empty/null `RequiredQuestIDs` = no gating.\n\n---\n\n## Chains — a cycle that runs phases one after another\n\nSet the cycle's `Schedule.Mode` to `\"Chained\"` and fill `Phases`. The chain starts at\n`Schedule.Chain.AnchorUtc`, plays its phases in `Order`, then repeats (`MaxCycles`, pauses).\n\n```ts\ninterface QuestPhaseDefinition {\n PhaseID?: string; // unique within the chain; referenced by QuestLinking.PhaseIDs\n Order?: number; // position within one full pass (0, 1, 2...)\n DurationSec?: number; // how long the phase stays open\n ClaimGraceHours?: number;// extra claim window after it ends\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // AND-ed with cycle gate and quest gate\n Milestones?: Record<string, MilestoneDefinition>; // null = the cycle's ladder is used\n Presets?: { Milestones?: PresetBinding };\n PointsToken?: EventTokenDefinition; // null = the cycle's token\n}\n```\n\nThree rules worth knowing before designing one:\n\n- **Every phase owns its progress.** The points-track address includes the instance key, and for\n a phase that key is `chain:{cycleIndex}:{phaseID}`. So week 2 starts from zero points with its\n own claimed-milestone list — it never inherits week 1. The cycle's quest state resets at a phase\n boundary exactly like it resets at midnight for a `Daily` cycle.\n- **Unset phase content falls back to the cycle.** Eight identical weeks are eight phases with\n only `Order`/`DurationSec` filled — not eight copies of the milestone ladder. The exception is an\n *empty* (not absent) `Milestones` object: that explicitly means \"no milestones in this phase\".\n- **A `Chained` cycle with no phases never activates.** There is nothing to resolve, so the whole\n cycle stays closed and its quests never progress.\n\nBind a quest to specific phases with `Linking.PhaseIDs` (empty = every phase). It gates\nboth progress and claiming — a week-2 quest cannot be claimed during week 1, in single and batch\nclaims alike. `PhaseIDs` is the direct way to say \"this quest belongs to week two\"; the quest's own\n`Availability.Schedule` with a `Relative` window stays for staged unlocking *within* one phase\n(\"Day N\").\n\n`GetUserQuestState` reports the live phase on each track: `PointsTracks[cycleID].PhaseID` and\n`.CycleIndex` (both absent/0 for a plain cycle) alongside `CycleStartUtc`/`CycleEndUtc` of that\nphase — enough to render \"Week 2 of 8\" and a countdown.\n\n### Milestone presets\n\n`QuestDefinitions.Presets.Milestones` is a registry of reusable `MilestoneSet`s keyed by PresetID —\nthe one preset block that belongs to cycles and phases rather than to quests. A cycle or a phase\nreferences one through `Presets.Milestones` (`PresetBinding`): the preset is the base, the inline\n`Milestones` dictionary overrides or adds by MilestoneID, and `Remove` drops keys. No PresetID ⇒\ninline only. An unknown PresetID silently falls back to inline — it never wipes the entity's own\nladder. Everything else about presets is in the next section.\n\n---\n\n## Presets — authoring N days × M tasks without N×M copies\n\nA seven-day event of six tasks a day is 42 quests that differ in three numbers. One mechanism\nexists so the config says that once instead of 42 times, and it is the **same one Character\nuses**: a registry of reusable blocks plus a binding per block. There is no second mechanism —\nno group entity, no chassis, no inheritance chain. It is **authoring-side only**: the backend\nresolves it at config load and everything downstream sees ordinary assembled quests.\n\n**The one rule:** *unset = take it from the preset, set = final.* A field that is absent takes its\nvalue from the preset bound to that block; a field that is present — **including `0`, `false` and\n`[]`** — wins and is never overwritten. That asymmetry is deliberate: \"this quest gives no points\"\n(`Reward.PointsReward: 0`) has to survive against a preset that grants 30.\n\n```ts\n/** Registry: one dictionary per block, each mirroring the same-named QuestDefinition block. */\ninterface QuestPresetRegistry {\n Milestones?: Record<string, MilestoneSet>; // cycles and chain phases only\n Linking?: Record<string, QuestLinking>;\n Availability?: Record<string, QuestAvailability>;\n Reward?: Record<string, QuestReward>;\n Objectives?: Record<string, Record<string, QuestObjectiveDefinition>>; // inner key = ObjectiveID\n}\n\n/** Wiring: one binding per block, exactly like CharacterDefinition.Presets. */\ninterface QuestPresetBindings {\n Milestones?: PresetBinding; // on a cycle / phase, not on a quest\n Linking?: PresetBinding;\n Availability?: PresetBinding;\n Reward?: PresetBinding;\n Objectives?: PresetBinding; // merges by ObjectiveID; `Remove` drops preset entries\n}\n```\n\n**Bindings are independent.** Take the schedule from one preset, the reward from another, and\nwrite the objectives inline — the blocks don't know about each other. Precedence inside one\nblock is just two layers:\n\n```\nquest's own field → the preset bound to that block → engine default\n```\n\n**`Identity` has no preset on purpose.** A quest's name and sort order are unique to it, and\n`Description` — the only field that is ever shared — is displayed by no client, so a registry for\nthis block added a binding to every quest and carried nothing. Write Identity inline.\n\nSingle-object blocks (`Linking` / `Availability` / `Reward`) merge **field by field**. `Objectives` merges **by ObjectiveID**, and inside a matched objective the same\nunset-takes-from-preset rule applies — that is the piece that pays for itself: the preset says\n*how* an objective advances, the quest restates only what differs.\n\n```jsonc\n// preset: how \"make N moves\" works — written once\n\"Presets\": { \"Objectives\": { \"moves\": {\n \"task\": { \"Source\": \"SystemEvent\", \"TargetValue\": 15,\n \"Triggers\": [{ \"SourceType\": \"BoardTileLanding\" }] } } } }\n\n// day 5's quest: name and target are all that is unique\n\"Quests\": { \"e7_d5_moves\": {\n \"Identity\": { \"DisplayName\": \"Day 5. Make 35 moves\", \"SortOrder\": 501 },\n \"Presets\": {\n \"Linking\": { \"PresetID\": \"e7\" }, // cycle + group label, shared by all 42\n \"Availability\": { \"PresetID\": \"e7_d5\" }, // \"opens 4 days after the event starts\"\n \"Reward\": { \"PresetID\": \"e7_d5\" }, // day-5 payout, shared by that day's 6 tasks\n \"Objectives\": { \"PresetID\": \"moves\" }\n },\n \"Objectives\": { \"task\": { \"TargetValue\": 35 } } // triggers survive — only the number changes\n}}\n```\n\nThree things that bite if you don't know them:\n\n- **The dictionary key is the ID.** A quest or objective written without `QuestID` /\n `ObjectiveID` takes it from its key. In the compact form it is easy to omit, and an objective\n with no ID used to be skipped silently — the quest looked configured and never moved.\n- **An unknown PresetID falls back to inline**, it never wipes the block. A typo therefore shows\n up as a quest with a missing window or a missing reward, not as an error at load.\n- **`Remove` on `Presets.Objectives`** is the only way to take a preset objective away for a\n single quest.\n\n---\n\n## QuestObjectiveDefinition + progress aggregation\n\n```ts\ninterface QuestObjectiveDefinition {\n ObjectiveID?: string;\n Source?: \"ClientApi\" | \"ServerApi\" | \"SystemEvent\"; // what advances it; default ClientApi\n MaxProgressPerCall?: number; // ClientApi BAN threshold (not a clamp); 0/absent = no check\n MetricID?: string; // ClientApi/ServerApi only: the metric key reported against\n Triggers?: TriggerSource[]; // SystemEvent only: in-game events that advance it\n TargetValue?: number; // default 1; required value to complete\n AggregationMethod?: string; // \"Sum\" | \"Maximum\" | \"Minimum\" | \"Last\"; default \"Sum\"\n}\n```\n\n`Source` selects **which field is read** — they are mutually exclusive:\n\n| Source | Advanced by | Field read |\n| ------------- | ----------------------------------------------- | ------------ |\n| `ClientApi` | the game calling `addQuestProgress(MetricID, v)` | `MetricID` |\n| `ServerApi` | a CloudCode script calling `server.AddQuestProgress(MetricID, v)` | `MetricID` |\n| `SystemEvent` | the backend itself, on in-game events | `Triggers` |\n\n`addQuestProgress` reaches **only** `ClientApi` objectives; a `MetricID` with no\nmatching `ClientApi` objective anywhere in the catalog is rejected outright\n(`\"MetricID not allowed for ClientApi\"`).\n\n### `Triggers` — SystemEvent objectives\n\n`Triggers` is the shared `TriggerSource` used by `EventContent.TokenSources`\n(TimedEvent) and `LeaderboardDefinition.ScoreSources`: an event type plus\nfilters, with `BaseWeight` as the progress step per fire. The list is OR-ed\n(first match wins). Empty/absent ⇒ the objective never advances.\n\nThe backend emits these event types into quests — anything else in\n`EventTokenSourceType` never reaches a quest (use `ClientApi` for\nclient-observed actions like watching an ad):\n\n`BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`, `BoardBuild`,\n`BoardStageComplete`, `BoardSpecialComplete` (GameLoop) · `StorePurchase`\n(`Store.Purchase` / `PurchaseBatch`, multiplier = purchase count) ·\n`MarketplaceSell` / `MarketplaceBuy` (settlement, **acting player only**) ·\n`QuestComplete` (`ClaimQuestReward`, for meta-quests) · `DailyLogin` (first login of a UTC day —\ndeduped at login, so ten re-entries in one evening count as one day) · `CurrencySpent`\n(`ResourceService`, on the applied consume; multiplier = **amount spent**) · `LootboxOpened`\n(multiplier = boxes opened in the call) · `LeaderboardRankReward` (fired when a rank reward is\nactually claimed, not while the standing changes) · `IapPurchase` (`PurchaseV2`, after the receipt\nis verified and the goods granted; multiplier = units granted — **also fires on subscription\nauto-renewals** from the store callback, tagged `Renewal: \"true\"`) · `CryptoDeposit` / `CryptoWithdraw`\n(deposit credited / withdrawal **confirmed on chain** — not on the request, which may never land;\nmultiplier = 1 operation) · `CryptoSpent` (crypto consumed in-game; multiplier = amount) ·\n`CurrencyEarned` / `CryptoEarned` (`ResourceService`, on the applied **grant**, premium tiers\nincluded; multiplier = **amount granted**).\n\nTwo of these carry an *amount* in the multiplier rather than a count, which makes\n`ScaleWithRollMultiplier` the switch between two different goals:\n\n| Source | `true` | `false` |\n| ------ | ------ | ------- |\n| `CurrencySpent` | \"spend 100 coins\" | \"make 100 separate spends\" |\n| `CryptoSpent` | \"spend 100 tokens\" | \"make 100 separate spends\" |\n| `CurrencyEarned` | \"earn 1000 coins\" | \"receive coins 1000 times\" |\n| `CryptoEarned` | \"earn 100 tokens\" | \"receive tokens 100 times\" |\n| `LootboxOpened` | \"open 15 chests\" (one call of 15 counts fully) | \"open a chest 15 times\" |\n| `IapPurchase` | \"buy 5 units\" (a x5 pack counts fully) | \"make 5 separate purchases\" |\n\nSoft currency, crypto and real money are three **separate** sources on purpose: a goal like\n\"spend 100\" must not be closeable by coins one day and by tokens or dollars the next. If a title\nstores crypto in minimal (wei-like) units, set `ScaleWithRollMultiplier: false` on `CryptoSpent`\nand count operations — the amount would otherwise be astronomically large.\n\n`Params` filters the matcher actually checks: `StorePurchase` → `OfferID`;\n`QuestComplete` → `QuestID`, `CycleID`; `Marketplace*` → `CatalogID`, `ItemID`,\n`OfferType`; `CustomAction` → `ActionName`; `CurrencySpent` → `CurrencyID`;\n`LootboxOpened` → `LootboxID`; `IapPurchase` → `ProductID`, `Store`, `Renewal`\n(`\"true\"` = subscription auto-renewal, `\"false\"` = the player bought it by hand; omit to count\nboth — money was paid either way);\n`CryptoDeposit` / `CryptoWithdraw` → `CurrencyID`, `NetworkID`; `CryptoSpent` → `CurrencyID`;\n`CurrencyEarned` / `CryptoEarned` → `CurrencyID`, `Origin`\n(`\"Gameplay\"` = only what the game paid out, `\"RewardClaim\"` = only quest/milestone/rank/season/daily\npayouts, omit to count both — a goal like \"earn 1000 coins\" is otherwise partly closed by other\nquests' rewards);\n`LeaderboardRankReward` → `LeaderboardID`, `Rank`\n(exact match — \"first place\" is `Rank: \"1\"`; for \"top 3\" declare three sources or omit `Rank`).\nAny other key is stored but ignored.\n\nSet `ScaleWithRollMultiplier: false` on a quest trigger unless you *want* the\nboard's roll multiplier to inflate the step — otherwise \"win 3 raids\" closes on\na single x3 raid.\n\n`SystemEvent` progress is applied **after** the handler succeeds and returns on\nthat response's envelope as `QuestProgress: QuestProgressUpdate[]` (absent when\nnothing moved). The guarantee is at-most-once: the game action is already\ncommitted, so a failure here loses the event rather than rolling the action back.\n\nAggregation (`ApplyAggregation`, `Quest.cs`), given the objective's current\n`CurrentValue` and the incoming call value:\n\n| Method | New value |\n| ------------------------ | ---------------------------------------------------------------------------------------- |\n| `Sum` (default) | `current + incoming`, clamped to `long.MaxValue` on overflow; `incoming <= 0` is a no-op |\n| `Maximum` | `max(current, incoming)` |\n| `Minimum` | `incoming` if `current == 0`, else `min(current, incoming)` |\n| `Last` (or unrecognized) | `incoming` (last-write-wins) |\n\nAfter aggregation, if `TargetValue > 0` the new value is clamped to\n`TargetValue` (progress bars never overshoot 100%); if `TargetValue <= 0` there\nis no cap. An objective is marked `Completed` once\n`(TargetValue <= 0 && newValue > 0) || newValue >= TargetValue`. A quest becomes\n`\"Completed\"` once **every** objective the player has a progress record for is\n`Completed` **and** every objective in the definition has a progress record —\ni.e. an objective with zero recorded progress blocks completion (it's absent\nfrom the player's `Objectives` map, so the `All(...)` check in\n`EnsureQuestObjectivesAndCompletion` fails for it).\n\n`MaxProgressPerCall` guards two different things depending on\n`AggregationMethod`:\n\n- If **any** matching `ClientApi` objective for the `MetricID` has\n `MaxProgressPerCall > 0`, the server compares the **raw** `ProgressValue` you\n sent against the (minimum across matches) cap **before** any clamping. If the\n raw value exceeds it, the call is rejected **and the player is banned**\n (`service.BanUser(...)`, fire-and-forget) with error `\"User banned: Value\nexceeds MaxValuePerCall\"`. This is a hard anti-abuse trip-wire, not a soft\n clamp — never let client code send inflated values \"to be safe.\"\n- Only for objectives using `Sum` aggregation is the value additionally\n clamped to `MaxProgressPerCall` before summing (defense in depth; irrelevant\n once the ban-check above has already passed, since raw ⇐ cap by that point).\n\n---\n\n## Prerequisites (`RequiredQuestIDs`)\n\nSee the `PrerequisiteMode` table above. Enforcement points in `Quest.cs`:\n\n- **Progress accrual** (`AddQuestProgress` internal helper): for permanent\n quests and for each cycle a cyclic quest belongs to, prerequisites are\n checked (only in `BlockProgressAndClaim` mode) before the quest's\n `UserQuestProgress` is even created/updated for that call.\n- **Claim** (`ClaimQuestReward` / batch): `ArePrerequisitesMet(...)` is checked\n unconditionally (both modes gate the claim) — error\n `\"Prerequisite quests are not completed\"`.\n\n---\n\n## Cycle schedule resolution\n\n`QuestCycleDefinition.Schedule` is a `ScheduleSpec` (`_shared/ScheduleModels.ts`\n/ `Core/Scheduling`), the same primitive every other module uses. For Quest,\n`RefreshQuestCycles` resolves it via `ScheduleResolver.ResolveActive(...)`\ninto a `[CycleStartUtc, CycleEndUtc)` window per the active `Mode`:\n\n- **`Cyclic`** (the practical default for dailies/weeklies): `Cyclic.Reset` picks\n the calendar cadence — `Hourly`/`Daily`/`Weekly` (always **Monday** start)/`Monthly`\n (always the **1st**)/`Yearly` are calendar-aligned in **UTC**, reset time is\n **always 00:00:00 UTC** and is not configurable. `FixedInterval` instead repeats\n every `Cyclic.IntervalSeconds` seconds from `Cyclic.AnchorUtc` (default anchor\n `2026-01-01T00:00:00Z`, default interval 86400s if unset/≤0); an optional\n `PauseBetweenCyclesSec` inserts a dead gap after each active window during\n which `CanEarn` is `false` (`IsInPause = true`) but the window has still\n technically \"ended\" — new progress does not accrue during the pause, though\n already-completed quests remain claimable (claims are never earn-gated).\n- **`Scheduled`**: one fixed `[StartUtc, EndUtc]` window; `ClaimGraceHours`\n extends claimability past `EndUtc` without extending earning (unless\n `AllowEarningAfterEnd` is set).\n- **`AlwaysOn`**: always active, no end.\n- Any other/inactive resolution (e.g. `Triggered`, or `spec.IsActive === false`)\n makes `ComputeCycleWindowUtc` **degrade to a plain UTC calendar day**\n `[today 00:00, tomorrow 00:00)` as a fallback — don't configure `Triggered` on\n a quest cycle expecting anything else.\n\n**Rollover behavior** (`RefreshQuestCycles`, called automatically by\n`getUserQuestState({autoRefreshCycles: true})` — the default — and before every\nmutating Quest action): when the resolved `[start, end)` no longer matches the\nstored `CycleStartUtc`/`CycleEndUtc`, the entire `UserQuestCycleState` for that\ncycle is **replaced with a brand-new, empty one** (`Quests: {}`,\n`ClaimedGroupCompletionIDs` reset) — there is no partial carry-over of\nin-progress quests into the new window. Cycles removed from config entirely are\ndeleted from the player's state on the next refresh. If the window has **not**\nrolled over, the refresh instead walks the player's **existing** quest progress\nrecords (only ones already started) and re-evaluates `Completed` status against\ncurrent config — it does not add new objectives to already-tracked quests.\n\n---\n\n## Per-quest schedule (\"staged unlock\" / Achievements)\n\n`QuestAvailability.Schedule` is an **independent, optional** `ScheduleSpec` layered\non top of the cycle's own schedule — this is how \"Day 2 unlocks 24h after Day 1\"\nor an \"Achievements\" track with a `ScheduleSpec`-driven unlock (rather than a\nliteral day-count) is built, with any number of stages at any interval, not just\nliteral days:\n\n- `Schedule` absent → the quest simply inherits its cycle's window; earning and\n claiming follow the cycle's own `CanEarn`/`CanClaim`.\n- `Schedule` present → resolved via `ResolveQuestInstance`, which passes the\n **cycle's** resolved instance as the `parent` for `Relative`-mode windows —\n so a per-quest `Relative` schedule with `OffsetSecondsFromParentStart` is\n \"N seconds after this cycle instance started,\" letting one `Cyclic` cycle\n auto-repeat a whole staged sequence without hardcoded absolute dates.\n- `Availability.AccrueProgressWhenLocked` (default `false`) decides what happens **while**\n the cycle's window is open but the quest's own window is not: `false` means a\n locked stage accrues **zero** progress (a true lock — progress reported for\n its metric while locked is simply dropped for that quest); `true` means\n progress accrues the whole time the cycle is active, but the **reward claim**\n is still gated on the quest's own `CanClaim` — so you can pre-accrue \"Day 3\"\n progress while day 3 is still locked, and only the payout waits.\n\nEarning gate precedence for a cyclic quest, all of which must pass\n(`AddQuestProgress` internal helper): cycle `earningCycles` membership (cycle\nitself must be `CanEarn`, i.e. not `IsInPause`) → quest's own\n`IsQuestEarnable` (`AccrueProgressWhenLocked` bypasses this specific check) →\n`QuestGatesPass` (cycle `Gate` AND `Availability.Gate`) → prerequisites (only in\n`BlockProgressAndClaim` mode).\n\n---\n\n## Points track (\"Achievements\") — the Quest event-token\n\nThis is the mechanism the \"Achievements\" hint in the prompt refers to — it is\nreal and it is exactly the cycle's points track, not a separate module. Russian\ncomments in `Quest.cs` literally label it «Достижения» (Achievements).\n\n**How points get earned.** Each cyclic `QuestDefinition.Reward.PointsReward` (points,\nnot currency) is granted **only on claim** of that quest's own reward — via\n`ClaimQuestReward` / `ClaimQuestRewardsBatch`, in the **same atomic transaction**\nas the quest's `Reward` grant. It's `long`, defaults to `0`, and is ignored for\npermanent quests (`isPermanent` quests never touch the points track). A group\ncompletion (below) can **also** add points via its own `PointsReward`, on top of\nwhatever its member quests already contributed individually.\n\n**Where it's addressed.** The points track is backed by a standard\n`EventTokenType.Quest` event token (the same primitive TimedEvent/Leaderboard/\nCoopEvent/Season points tracks use), addressed at\n`EntityID = \"{cycleID}:{instanceKey}\"` where `instanceKey` comes from\n`ScheduleResolver`'s resolution of the **cycle's** schedule (`\"all\"` if the\ncycle has no resolvable instance). Because the instance key changes when the\ncycle's schedule rotates to a new window, **the points balance and claimed-milestone\nlist reset automatically on cycle rollover** — there is no explicit\n\"reset points\" step; it's a natural consequence of the address changing.\n\n**Where it lives in state.** `UserQuestState` does **not** carry the points\nbalance — it lives in `UserDataDocument.EventToken.Quest[entityID]`\n(`UserEventTokenProgress`: `Balance.Current`/`Balance.TotalEarned`,\n`Milestone.ClaimedIDs`). `GetUserQuestState` additionally projects a **read-only\nsnapshot** per cycle into `GetUserQuestStateResponse.PointsTracks[cycleID]`\n(`QuestPointsTrackView`) so the client doesn't have to know the composite key —\nthe TS SDK's `patchQuestPointsTracks` writes this into\n`client.data.user.state.EventToken.Quest[\"{cycleID}:{instanceKey}\"]` for you,\nand `client.data.user.getQuestPointsProgress(cycleID)` resolves the composite\nkey back out (`matchesBase`, `util/eventTokenIds.ts`) so you can look it up by\nplain `cycleID`.\n\n```ts\ninterface QuestPointsTrackView {\n CycleID: string;\n InstanceKey?: string | null;\n CycleStartUtc?: string | null;\n CycleEndUtc?: string | null; // source for a \"resets in\" timer\n PointsTotalEarned?: number | null; // lifetime points earned this cycle instance\n PointsCurrent?: number | null; // current balance (== TotalEarned; points are never spent)\n ClaimedPointMilestoneIDs?: string[] | null;\n}\n```\n\n**Milestones** (`QuestCycleDefinition.Milestones`, keyed by `MilestoneID`) are\nthe shared Core `MilestoneDefinition` primitive\n(`RequiredProgress`, `Rewards`, `BonusRewards`, `SeasonTierRewards`,\n`SortOrder`, `IsFeatured`) — see `_shared/MilestoneModels.ts`. Eligibility is\njudged **only** against `Balance.TotalEarned` on the points token (never\n`Current`, though for Quest the two happen to always be equal since points are\nonly ever granted, never spent) — `EventTokenService.ComputeMilestoneClaim`:\nfails with `\"Not enough earned. Have: X, need: Y.\"` if under threshold, or\n`\"Milestone already claimed.\"` if `MilestoneID` is already in `ClaimedIDs`.\nClaiming pushes the id into `ClaimedIDs` via a Mongo `$push` guarded by a\n`$nin` filter (OCC — a concurrent duplicate claim loses the race cleanly). The\nmilestone's reward itself runs through the shared `MilestoneRewardResolver`\n(same resolver Leaderboard/TimedEvent/CommunityChest/Referral use), which\napplies the title's progression-multiplier overlay\n(`cfg.Reward.MilestoneRewardMultiplier`) if configured — so the actual payout\ncan exceed the base `Rewards` grant; read it from the response, don't assume\nface value.\n\n**Caps** on points grants (`BuildPointsGrantContext`): **global** caps come from\n`QuestCycleDefinition.PointsToken` (an `EventTokenDefinition` — `DailyEarnCap`,\n`MaxBalance`, `MaxPerGrant`); **per-quest-source** caps/cooldown come from\n`QuestAvailability.Limits` (a `LimitSpec`, mapped as `DailyWeightCap` →\nper-source daily cap, `DailyCap` → per-source daily trigger count,\n`CooldownSeconds` → per-source cooldown). Per-source limits only apply in the\n**single** `ClaimQuestReward` path — the batch claim path\n(`ClaimQuestRewardsBatch`) only enforces the cycle's **global** `PointsToken`\ncaps against the **summed** batch amount per address, since per-source limits\ndon't make sense once amounts from multiple quests are merged into one token\noperation. If a cap fully exhausts the grant, `EventTokenService.ComputeGrant`\ncan reduce the amount to `0`, which surfaces as a failed points portion inside\nthe resource operation — always read granted amounts from the response, never\nassume the full `PointsReward` landed.\n\n---\n\n## Group-completion (grand reward) math\n\n`QuestCycleDefinition.GroupCompletions[completionID]` (`QuestGroupCompletionDefinition`):\n\n```ts\ninterface QuestGroupCompletionDefinition {\n CompletionID?: string;\n GroupID?: string; // must match QuestLinking.GroupID on member quests\n RequiredCompletedQuests?: number; // 0 = \"ALL quests in this group, per current config\"\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Reward?: ResourceGrant;\n PointsReward?: number; // additional points into the SAME cycle points track; 0 = none\n}\n```\n\n`ClaimGroupCompletionReward` computes eligibility **live**, at claim time, by\nscanning the **current** `QuestDefinitions.Quests` for every quest that (a)\nlists this `cycleID` in its `Linking.CycleIDs` and (b) has `Linking.GroupID` equal to the\ncompletion's `GroupID` — that's `totalGroupQuests`. Of those, it counts how many\nhave reached `Status === \"Completed\"` **or** `\"Claimed\"` in the player's current\ncycle state — that's `completedGroupQuests`. The required threshold is\n`RequiredCompletedQuests` if `> 0`, otherwise `totalGroupQuests` (i.e. every\ngroup quest currently in config). Failure modes:\n\n- `RequiredCompletedQuests` unset and the group is empty/misconfigured (no\n quests currently reference that `GroupID` in that cycle) →\n `\"No quests configured for this group\"` (required resolves to `0`, which is\n rejected outright — you can never claim an empty group).\n- `completedGroupQuests < required` → `\"Not enough completed quests for this\ngroup\"`.\n- Already in `cycle.ClaimedGroupCompletionIDs` → `\"Group completion already\nclaimed\"` (checked in-memory before the DB round-trip, then re-enforced by an\n `AnyEq`-negated Mongo filter for the actual OCC guard).\n- `completion.GroupID` blank/whitespace on the definition itself →\n `\"Group completion has no GroupID\"` (a config error, not a player error).\n\nBecause the scan is **live against current config**, removing a quest from the\ngroup (or from the cycle) between when a player completed it and when they\nclaim the group reward can change `totalGroupQuests`/`completedGroupQuests` —\nthere's no snapshot of \"the group as it was.\" The response echoes\n`CompletedGroupQuests` and `RequiredGroupQuests` (the resolved threshold, not\nthe raw config field) so the client can show \"3 / 3\" without recomputing\nanything.\n\n---\n\n## `AddQuestProgress` server-side rules\n\nFull request-to-mutation path (`QuestV2.AddQuestProgress` public entry point +\nthe internal shared helper), summarized because several rules only make sense\ntogether:\n\n1. `MetricID` required; must match at least one `ClientApi`-sourced objective\n in the **entire** quest catalog, or the call fails with `\"MetricID not\nallowed for ClientApi\"` before touching the database.\n2. `ProgressValue` (`long`) must be `>= 0`.\n3. If any matching objective declares `MaxProgressPerCall > 0`, the **raw**\n value is checked against the smallest such cap across all matches; exceeding\n it **bans the account** (see the objective section above) rather than\n clamping — this is a hard security control, not UX guidance.\n4. The (possibly `Sum`-clamped) value then fans out to **every** quest/objective\n pair across **every currently-earning cycle and every permanent quest**\n whose objective's `Source === \"ClientApi\"` and `MetricID` matches — one\n `addQuestProgress` call can move several quests (even across different\n cycles) simultaneously if they all listen to the same metric.\n5. Each matched quest only advances if it isn't already `Completed`/`Claimed`\n (`ApplyToQuestInstance` early-returns `false` otherwise) — so calling\n `addQuestProgress` for an action a player keeps performing after a quest is\n done is safe and a no-op for that quest.\n6. The response's `Updates[]` (`QuestProgressUpdate`) lists **only the\n quest/objective pairs that actually changed** this call — an objective whose\n `Sum` increment was clamped to `0` (already at `TargetValue`) or a locked\n quest that accrued nothing produces no entry.\n7. This whole path calls `RefreshQuestCycles` first (unless the internal helper\n is invoked with `ensureCyclesUpToDate: false`, which the public\n `AddQuestProgress` action does to avoid double-refreshing) — so cycle\n windows are always current before progress is evaluated.\n\n---\n\n## Idempotency, atomicity, batch limits\n\n- **Idempotency keys** (`ResourceService.ResolveRelatedEntityID`, stable ID\n patterns from `Quest.cs`): single quest claim →\n `\"{questID}\"` (permanent) or `\"{questID}_{cycleStartUtc:yyyyMMddHHmmss}\"`\n (cyclic — so the **same** quest claimed again after the cycle rolls to a new\n window is a distinct idempotency key, not a duplicate); milestone claim →\n `\"{cycleID}_{milestoneID}_{instanceKey}\"`; group completion →\n `\"{cycleID}_{completionID}_{cycleStartUtc:yyyyMMddHHmmss}\"`. You never\n construct these yourself — the TS SDK mints its own client-side\n `RelatedEntityID` (`quest_claim_…`, `milestone_claim_…`, `group_completion_…`,\n each suffixed with a fresh UUID) purely for its own request-level tracking;\n the **server-side** idempotency guarantee comes from the stable IDs above\n plus the OCC filter on each claim, not from the client's `RelatedEntityID`.\n- **Atomicity.** Every claim path (`ClaimQuestReward`, `ClaimMilestoneReward`,\n `ClaimGroupCompletionReward`, and both batch variants) runs the reward grant\n and the state-mutating patches (status → `Claimed`, milestone `$push`, group\n `$addToSet`) inside **one** `ResourceService.ApplyResourceOperationAtomicAsync`\n call with an `extraFilter` re-asserting the pre-claim condition (e.g. quest\n `Status == Completed`) — if the grant fails for any reason (insufficient\n server-side room, a concurrent claim already flipped the filter condition,\n etc.) the whole transaction rolls back; there is no partially-applied claim.\n- **Resources in batch responses.** For both `ClaimQuestRewardsBatch` and\n `ClaimMilestoneRewardsBatch`, all included items' rewards are merged into a\n **single** `ResourceBundle` (`BatchSupport.MergeBundles`) and charged/granted\n in one call — the merged `ResourceOperation` is attached to only the **first\n successful** `BatchItemResult.Data.Resources` in the returned array; every\n other successful item's `Data.Resources` is an **empty** `ResourceOperation`\n (`new ResourceOperation()`), not a duplicate of the shared one. Don't sum\n resources across batch items — read them once from wherever they landed (the\n TS SDK's `applyResourceOperation` is only ever called once, on the first\n `Resources` it finds, matching this).\n- **Batch size.** `BatchSupport.MaxBatchSize = 50`. Both\n `claimQuestRewardsBatch` and `claimMilestoneRewardsBatch` accumulate refs from\n your array only up to 50 (after deduping by `CycleID+QuestID` /\n `CycleID+MilestoneID`); anything past the 50th valid, deduped entry is\n **silently dropped** — it never appears in the result array at all, so a\n `results.length` shorter than your input isn't necessarily an error. Chunk\n larger sets yourself.\n- **Batch validity filtering happens before charging.** Each item is\n independently checked (mongo-safety, config existence, gates, schedule\n window, prerequisites, current `Status`) and rejected into a preset\n `BatchItemResult` **before** the shared resource operation runs; only\n surviving items contribute to the merged grant and the combined Mongo filter\n (`AND` of each item's own OCC filter). That combined filter means: if even\n one surviving item's condition is no longer true by the time the transaction\n actually commits (e.g. a race with another request), **the entire merged\n operation fails** and every surviving item in that batch call reports the\n same `apply.Error` — \"partial-aware\" describes the **pre-filtering** stage,\n not protection against a mid-flight race on the shared charge.\n- **Rate limit / lock.** The whole `QuestV2` function uses\n `RateLimitMilliseconds = 500` (per-IP endpoint throttle) and\n `LockDurationMilliseconds = 10000` (per-user-action Mongo transaction lock)\n inside `ClientRun.Execute` — both are backend-side controls independent of\n the TS SDK's own 600ms client-side throttle guard.\n"
8
+ "content": "# Quest data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nresolution rules, the points-track (\"Achievements\") plumbing, group-completion\nmath, and the server-side limits/idempotency rules. All of these are **strictly\ntyped in the SDK** — `QuestDefinitions` and every nested block (`QuestDefinition`\nand its `QuestIdentity`/`QuestLinking`/`QuestAvailability`/`QuestReward` blocks,\n`QuestCycleDefinition`, `QuestPhaseDefinition`, `QuestObjectiveDefinition`,\n`QuestGroupCompletionDefinition`, `QuestPresetRegistry`/`QuestPresetBindings`,\nthe shared `ScheduleSpec`/`SegmentGate`/`LimitSpec`/`MilestoneDefinition`/\n`EventTokenDefinition` blocks, …) are exported from `@idosgames/core`, so\n`getQuestDefinitions()` and `getSection<QuestDefinitions>(\"Quest\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight from\nthe backend JSON).\n\nBackend source of truth for everything below:\n`IDosGamesSDK/API/Client/v2/Quest/Quest.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/QuestDefinitions.cs`,\n`IDosGamesSDK/API/Client/v2/Quest/Models/UserQuestState.cs`,\n`IDosGamesSDK/API/Core/Scheduling/Services/ScheduleResolver.cs`,\n`IDosGamesSDK/API/Core/Event/Services/EventTokenService.cs`.\n\n## Contents\n\n- [Player state](#player-state) — what `getUserQuestState()` returns\n- [Config: QuestDefinitions](#config-questdefinitions) — what `getQuestDefinitions()` returns\n- [QuestCycleDefinition](#questcycledefinition)\n- [QuestDefinition](#questdefinition)\n- [Presets](#presets--authoring-n-days--m-tasks-without-nm-copies) — authoring N days × M tasks without N×M copies\n- [QuestObjectiveDefinition + progress aggregation](#questobjectivedefinition--progress-aggregation)\n- [Prerequisites (`RequiredQuestIDs`)](#prerequisites-requiredquestids)\n- [Cycle schedule resolution](#cycle-schedule-resolution)\n- [Per-quest schedule (\"staged unlock\" / Achievements)](#per-quest-schedule-staged-unlock--achievements)\n- [Points track (\"Achievements\") — the Quest event-token](#points-track-achievements--the-quest-event-token)\n- [Group-completion (grand reward) math](#group-completion-grand-reward-math)\n- [`AddQuestProgress` server-side rules](#addquestprogress-server-side-rules)\n- [Idempotency, atomicity, batch limits](#idempotency-atomicity-batch-limits)\n\n---\n\n## Player state\n\nReturned by `getUserQuestState()` as `{ State, PointsTracks }` and cached at\n`client.data.user.state?.Quest` (progress) + `client.data.user.state?.EventToken?.Quest`\n(points track — see below). Hand-written interfaces (not `z.infer`) because the\ncache-patch methods mutate these objects in place.\n\n```ts\ninterface UserQuestState {\n Cycles?: Record<string, UserQuestCycleState>; // key = CycleID\n PermanentQuests?: Record<string, UserQuestProgress>; // key = QuestID\n LastUpdatedUtc?: string;\n}\n\ninterface UserQuestCycleState {\n CycleID?: string;\n CycleStartUtc?: string; // current window start, UTC\n CycleEndUtc?: string; // current window end, UTC\n Quests?: Record<string, UserQuestProgress>; // key = QuestID, THIS window only\n ClaimedGroupCompletionIDs?: string[]; // CompletionIDs already claimed this window\n}\n\ninterface UserQuestProgress {\n QuestID: string;\n Status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n ActivatedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n ClaimedAtUtc?: string | null;\n Objectives?: Record<string, UserQuestObjectiveProgress>; // key = ObjectiveID\n}\n\ninterface UserQuestObjectiveProgress {\n ObjectiveID: string;\n CurrentValue: number;\n Completed: boolean;\n CompletedAtUtc?: string | null;\n}\n```\n\n**Lazy initialization** (`Quest.cs` `RefreshQuestCycles` / `CreateQuestProgressFromDefinition`):\na quest's `UserQuestProgress` (and each objective's `UserQuestObjectiveProgress`)\nis created only the first time progress is reported for it — the server does\n**not** pre-populate every configured quest/objective with zeros. A quest absent\nfrom `Cycles[cycleID].Quests` (or `PermanentQuests`) simply has zero progress on\nevery objective; render it as `\"Active\"`, not as an error or \"unknown\" state.\n`Expired` is only ever set for cyclic quests (never for permanent quests) and\nonly via the same lazy path — in practice you will see `Active` → `Completed` →\n`Claimed` for anything you've touched; a truly stale quest from a rolled-over\ncycle is simply absent (the whole cycle bucket gets replaced on rollover, see\nbelow), not flagged `Expired` in current code paths.\n\n---\n\n## Config: QuestDefinitions\n\nReturned by `getQuestDefinitions()`; cached via\n`client.data.config.getSection<QuestDefinitions>(\"Quest\")`.\n\n```ts\ninterface QuestDefinitions {\n Cycles?: Record<string, QuestCycleDefinition>; // key = CycleID\n Quests?: Record<string, QuestDefinition>; // key = QuestID\n Presets?: QuestPresetRegistry; // reusable blocks, one registry per QuestDefinition block\n}\n```\n\n**Quests arrive already assembled.** The config is _authored_ compactly — a field left unset on a\nquest comes from the preset bound to that block — but the backend resolves it once when it\nmaterializes the title config, so what `getQuestDefinitions()` returns already has every quest's\nblocks filled in. `Presets` rides along for editors; a game client never merges anything.\n\nAssembled is not the same as flattened: the **shape** stays blocked. A quest's name is at\n`Identity.DisplayName`, its cycles at `Linking.CycleIDs`, its window at `Availability.Schedule`,\nits payout at `Reward.Grant`.\n\nA quest is **permanent** iff its `Linking.CycleIDs` is null/empty; otherwise it is\n**cyclic** and belongs to every cycle listed there (a quest can appear\nin more than one cycle definition, each with independent progress/claim state).\nCycle IDs and quest/objective IDs must all be Mongo-safe (no `.` or `$`) —\nthe server rejects unsafe keys outright (`\"Invalid CycleID (mongo-unsafe): …\"`,\n`\"QuestID is mongo-unsafe\"`, etc.); this only matters if you let players or\nremote config drive raw ids into these fields.\n\n---\n\n## QuestCycleDefinition\n\n`Quest.cs` / `QuestDefinitions.cs`. One recurring or one-off \"board\" (dailies,\nweeklies, a scheduled event window, …). Quests do **not** get listed here —\neach `QuestDefinition` points back at the cycle via `Linking.CycleIDs`.\n\n```ts\ninterface QuestCycleDefinition {\n CycleID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // cycle window/reset — see below\n Milestones?: Record<string, MilestoneDefinition>; // points-track rungs, key = MilestoneID\n Phases?: Record<string, QuestPhaseDefinition>; // chain phases, only for Schedule.Mode = \"Chained\"\n Presets?: { Milestones?: PresetBinding }; // cycle-level preset wiring (Core/Presets)\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // audience gate for the whole cycle; ANDed with each quest's own Gate\n PointsToken?: EventTokenDefinition; // global caps/burn for the points track (see below)\n GroupCompletions?: Record<string, QuestGroupCompletionDefinition>; // key = CompletionID\n}\n```\n\nBackend default when a cycle is authored without an explicit `Schedule`:\n`Mode: \"Cyclic\"`, `Cyclic: {}` (i.e. daily calendar reset) —\n`QuestCycleDefinition.Schedule` in `QuestDefinitions.cs`.\n\n---\n\n## QuestDefinition\n\nOnly the ID lives at the root; everything else is a named block, exactly like\n`CharacterDefinition` (`Identity` / `Classification` / `Unlock` / `Stats` / …).\n\n```ts\ninterface QuestDefinition {\n QuestID?: string;\n Identity?: QuestIdentity;\n Linking?: QuestLinking;\n Availability?: QuestAvailability;\n Objectives?: Record<string, QuestObjectiveDefinition>; // key = ObjectiveID; ALL must complete\n Reward?: QuestReward;\n Presets?: QuestPresetBindings; // one binding per block — see Presets\n}\n\n/** Display part — analogous to CharacterIdentity. */\ninterface QuestIdentity {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number; // lower = earlier in UI; default 0\n AssetPaths?: Record<string, string>; // task icon and other client assets\n CustomParams?: Record<string, string>; // passed to the client untouched\n}\n\n/** Links — analogous to CharacterClassification. */\ninterface QuestLinking {\n CycleIDs?: string[]; // null/empty => permanent; else one entry per cycle it appears in\n GroupID?: string; // plain label: UI sections + QuestGroupCompletionDefinition matching\n PhaseIDs?: string[]; // chain phases this quest lives in; empty = all\n RequiredQuestIDs?: string[]; // prerequisite QuestIDs — see below\n PrerequisiteMode?: \"BlockProgressAndClaim\" | \"BlockClaimOnly\"; // default BlockProgressAndClaim\n}\n\n/** Access rules — analogous to CharacterUnlock. */\ninterface QuestAvailability {\n Schedule?: ScheduleSpec; // per-quest unlock window; unset = the cycle's window (see below)\n AccrueProgressWhenLocked?: boolean; // default false — see per-quest schedule section\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Limits?: LimitSpec; // per-source caps on POINTS grants only (not on objective progress)\n}\n\n/** Claim payout: the grant plus points into the cycle track. */\ninterface QuestReward {\n Grant?: ResourceGrant; // claimed via claimQuestReward\n PointsReward?: number; // points into the cycle's track on claim; ignored for permanent quests\n}\n```\n\nThere is **no group entity.** `Linking.GroupID` is a plain string: it groups quests into UI\nsections and it is what `QuestGroupCompletionDefinition` matches on. Nothing has to declare it,\nand nothing inherits through it.\n\nIn the **stored** config every block, and every field inside it, is optional in the strong sense —\nabsent means \"take it from the preset bound to this block\" (see\n[Presets](#presets--authoring-n-days--m-tasks-without-nm-copies)). By the time this reaches a\nclient the backend has already assembled them.\n\n`PrerequisiteMode` (`QuestDefinitions.cs` comment, verbatim intent):\n\n| Mode | Effect on `RequiredQuestIDs` |\n| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `BlockProgressAndClaim` (default) | The quest does not start accruing progress at all until every listed prerequisite reaches `Completed`/`Claimed`; consequently it also can't be claimed. |\n| `BlockClaimOnly` | Progress accrues immediately; only the final reward claim is blocked until prerequisites are met. |\n\nA prerequisite is looked up \"where its own progress lives\": permanent →\n`PermanentQuests`; cyclic → the same `cycleID` if the prerequisite also belongs\nto it, otherwise the prerequisite's own first `Linking.CycleIDs` entry\n(`ArePrerequisitesMet`, `Quest.cs`). Empty/null `RequiredQuestIDs` = no gating.\n\n---\n\n## Chains — a cycle that runs phases one after another\n\nSet the cycle's `Schedule.Mode` to `\"Chained\"` and fill `Phases`. The chain starts at\n`Schedule.Chain.AnchorUtc`, plays its phases in `Order`, then repeats (`MaxCycles`, pauses).\n\n```ts\ninterface QuestPhaseDefinition {\n PhaseID?: string; // unique within the chain; referenced by QuestLinking.PhaseIDs\n Order?: number; // position within one full pass (0, 1, 2...)\n DurationSec?: number; // how long the phase stays open\n ClaimGraceHours?: number; // extra claim window after it ends\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n Gate?: SegmentGate; // AND-ed with cycle gate and quest gate\n Milestones?: Record<string, MilestoneDefinition>; // null = the cycle's ladder is used\n Presets?: { Milestones?: PresetBinding };\n PointsToken?: EventTokenDefinition; // null = the cycle's token\n}\n```\n\nThree rules worth knowing before designing one:\n\n- **Every phase owns its progress.** The points-track address includes the instance key, and for\n a phase that key is `chain:{cycleIndex}:{phaseID}`. So week 2 starts from zero points with its\n own claimed-milestone list — it never inherits week 1. The cycle's quest state resets at a phase\n boundary exactly like it resets at midnight for a `Daily` cycle.\n- **Unset phase content falls back to the cycle.** Eight identical weeks are eight phases with\n only `Order`/`DurationSec` filled — not eight copies of the milestone ladder. The exception is an\n _empty_ (not absent) `Milestones` object: that explicitly means \"no milestones in this phase\".\n- **A `Chained` cycle with no phases never activates.** There is nothing to resolve, so the whole\n cycle stays closed and its quests never progress.\n\nBind a quest to specific phases with `Linking.PhaseIDs` (empty = every phase). It gates\nboth progress and claiming — a week-2 quest cannot be claimed during week 1, in single and batch\nclaims alike. `PhaseIDs` is the direct way to say \"this quest belongs to week two\"; the quest's own\n`Availability.Schedule` with a `Relative` window stays for staged unlocking _within_ one phase\n(\"Day N\").\n\n`GetUserQuestState` reports the live phase on each track: `PointsTracks[cycleID].PhaseID` and\n`.CycleIndex` (both absent/0 for a plain cycle) alongside `CycleStartUtc`/`CycleEndUtc` of that\nphase — enough to render \"Week 2 of 8\" and a countdown.\n\n### Milestone presets\n\n`QuestDefinitions.Presets.Milestones` is a registry of reusable `MilestoneSet`s keyed by PresetID —\nthe one preset block that belongs to cycles and phases rather than to quests. A cycle or a phase\nreferences one through `Presets.Milestones` (`PresetBinding`): the preset is the base, the inline\n`Milestones` dictionary overrides or adds by MilestoneID, and `Remove` drops keys. No PresetID ⇒\ninline only. An unknown PresetID silently falls back to inline — it never wipes the entity's own\nladder. Everything else about presets is in the next section.\n\n---\n\n## Presets — authoring N days × M tasks without N×M copies\n\nA seven-day event of six tasks a day is 42 quests that differ in three numbers. One mechanism\nexists so the config says that once instead of 42 times, and it is the **same one Character\nuses**: a registry of reusable blocks plus a binding per block. There is no second mechanism —\nno group entity, no chassis, no inheritance chain. It is **authoring-side only**: the backend\nresolves it at config load and everything downstream sees ordinary assembled quests.\n\n**The one rule:** _unset = take it from the preset, set = final._ A field that is absent takes its\nvalue from the preset bound to that block; a field that is present — **including `0`, `false` and\n`[]`** — wins and is never overwritten. That asymmetry is deliberate: \"this quest gives no points\"\n(`Reward.PointsReward: 0`) has to survive against a preset that grants 30.\n\n```ts\n/** Registry: one dictionary per block, each mirroring the same-named QuestDefinition block. */\ninterface QuestPresetRegistry {\n Milestones?: Record<string, MilestoneSet>; // cycles and chain phases only\n Linking?: Record<string, QuestLinking>;\n Availability?: Record<string, QuestAvailability>;\n Reward?: Record<string, QuestReward>;\n Objectives?: Record<string, Record<string, QuestObjectiveDefinition>>; // inner key = ObjectiveID\n}\n\n/** Wiring: one binding per block, exactly like CharacterDefinition.Presets. */\ninterface QuestPresetBindings {\n Milestones?: PresetBinding; // on a cycle / phase, not on a quest\n Linking?: PresetBinding;\n Availability?: PresetBinding;\n Reward?: PresetBinding;\n Objectives?: PresetBinding; // merges by ObjectiveID; `Remove` drops preset entries\n}\n```\n\n**Bindings are independent.** Take the schedule from one preset, the reward from another, and\nwrite the objectives inline — the blocks don't know about each other. Precedence inside one\nblock is just two layers:\n\n```\nquest's own field → the preset bound to that block → engine default\n```\n\n**`Identity` has no preset on purpose.** A quest's name and sort order are unique to it, and\n`Description` — the only field that is ever shared — is displayed by no client, so a registry for\nthis block added a binding to every quest and carried nothing. Write Identity inline.\n\nSingle-object blocks (`Linking` / `Availability` / `Reward`) merge **field by field**. `Objectives` merges **by ObjectiveID**, and inside a matched objective the same\nunset-takes-from-preset rule applies — that is the piece that pays for itself: the preset says\n_how_ an objective advances, the quest restates only what differs.\n\n```jsonc\n// preset: how \"make N moves\" works — written once\n\"Presets\": { \"Objectives\": { \"moves\": {\n \"task\": { \"Source\": \"SystemEvent\", \"TargetValue\": 15,\n \"Triggers\": [{ \"SourceType\": \"BoardTileLanding\" }] } } } }\n\n// day 5's quest: name and target are all that is unique\n\"Quests\": { \"e7_d5_moves\": {\n \"Identity\": { \"DisplayName\": \"Day 5. Make 35 moves\", \"SortOrder\": 501 },\n \"Presets\": {\n \"Linking\": { \"PresetID\": \"e7\" }, // cycle + group label, shared by all 42\n \"Availability\": { \"PresetID\": \"e7_d5\" }, // \"opens 4 days after the event starts\"\n \"Reward\": { \"PresetID\": \"e7_d5\" }, // day-5 payout, shared by that day's 6 tasks\n \"Objectives\": { \"PresetID\": \"moves\" }\n },\n \"Objectives\": { \"task\": { \"TargetValue\": 35 } } // triggers survive — only the number changes\n}}\n```\n\nThree things that bite if you don't know them:\n\n- **The dictionary key is the ID.** A quest or objective written without `QuestID` /\n `ObjectiveID` takes it from its key. In the compact form it is easy to omit, and an objective\n with no ID used to be skipped silently — the quest looked configured and never moved.\n- **An unknown PresetID falls back to inline**, it never wipes the block. A typo therefore shows\n up as a quest with a missing window or a missing reward, not as an error at load.\n- **`Remove` on `Presets.Objectives`** is the only way to take a preset objective away for a\n single quest.\n\n---\n\n## QuestObjectiveDefinition + progress aggregation\n\n```ts\ninterface QuestObjectiveDefinition {\n ObjectiveID?: string;\n Source?: \"ClientApi\" | \"ServerApi\" | \"SystemEvent\"; // what advances it; default ClientApi\n MaxProgressPerCall?: number; // ClientApi BAN threshold (not a clamp); 0/absent = no check\n MetricID?: string; // ClientApi/ServerApi only: the metric key reported against\n Triggers?: TriggerSource[]; // SystemEvent only: in-game events that advance it\n TargetValue?: number; // default 1; required value to complete\n AggregationMethod?: string; // \"Sum\" | \"Maximum\" | \"Minimum\" | \"Last\"; default \"Sum\"\n}\n```\n\n`Source` selects **which field is read** — they are mutually exclusive:\n\n| Source | Advanced by | Field read |\n| ------------- | ----------------------------------------------------------------- | ---------- |\n| `ClientApi` | the game calling `addQuestProgress(MetricID, v)` | `MetricID` |\n| `ServerApi` | a CloudCode script calling `server.AddQuestProgress(MetricID, v)` | `MetricID` |\n| `SystemEvent` | the backend itself, on in-game events | `Triggers` |\n\n`addQuestProgress` reaches **only** `ClientApi` objectives; a `MetricID` with no\nmatching `ClientApi` objective anywhere in the catalog is rejected outright\n(`\"MetricID not allowed for ClientApi\"`).\n\n### `Triggers` — SystemEvent objectives\n\n`Triggers` is the shared `TriggerSource` used by `EventContent.TokenSources`\n(TimedEvent) and `LeaderboardDefinition.ScoreSources`: an event type plus\nfilters, with `BaseWeight` as the progress step per fire. The list is OR-ed\n(first match wins). Empty/absent ⇒ the objective never advances.\n\nThe backend emits these event types into quests — anything else in\n`EventTokenSourceType` never reaches a quest (use `ClientApi` for\nclient-observed actions like watching an ad):\n\n`BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`, `BoardBuild`,\n`BoardStageComplete`, `BoardSpecialComplete` (GameLoop) · `StorePurchase`\n(`Store.Purchase` / `PurchaseBatch`, multiplier = purchase count) ·\n`MarketplaceSell` / `MarketplaceBuy` (settlement, **acting player only**) ·\n`QuestComplete` (`ClaimQuestReward`, for meta-quests) · `DailyLogin` (first login of a UTC day —\ndeduped at login, so ten re-entries in one evening count as one day) · `CurrencySpent`\n(`ResourceService`, on the applied consume; multiplier = **amount spent**) · `LootboxOpened`\n(multiplier = boxes opened in the call) · `LeaderboardRankReward` (fired when a rank reward is\nactually claimed, not while the standing changes) · `IapPurchase` (`PurchaseV2`, after the receipt\nis verified and the goods granted; multiplier = units granted — **also fires on subscription\nauto-renewals** from the store callback, tagged `Renewal: \"true\"`) · `CryptoDeposit` / `CryptoWithdraw`\n(deposit credited / withdrawal **confirmed on chain** — not on the request, which may never land;\nmultiplier = 1 operation) · `CryptoSpent` (crypto consumed in-game; multiplier = amount) ·\n`CurrencyEarned` / `CryptoEarned` (`ResourceService`, on the applied **grant**, premium tiers\nincluded; multiplier = **amount granted**).\n\nTwo of these carry an _amount_ in the multiplier rather than a count, which makes\n`ScaleWithRollMultiplier` the switch between two different goals:\n\n| Source | `true` | `false` |\n| ---------------- | ---------------------------------------------- | --------------------------- |\n| `CurrencySpent` | \"spend 100 coins\" | \"make 100 separate spends\" |\n| `CryptoSpent` | \"spend 100 tokens\" | \"make 100 separate spends\" |\n| `CurrencyEarned` | \"earn 1000 coins\" | \"receive coins 1000 times\" |\n| `CryptoEarned` | \"earn 100 tokens\" | \"receive tokens 100 times\" |\n| `LootboxOpened` | \"open 15 chests\" (one call of 15 counts fully) | \"open a chest 15 times\" |\n| `IapPurchase` | \"buy 5 units\" (a x5 pack counts fully) | \"make 5 separate purchases\" |\n\nSoft currency, crypto and real money are three **separate** sources on purpose: a goal like\n\"spend 100\" must not be closeable by coins one day and by tokens or dollars the next. If a title\nstores crypto in minimal (wei-like) units, set `ScaleWithRollMultiplier: false` on `CryptoSpent`\nand count operations — the amount would otherwise be astronomically large.\n\n`Params` filters the matcher actually checks: `StorePurchase` → `OfferID`;\n`QuestComplete` → `QuestID`, `CycleID`; `Marketplace*` → `CatalogID`, `ItemID`,\n`OfferType`; `CustomAction` → `ActionName`; `CurrencySpent` → `CurrencyID`;\n`LootboxOpened` → `LootboxID`; `IapPurchase` → `ProductID`, `Store`, `Renewal`\n(`\"true\"` = subscription auto-renewal, `\"false\"` = the player bought it by hand; omit to count\nboth — money was paid either way);\n`CryptoDeposit` / `CryptoWithdraw` → `CurrencyID`, `NetworkID`; `CryptoSpent` → `CurrencyID`;\n`CurrencyEarned` / `CryptoEarned` → `CurrencyID`, `Origin`\n(`\"Gameplay\"` = only what the game paid out, `\"RewardClaim\"` = only quest/milestone/rank/season/daily\npayouts, omit to count both — a goal like \"earn 1000 coins\" is otherwise partly closed by other\nquests' rewards);\n`LeaderboardRankReward` → `LeaderboardID`, `Rank`\n(exact match — \"first place\" is `Rank: \"1\"`; for \"top 3\" declare three sources or omit `Rank`).\nAny other key is stored but ignored.\n\nSet `ScaleWithRollMultiplier: false` on a quest trigger unless you _want_ the\nboard's roll multiplier to inflate the step — otherwise \"win 3 raids\" closes on\na single x3 raid.\n\n`SystemEvent` progress is applied **after** the handler succeeds and returns on\nthat response's envelope as `QuestProgress: QuestProgressUpdate[]` (absent when\nnothing moved). The guarantee is at-most-once: the game action is already\ncommitted, so a failure here loses the event rather than rolling the action back.\n\nAggregation (`ApplyAggregation`, `Quest.cs`), given the objective's current\n`CurrentValue` and the incoming call value:\n\n| Method | New value |\n| ------------------------ | ---------------------------------------------------------------------------------------- |\n| `Sum` (default) | `current + incoming`, clamped to `long.MaxValue` on overflow; `incoming <= 0` is a no-op |\n| `Maximum` | `max(current, incoming)` |\n| `Minimum` | `incoming` if `current == 0`, else `min(current, incoming)` |\n| `Last` (or unrecognized) | `incoming` (last-write-wins) |\n\nAfter aggregation, if `TargetValue > 0` the new value is clamped to\n`TargetValue` (progress bars never overshoot 100%); if `TargetValue <= 0` there\nis no cap. An objective is marked `Completed` once\n`(TargetValue <= 0 && newValue > 0) || newValue >= TargetValue`. A quest becomes\n`\"Completed\"` once **every** objective the player has a progress record for is\n`Completed` **and** every objective in the definition has a progress record —\ni.e. an objective with zero recorded progress blocks completion (it's absent\nfrom the player's `Objectives` map, so the `All(...)` check in\n`EnsureQuestObjectivesAndCompletion` fails for it).\n\n`MaxProgressPerCall` guards two different things depending on\n`AggregationMethod`:\n\n- If **any** matching `ClientApi` objective for the `MetricID` has\n `MaxProgressPerCall > 0`, the server compares the **raw** `ProgressValue` you\n sent against the (minimum across matches) cap **before** any clamping. If the\n raw value exceeds it, the call is rejected **and the player is banned**\n (`service.BanUser(...)`, fire-and-forget) with error `\"User banned: Value\nexceeds MaxValuePerCall\"`. This is a hard anti-abuse trip-wire, not a soft\n clamp — never let client code send inflated values \"to be safe.\"\n- Only for objectives using `Sum` aggregation is the value additionally\n clamped to `MaxProgressPerCall` before summing (defense in depth; irrelevant\n once the ban-check above has already passed, since raw ⇐ cap by that point).\n\n---\n\n## Prerequisites (`RequiredQuestIDs`)\n\nSee the `PrerequisiteMode` table above. Enforcement points in `Quest.cs`:\n\n- **Progress accrual** (`AddQuestProgress` internal helper): for permanent\n quests and for each cycle a cyclic quest belongs to, prerequisites are\n checked (only in `BlockProgressAndClaim` mode) before the quest's\n `UserQuestProgress` is even created/updated for that call.\n- **Claim** (`ClaimQuestReward` / batch): `ArePrerequisitesMet(...)` is checked\n unconditionally (both modes gate the claim) — error\n `\"Prerequisite quests are not completed\"`.\n\n---\n\n## Cycle schedule resolution\n\n`QuestCycleDefinition.Schedule` is a `ScheduleSpec` (`_shared/ScheduleModels.ts`\n/ `Core/Scheduling`), the same primitive every other module uses. For Quest,\n`RefreshQuestCycles` resolves it via `ScheduleResolver.ResolveActive(...)`\ninto a `[CycleStartUtc, CycleEndUtc)` window per the active `Mode`:\n\n- **`Cyclic`** (the practical default for dailies/weeklies): `Cyclic.Reset` picks\n the calendar cadence — `Hourly`/`Daily`/`Weekly` (always **Monday** start)/`Monthly`\n (always the **1st**)/`Yearly` are calendar-aligned in **UTC**, reset time is\n **always 00:00:00 UTC** and is not configurable. `FixedInterval` instead repeats\n every `Cyclic.IntervalSeconds` seconds from `Cyclic.AnchorUtc` (default anchor\n `2026-01-01T00:00:00Z`, default interval 86400s if unset/≤0); an optional\n `PauseBetweenCyclesSec` inserts a dead gap after each active window during\n which `CanEarn` is `false` (`IsInPause = true`) but the window has still\n technically \"ended\" — new progress does not accrue during the pause, though\n already-completed quests remain claimable (claims are never earn-gated).\n- **`Scheduled`**: one fixed `[StartUtc, EndUtc]` window; `ClaimGraceHours`\n extends claimability past `EndUtc` without extending earning (unless\n `AllowEarningAfterEnd` is set).\n- **`AlwaysOn`**: always active, no end.\n- Any other/inactive resolution (e.g. `Triggered`, or `spec.IsActive === false`)\n makes `ComputeCycleWindowUtc` **degrade to a plain UTC calendar day**\n `[today 00:00, tomorrow 00:00)` as a fallback — don't configure `Triggered` on\n a quest cycle expecting anything else.\n\n**Rollover behavior** (`RefreshQuestCycles`, called automatically by\n`getUserQuestState({autoRefreshCycles: true})` — the default — and before every\nmutating Quest action): when the resolved `[start, end)` no longer matches the\nstored `CycleStartUtc`/`CycleEndUtc`, the entire `UserQuestCycleState` for that\ncycle is **replaced with a brand-new, empty one** (`Quests: {}`,\n`ClaimedGroupCompletionIDs` reset) — there is no partial carry-over of\nin-progress quests into the new window. Cycles removed from config entirely are\ndeleted from the player's state on the next refresh. If the window has **not**\nrolled over, the refresh instead walks the player's **existing** quest progress\nrecords (only ones already started) and re-evaluates `Completed` status against\ncurrent config — it does not add new objectives to already-tracked quests.\n\n---\n\n## Per-quest schedule (\"staged unlock\" / Achievements)\n\n`QuestAvailability.Schedule` is an **independent, optional** `ScheduleSpec` layered\non top of the cycle's own schedule — this is how \"Day 2 unlocks 24h after Day 1\"\nor an \"Achievements\" track with a `ScheduleSpec`-driven unlock (rather than a\nliteral day-count) is built, with any number of stages at any interval, not just\nliteral days:\n\n- `Schedule` absent → the quest simply inherits its cycle's window; earning and\n claiming follow the cycle's own `CanEarn`/`CanClaim`.\n- `Schedule` present → resolved via `ResolveQuestInstance`, which passes the\n **cycle's** resolved instance as the `parent` for `Relative`-mode windows —\n so a per-quest `Relative` schedule with `OffsetSecondsFromParentStart` is\n \"N seconds after this cycle instance started,\" letting one `Cyclic` cycle\n auto-repeat a whole staged sequence without hardcoded absolute dates.\n- `Availability.AccrueProgressWhenLocked` (default `false`) decides what happens **while**\n the cycle's window is open but the quest's own window is not: `false` means a\n locked stage accrues **zero** progress (a true lock — progress reported for\n its metric while locked is simply dropped for that quest); `true` means\n progress accrues the whole time the cycle is active, but the **reward claim**\n is still gated on the quest's own `CanClaim` — so you can pre-accrue \"Day 3\"\n progress while day 3 is still locked, and only the payout waits.\n\nEarning gate precedence for a cyclic quest, all of which must pass\n(`AddQuestProgress` internal helper): cycle `earningCycles` membership (cycle\nitself must be `CanEarn`, i.e. not `IsInPause`) → quest's own\n`IsQuestEarnable` (`AccrueProgressWhenLocked` bypasses this specific check) →\n`QuestGatesPass` (cycle `Gate` AND `Availability.Gate`) → prerequisites (only in\n`BlockProgressAndClaim` mode).\n\n---\n\n## Points track (\"Achievements\") — the Quest event-token\n\nThis is the mechanism the \"Achievements\" hint in the prompt refers to — it is\nreal and it is exactly the cycle's points track, not a separate module. Russian\ncomments in `Quest.cs` literally label it «Достижения» (Achievements).\n\n**How points get earned.** Each cyclic `QuestDefinition.Reward.PointsReward` (points,\nnot currency) is granted **only on claim** of that quest's own reward — via\n`ClaimQuestReward` / `ClaimQuestRewardsBatch`, in the **same atomic transaction**\nas the quest's `Reward` grant. It's `long`, defaults to `0`, and is ignored for\npermanent quests (`isPermanent` quests never touch the points track). A group\ncompletion (below) can **also** add points via its own `PointsReward`, on top of\nwhatever its member quests already contributed individually.\n\n**Where it's addressed.** The points track is backed by a standard\n`EventTokenType.Quest` event token (the same primitive TimedEvent/Leaderboard/\nCoopEvent/Season points tracks use), addressed at\n`EntityID = \"{cycleID}:{instanceKey}\"` where `instanceKey` comes from\n`ScheduleResolver`'s resolution of the **cycle's** schedule (`\"all\"` if the\ncycle has no resolvable instance). Because the instance key changes when the\ncycle's schedule rotates to a new window, **the points balance and claimed-milestone\nlist reset automatically on cycle rollover** — there is no explicit\n\"reset points\" step; it's a natural consequence of the address changing.\n\n**Where it lives in state.** `UserQuestState` does **not** carry the points\nbalance — it lives in `UserDataDocument.EventToken.Quest[entityID]`\n(`UserEventTokenProgress`: `Balance.Current`/`Balance.TotalEarned`,\n`Milestone.ClaimedIDs`). `GetUserQuestState` additionally projects a **read-only\nsnapshot** per cycle into `GetUserQuestStateResponse.PointsTracks[cycleID]`\n(`QuestPointsTrackView`) so the client doesn't have to know the composite key —\nthe TS SDK's `patchQuestPointsTracks` writes this into\n`client.data.user.state.EventToken.Quest[\"{cycleID}:{instanceKey}\"]` for you,\nand `client.data.user.getQuestPointsProgress(cycleID)` resolves the composite\nkey back out (`matchesBase`, `util/eventTokenIds.ts`) so you can look it up by\nplain `cycleID`.\n\n```ts\ninterface QuestPointsTrackView {\n CycleID: string;\n InstanceKey?: string | null;\n CycleStartUtc?: string | null;\n CycleEndUtc?: string | null; // source for a \"resets in\" timer\n PointsTotalEarned?: number | null; // lifetime points earned this cycle instance\n PointsCurrent?: number | null; // current balance (== TotalEarned; points are never spent)\n ClaimedPointMilestoneIDs?: string[] | null;\n}\n```\n\n**Milestones** (`QuestCycleDefinition.Milestones`, keyed by `MilestoneID`) are\nthe shared Core `MilestoneDefinition` primitive\n(`RequiredProgress`, `Rewards`, `BonusRewards`, `SeasonTierRewards`,\n`SortOrder`, `IsFeatured`) — see `_shared/MilestoneModels.ts`. Eligibility is\njudged **only** against `Balance.TotalEarned` on the points token (never\n`Current`, though for Quest the two happen to always be equal since points are\nonly ever granted, never spent) — `EventTokenService.ComputeMilestoneClaim`:\nfails with `\"Not enough earned. Have: X, need: Y.\"` if under threshold, or\n`\"Milestone already claimed.\"` if `MilestoneID` is already in `ClaimedIDs`.\nClaiming pushes the id into `ClaimedIDs` via a Mongo `$push` guarded by a\n`$nin` filter (OCC — a concurrent duplicate claim loses the race cleanly). The\nmilestone's reward itself runs through the shared `MilestoneRewardResolver`\n(same resolver Leaderboard/TimedEvent/CommunityChest/Referral use), which\napplies the title's progression-multiplier overlay\n(`cfg.Reward.MilestoneRewardMultiplier`) if configured — so the actual payout\ncan exceed the base `Rewards` grant; read it from the response, don't assume\nface value.\n\n**Caps** on points grants (`BuildPointsGrantContext`): **global** caps come from\n`QuestCycleDefinition.PointsToken` (an `EventTokenDefinition` — `DailyEarnCap`,\n`MaxBalance`, `MaxPerGrant`); **per-quest-source** caps/cooldown come from\n`QuestAvailability.Limits` (a `LimitSpec`, mapped as `DailyWeightCap` →\nper-source daily cap, `DailyCap` → per-source daily trigger count,\n`CooldownSeconds` → per-source cooldown). Per-source limits only apply in the\n**single** `ClaimQuestReward` path — the batch claim path\n(`ClaimQuestRewardsBatch`) only enforces the cycle's **global** `PointsToken`\ncaps against the **summed** batch amount per address, since per-source limits\ndon't make sense once amounts from multiple quests are merged into one token\noperation. If a cap fully exhausts the grant, `EventTokenService.ComputeGrant`\ncan reduce the amount to `0`, which surfaces as a failed points portion inside\nthe resource operation — always read granted amounts from the response, never\nassume the full `PointsReward` landed.\n\n---\n\n## Group-completion (grand reward) math\n\n`QuestCycleDefinition.GroupCompletions[completionID]` (`QuestGroupCompletionDefinition`):\n\n```ts\ninterface QuestGroupCompletionDefinition {\n CompletionID?: string;\n GroupID?: string; // must match QuestLinking.GroupID on member quests\n RequiredCompletedQuests?: number; // 0 = \"ALL quests in this group, per current config\"\n Gate?: SegmentGate; // ANDed with the cycle's Gate\n Reward?: ResourceGrant;\n PointsReward?: number; // additional points into the SAME cycle points track; 0 = none\n}\n```\n\n`ClaimGroupCompletionReward` computes eligibility **live**, at claim time, by\nscanning the **current** `QuestDefinitions.Quests` for every quest that (a)\nlists this `cycleID` in its `Linking.CycleIDs` and (b) has `Linking.GroupID` equal to the\ncompletion's `GroupID` — that's `totalGroupQuests`. Of those, it counts how many\nhave reached `Status === \"Completed\"` **or** `\"Claimed\"` in the player's current\ncycle state — that's `completedGroupQuests`. The required threshold is\n`RequiredCompletedQuests` if `> 0`, otherwise `totalGroupQuests` (i.e. every\ngroup quest currently in config). Failure modes:\n\n- `RequiredCompletedQuests` unset and the group is empty/misconfigured (no\n quests currently reference that `GroupID` in that cycle) →\n `\"No quests configured for this group\"` (required resolves to `0`, which is\n rejected outright — you can never claim an empty group).\n- `completedGroupQuests < required` → `\"Not enough completed quests for this\ngroup\"`.\n- Already in `cycle.ClaimedGroupCompletionIDs` → `\"Group completion already\nclaimed\"` (checked in-memory before the DB round-trip, then re-enforced by an\n `AnyEq`-negated Mongo filter for the actual OCC guard).\n- `completion.GroupID` blank/whitespace on the definition itself →\n `\"Group completion has no GroupID\"` (a config error, not a player error).\n\nBecause the scan is **live against current config**, removing a quest from the\ngroup (or from the cycle) between when a player completed it and when they\nclaim the group reward can change `totalGroupQuests`/`completedGroupQuests` —\nthere's no snapshot of \"the group as it was.\" The response echoes\n`CompletedGroupQuests` and `RequiredGroupQuests` (the resolved threshold, not\nthe raw config field) so the client can show \"3 / 3\" without recomputing\nanything.\n\n---\n\n## `AddQuestProgress` server-side rules\n\nFull request-to-mutation path (`QuestV2.AddQuestProgress` public entry point +\nthe internal shared helper), summarized because several rules only make sense\ntogether:\n\n1. `MetricID` required; must match at least one `ClientApi`-sourced objective\n in the **entire** quest catalog, or the call fails with `\"MetricID not\nallowed for ClientApi\"` before touching the database.\n2. `ProgressValue` (`long`) must be `>= 0`.\n3. If any matching objective declares `MaxProgressPerCall > 0`, the **raw**\n value is checked against the smallest such cap across all matches; exceeding\n it **bans the account** (see the objective section above) rather than\n clamping — this is a hard security control, not UX guidance.\n4. The (possibly `Sum`-clamped) value then fans out to **every** quest/objective\n pair across **every currently-earning cycle and every permanent quest**\n whose objective's `Source === \"ClientApi\"` and `MetricID` matches — one\n `addQuestProgress` call can move several quests (even across different\n cycles) simultaneously if they all listen to the same metric.\n5. Each matched quest only advances if it isn't already `Completed`/`Claimed`\n (`ApplyToQuestInstance` early-returns `false` otherwise) — so calling\n `addQuestProgress` for an action a player keeps performing after a quest is\n done is safe and a no-op for that quest.\n6. The response's `Updates[]` (`QuestProgressUpdate`) lists **only the\n quest/objective pairs that actually changed** this call — an objective whose\n `Sum` increment was clamped to `0` (already at `TargetValue`) or a locked\n quest that accrued nothing produces no entry.\n7. This whole path calls `RefreshQuestCycles` first (unless the internal helper\n is invoked with `ensureCyclesUpToDate: false`, which the public\n `AddQuestProgress` action does to avoid double-refreshing) — so cycle\n windows are always current before progress is evaluated.\n\n---\n\n## Idempotency, atomicity, batch limits\n\n- **Idempotency keys** (`ResourceService.ResolveRelatedEntityID`, stable ID\n patterns from `Quest.cs`): single quest claim →\n `\"{questID}\"` (permanent) or `\"{questID}_{cycleStartUtc:yyyyMMddHHmmss}\"`\n (cyclic — so the **same** quest claimed again after the cycle rolls to a new\n window is a distinct idempotency key, not a duplicate); milestone claim →\n `\"{cycleID}_{milestoneID}_{instanceKey}\"`; group completion →\n `\"{cycleID}_{completionID}_{cycleStartUtc:yyyyMMddHHmmss}\"`. You never\n construct these yourself — the TS SDK mints its own client-side\n `RelatedEntityID` (`quest_claim_…`, `milestone_claim_…`, `group_completion_…`,\n each suffixed with a fresh UUID) purely for its own request-level tracking;\n the **server-side** idempotency guarantee comes from the stable IDs above\n plus the OCC filter on each claim, not from the client's `RelatedEntityID`.\n- **Atomicity.** Every claim path (`ClaimQuestReward`, `ClaimMilestoneReward`,\n `ClaimGroupCompletionReward`, and both batch variants) runs the reward grant\n and the state-mutating patches (status → `Claimed`, milestone `$push`, group\n `$addToSet`) inside **one** `ResourceService.ApplyResourceOperationAtomicAsync`\n call with an `extraFilter` re-asserting the pre-claim condition (e.g. quest\n `Status == Completed`) — if the grant fails for any reason (insufficient\n server-side room, a concurrent claim already flipped the filter condition,\n etc.) the whole transaction rolls back; there is no partially-applied claim.\n- **Resources in batch responses.** For both `ClaimQuestRewardsBatch` and\n `ClaimMilestoneRewardsBatch`, all included items' rewards are merged into a\n **single** `ResourceBundle` (`BatchSupport.MergeBundles`) and charged/granted\n in one call — the merged `ResourceOperation` is attached to only the **first\n successful** `BatchItemResult.Data.Resources` in the returned array; every\n other successful item's `Data.Resources` is an **empty** `ResourceOperation`\n (`new ResourceOperation()`), not a duplicate of the shared one. Don't sum\n resources across batch items — read them once from wherever they landed (the\n TS SDK's `applyResourceOperation` is only ever called once, on the first\n `Resources` it finds, matching this).\n- **Batch size.** `BatchSupport.MaxBatchSize = 50`. Both\n `claimQuestRewardsBatch` and `claimMilestoneRewardsBatch` accumulate refs from\n your array only up to 50 (after deduping by `CycleID+QuestID` /\n `CycleID+MilestoneID`); anything past the 50th valid, deduped entry is\n **silently dropped** — it never appears in the result array at all, so a\n `results.length` shorter than your input isn't necessarily an error. Chunk\n larger sets yourself.\n- **Batch validity filtering happens before charging.** Each item is\n independently checked (mongo-safety, config existence, gates, schedule\n window, prerequisites, current `Status`) and rejected into a preset\n `BatchItemResult` **before** the shared resource operation runs; only\n surviving items contribute to the merged grant and the combined Mongo filter\n (`AND` of each item's own OCC filter). That combined filter means: if even\n one surviving item's condition is no longer true by the time the transaction\n actually commits (e.g. a race with another request), **the entire merged\n operation fails** and every surviving item in that batch call reports the\n same `apply.Error` — \"partial-aware\" describes the **pre-filtering** stage,\n not protection against a mid-flight race on the shared charge.\n- **Rate limit / lock.** The whole `QuestV2` function uses\n `RateLimitMilliseconds = 500` (per-IP endpoint throttle) and\n `LockDurationMilliseconds = 10000` (per-user-action Mongo transaction lock)\n inside `ClientRun.Execute` — both are backend-side controls independent of\n the TS SDK's own 600ms client-side throttle guard.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "referral-system",
3
3
  "description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (who they're subscribed to, follower count, claimed invite rewards), activate someone else's referral code, and claim a staged invite reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants invite-friend / referral-code / refer-a-friend UIs, follower-milestone reward screens, or otherwise touches client.referral, ReferralService, ReferralDefinitions, UserReferralState, or referral codes — even if they don't name the module explicitly.",
4
- "content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount` and `FollowerIDs`, and which `InviteRewards` they've\n claimed (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------ | ------------------------------------------------------------------- | ------------------------------ |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount`/`FollowerIDs` are decremented/pulled, the new\n one incremented, and `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.FollowerIDs;\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
4
+ "content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount` and `FollowerIDs`, and which `InviteRewards` they've\n claimed (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------ | ------------------------------------------------------------------- | ------------------------------ |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n| `claimInviteRewardsBatch(inviteRewardIDs)` | Claim several milestones in ONE atomic call. Merged `Resources` sits at the TOP level; per-item `Data.Resources` is null (summing both would double count). Invalid items fail alone. | `ClaimInviteRewardsBatchResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount`/`FollowerIDs` are decremented/pulled, the new\n one incremented, and `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.FollowerIDs;\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -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\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",