@idosgames/mcp 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedFromCommit": "d12deae18316d281a4c5eb90f407ee19464cf29d",
2
+ "generatedFromCommit": "4eb28de9e6660c1fe88ce28d6d9d77c16a444199",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.2.0",
5
- "@idosgames/wallet": "0.1.13",
6
- "@idosgames/module-sdk": "0.1.3",
7
- "@idosgames/react": "0.1.1",
8
- "@idosgames/app-shell": "0.1.7"
4
+ "@idosgames/core": "0.3.0",
5
+ "@idosgames/wallet": "0.1.14",
6
+ "@idosgames/module-sdk": "0.1.4",
7
+ "@idosgames/react": "0.1.2",
8
+ "@idosgames/app-shell": "0.1.8"
9
9
  },
10
10
  "host": {
11
11
  "id": "host-starter",
@@ -239,7 +239,7 @@
239
239
  },
240
240
  {
241
241
  "name": "quest-system",
242
- "description": ""
242
+ "description": "Build a quest / daily-task system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.quest (QuestService): load quest and cycle definitions, load the player's quest progress state, add progress toward a metric, claim a completed quest's reward, claim a points-track milestone reward, claim a group-completion (grand) reward, and refresh cycles (dailies/ weeklies) forward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest screens, task lists, objective/progress trackers, battle-pass-style points tracks, milestone reward ladders, quest-group completion bonuses, or otherwise touches client.quest, QuestService, QuestDefinitions, UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they don't name the module explicitly."
243
243
  },
244
244
  {
245
245
  "name": "referral-system",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "quest-system",
3
- "description": "",
4
- "content": "---\r\nname: quest-system\r\ndescription: >-\r\n Build a quest / daily-task system in a game on the iDosGames TypeScript SDK\r\n (@idosgames/core) via client.quest (QuestService): load quest and cycle\r\n definitions, load the player's quest progress state, add progress toward a\r\n metric, claim a completed quest's reward, claim a points-track milestone\r\n reward, claim a group-completion (grand) reward, and refresh cycles (dailies/\r\n weeklies) forward. Use this whenever the user is working in the iDosGames TS\r\n SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest\r\n screens, task lists, objective/progress trackers, battle-pass-style points\r\n tracks, milestone reward ladders, quest-group completion bonuses, or\r\n otherwise touches client.quest, QuestService, QuestDefinitions,\r\n UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they\r\n don't name the module explicitly.\r\n---\r\n\r\n# Quest system (iDosGames TS SDK)\r\n\r\nThe Quest module runs a title's task/quest board: dailies, weeklies, permanent\r\nquests, and one-off event quests, each made of objectives that accrue progress\r\ntoward a metric. Everything is **server-authoritative**: the backend tracks\r\nprogress, decides when a quest is `Completed`, and validates every claim. The\r\nclient asks the backend to report progress or claim a reward, and the SDK\r\nmirrors the confirmed result into a local cache your UI reads. You never\r\ncompute quest status yourself — you call a method, check the result, and\r\nrender from the cache.\r\n\r\nThis skill is for **using** the production `QuestService`, not for porting or\r\nextending it. If a call is rejected, that's the backend enforcing a rule\r\n(objective not met, already claimed, prerequisite quest incomplete) — surface\r\nthe error, don't try to reproduce the check client-side.\r\n\r\n## The two data shapes\r\n\r\nKeep these straight; every recipe below is just moving between them.\r\n\r\n1. **Definitions** (config, same for every player) — the title's catalog of\r\n quest cycles (dailies/weeklies/permanent), the quests inside each cycle,\r\n their objectives/rewards/prerequisites, and the cycle's milestone points\r\n track and group-completion grand rewards. Fetched with\r\n `getQuestDefinitions()`.\r\n2. **User quest state** (state, per player) — this player's live progress:\r\n which cycle instances are active, each quest's `Status` and per-objective\r\n `CurrentValue`, and the points-track balance/claimed-milestone ids for each\r\n cycle. Fetched with `getUserQuestState()`.\r\n\r\nA quest lives either **inside a cycle** (`CycleID` set — dailies, weeklies,\r\nseasonal) or as a **permanent quest** (no `CycleID` — a one-time or\r\nalways-available quest, e.g. onboarding). Most methods take an optional/blank\r\n`CycleID` to address either; the cache keeps them in separate buckets\r\n(`Quest.Cycles[cycleID]` vs `Quest.PermanentQuests`).\r\n\r\nThree distinct reward mechanisms — don't conflate them:\r\n\r\n- **Quest reward** — the `Reward` on one `QuestDefinition`, claimed once that\r\n quest's objectives are all met (`Status: \"Completed\"`), via\r\n `claimQuestReward`. Moves the quest to `\"Claimed\"`.\r\n- **Chain phase** — a cycle whose `Schedule.Mode` is `\"Chained\"` plays its `Phases` one after\r\n another and then repeats. Each phase is a separate window with its **own** points track and its\r\n **own** claimed milestones, so a \"season\" of eight weeks is one cycle, not eight. Quests bind to\r\n phases with `PhaseIDs`. The live phase arrives in `PointsTracks[cycleID].PhaseID`.\r\n- **Milestone reward** — a rung on a cycle's **points track** (backend/config\r\n comments call this \"Achievements\"): claiming a quest with `PointsReward > 0`\r\n also grants that many points into a per-cycle point balance — a dedicated\r\n `EventTokenType.Quest` token, tracked separately from any single quest's own\r\n claim status — in the same atomic transaction as the quest claim. Each\r\n `MilestoneDefinition` in `Cycle.Milestones` pays out once that balance's\r\n lifetime total crosses its `RequiredProgress`. Claimed via\r\n `claimMilestoneReward`. This is the battle-pass-style ladder — a player can\r\n hit a milestone from points earned across many different quest claims, and\r\n milestone eligibility never re-checks any individual quest's status.\r\n- **Group-completion reward** — a grand bonus in `Cycle.GroupCompletions` that\r\n pays out once at least `RequiredCompletedQuests` quests sharing a `GroupID`\r\n have reached `\"Completed\"` (not necessarily claimed). Claimed via\r\n `claimGroupCompletionReward`.\r\n\r\nAll three can be in flight simultaneously for the same cycle — completing one\r\nquest can push its points into the milestone track, count toward its group's\r\ncompletion total, _and_ be individually claimable, all at once.\r\n\r\n**Progress** is reported with `addQuestProgress(metricID, progressValue)` — a\r\ngeneric counter keyed by `MetricID`, not by quest id. The backend fans one\r\nmetric update out to every objective across every active quest that listens to\r\nthat `MetricID` (per each objective's own `AggregationMethod`/filters), and\r\nreturns the list of quests/objectives that changed. You call this from your\r\ngame-loop code wherever the underlying action happens (e.g. \"enemy defeated\" →\r\n`addQuestProgress(\"EnemiesDefeated\", 1)`), not once per quest.\r\n\r\n**Cycles** (dailies/weeklies) roll forward on a schedule. `getUserQuestState`\r\ndefaults to auto-refreshing stale cycles for you (`autoRefreshCycles = true`);\r\ncall `refreshQuestCycles()` directly when you want to force-check for a new\r\ncycle boundary (e.g. app resumed from background) without re-fetching the\r\nwhole state.\r\n\r\nFor the full field-by-field shape of Definitions and state (objective sources,\r\nprerequisite modes, schedule/limit/gate blocks, the points-track/milestone\r\nplumbing), read [references/data-model.md](references/data-model.md). You do\r\n**not** need it to call the methods — only to drive richer UI off the config.\r\n\r\n## Setup\r\n\r\n```ts\r\nimport { createIDosGamesClient } from \"@idosgames/core\";\r\n\r\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\r\nawait client.auth.loginWithDeviceID(); // or any auth.* method\r\n\r\nconst quest = client.quest; // the QuestService\r\n```\r\n\r\nEvery quest method requires an authenticated session. Without one they return\r\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\r\n`client` per player; don't share it across sessions.\r\n\r\n## Methods\r\n\r\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\r\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\r\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\r\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\r\ninside the throttle window), `\"connection\"` (transient, offer Retry),\r\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\r\n`error` carries the human-readable reason, e.g. \"Quest is not completed\",\r\n\"Already claimed\", \"Prerequisite quest not completed\").\r\n\r\n| Method | Purpose | `data` on success |\r\n| -------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- |\r\n| `getQuestDefinitions()` | Load the title's quest/cycle catalog (config). | `QuestDefinitions` |\r\n| `getUserQuestState(autoRefreshCycles?)` | Load this player's quest progress (state). Defaults to auto-refresh. | `GetUserQuestStateResponse` (`State`, `PointsTracks`) |\r\n| `refreshQuestCycles()` | Force-check cycle boundaries and roll any stale cycle forward. | `SuccessResponse` |\r\n| `addQuestProgress(metricID, progressValue)` | Report progress on a metric; fans out to every listening objective. | `AddQuestProgressResponse` (`Updates`) |\r\n| `claimQuestReward(questID, cycleID?)` | Claim a single completed quest's reward. | `ClaimQuestRewardResponse` (`NewStatus`, `Resources`) |\r\n| `claimQuestRewardsBatch(quests)` | Claim several quests' rewards in one atomic call. | `BatchItemResult<ClaimQuestRewardResponse>[]` |\r\n| `claimMilestoneReward(cycleID, milestoneID)` | Claim one points-track milestone reward for a cycle. | `ClaimMilestoneRewardResponse` (`PointsTotalEarned`, `Resources`) |\r\n| `claimMilestoneRewardsBatch(milestones)` | Claim several milestone rewards in one atomic call. | `BatchItemResult<ClaimMilestoneRewardResponse>[]` |\r\n| `claimGroupCompletionReward(cycleID, groupCompletionID)` | Claim a cycle's group-completion grand reward. | `ClaimGroupCompletionRewardResponse` (`CompletedGroupQuests`, `Resources`) |\r\n\r\n`claimQuestReward` / `claimMilestoneReward` / `claimGroupCompletionReward` all\r\naccept a blank/absent `CycleID` to mean a permanent quest (quest claim only —\r\nmilestones and group-completions always belong to a cycle). Each mints its own\r\n`RelatedEntityID` internally for idempotency; you don't supply one.\r\n\r\n`claimQuestRewardsBatch(quests)` takes `QuestClaimRef[]` (`{ CycleID?,\r\nQuestID? }`, deduped by `CycleID`+`QuestID`); `claimMilestoneRewardsBatch(milestones)`\r\ntakes `MilestoneClaimRef[]` (`{ CycleID?, MilestoneID? }`, deduped by\r\n`CycleID`+`MilestoneID`).\r\n\r\nOn success, each method also **mirrors the confirmed change into the cache and\r\nemits an event** — you don't apply anything by hand. Granted resources\r\n(currencies, items) ride along in `data.Resources` (a `ResourceOperation`, see\r\n[ResourceModels](../../../packages/core/src/models/_shared/ResourceModels.ts))\r\nand are already applied to the cached balances, so read updated balances\r\nstraight from the cache.\r\n\r\n## Reading state and reacting to changes\r\n\r\nDrive the UI off the cache, not off one-off return values — that way every\r\nscreen stays consistent no matter which code path changed things.\r\n\r\n```ts\r\n// Quest progress (only present after getUserQuestState()):\r\nconst cycleA = client.data.user.state?.Quest?.Cycles?.[\"cycleA\"];\r\ncycleA?.Quests?.[\"q1\"]?.Status; // \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\"\r\ncycleA?.Quests?.[\"q1\"]?.Objectives?.[\"obj1\"]?.CurrentValue;\r\ncycleA?.ClaimedGroupCompletionIDs; // string[]\r\n\r\nconst permanentQuest =\r\n client.data.user.state?.Quest?.PermanentQuests?.[\"intro\"];\r\n\r\n// Points track (balance + claimed milestone ids), keyed by cycleID (or\r\n// \"cycleID:instanceKey\" for recurring cycles) — read with the helper so you\r\n// don't have to know the exact composite key:\r\nconst points = client.data.user.getQuestPointsProgress(\"cycleA\");\r\npoints?.Balance?.Current; // current points balance this cycle\r\npoints?.Balance?.TotalEarned;\r\npoints?.Milestone?.ClaimedIDs; // milestone ids already claimed\r\n\r\n// Definitions (cached after getQuestDefinitions()):\r\nimport type { QuestDefinitions } from \"@idosgames/core\";\r\nconst defs = client.data.config.getSection<QuestDefinitions>(\"Quest\");\r\n```\r\n\r\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\r\n\r\n- `quest:definitionsLoaded` → `QuestDefinitions`\r\n- `quest:userStateLoaded` → `UserQuestState`\r\n- `quest:cyclesRefreshed` → `void`\r\n- `quest:progressAdded` → `AddQuestProgressResponse`\r\n- `quest:rewardClaimed` → `ClaimQuestRewardResponse`\r\n- `quest:rewardsClaimedBatch` → `ClaimQuestRewardsBatchResponse`\r\n- `quest:milestoneClaimed` → `ClaimMilestoneRewardResponse`\r\n- `quest:milestonesClaimedBatch` → `ClaimMilestoneRewardsBatchResponse`\r\n- `quest:groupCompletionClaimed` → `ClaimGroupCompletionRewardResponse`\r\n\r\nThe coarse `user:questUpdated` (and `user:anyUpdated`) also fire on any quest\r\ncache write — handy for a \"re-render everything\" hook.\r\n\r\n```ts\r\nconst off = client.on(\"quest:progressAdded\", (r) => {\r\n for (const u of r.Updates ?? []) {\r\n console.log(`${u.QuestID} objective ${u.ObjectiveID} -> ${u.NewValue}`);\r\n }\r\n});\r\n// later: off();\r\n```\r\n\r\n## Recipes\r\n\r\n### Load the board and render quest cards\r\n\r\n```ts\r\nawait client.quest.getQuestDefinitions();\r\nawait client.quest.getUserQuestState();\r\n\r\nconst defs = client.data.config.getSection<QuestDefinitions>(\"Quest\");\r\nconst cycles = client.data.user.state?.Quest?.Cycles ?? {};\r\n\r\nfor (const [cycleID, cycleDef] of Object.entries(defs?.Cycles ?? {})) {\r\n const userCycle = cycles[cycleID];\r\n for (const [questID, questDef] of Object.entries(defs?.Quests ?? {})) {\r\n if (!questDef.CycleIDs?.includes(cycleID)) continue;\r\n const progress = userCycle?.Quests?.[questID];\r\n // progress?.Status drives the card state: not-started/Active/Completed/Claimed.\r\n // questDef.Objectives + progress?.Objectives drives the progress bar(s).\r\n }\r\n}\r\n```\r\n\r\nA quest's `CycleIDs` lists every cycle it can appear in; cross-reference\r\nagainst `defs.Cycles` to know which are currently relevant. A quest absent from\r\n`userCycle.Quests` simply hasn't accrued any progress yet — treat it as\r\n`\"Active\"` with zero progress, not as an error. The backend creates a quest's\r\nprogress record (and each objective's) lazily, the first time it accrues\r\nsomething — it never pre-populates the catalog with zeros.\r\n\r\n### Report progress, then claim\r\n\r\n```ts\r\n// Wherever the underlying game action happens:\r\nconst prog = await client.quest.addQuestProgress(\"EnemiesDefeated\", 1);\r\nif (!prog.ok) return showError(prog.error);\r\n\r\nfor (const u of prog.data.Updates ?? []) {\r\n if (u.Status === \"Completed\") {\r\n // Surface a \"claim\" button for u.QuestID / u.CycleID now.\r\n }\r\n}\r\n```\r\n\r\n```ts\r\n// Later, when the player taps Claim:\r\nconst claim = await client.quest.claimQuestReward(\"q1\", \"cycleA\");\r\nif (!claim.ok) return showError(claim.error); // e.g. \"Quest is not completed\", \"Already claimed\"\r\n// cache now shows q1 as \"Claimed\"; balances already credited.\r\n```\r\n\r\nClaiming before every objective is met, or claiming twice, both fail with\r\n`reason: \"server\"` — the quest must be `\"Completed\"` and not already\r\n`\"Claimed\"`. There's no client-side shortcut to check this ahead of time beyond\r\nreading the cached `Status` you already have.\r\n\r\nOnly objectives configured with `Source: \"ClientApi\"` can be advanced this way;\r\nan unrecognized `MetricID` fails with `\"MetricID not allowed for ClientApi\"`.\r\nNever send an inflated `ProgressValue` \"to be safe\" — if a matching objective\r\ndeclares `MaxProgressPerCall`, the backend compares your raw value against it\r\nand **bans the account** on a violation (`\"User banned: Value exceeds\r\nMaxValuePerCall\"`); it does not just clamp and continue.\r\n\r\n### Objectives you must NOT report progress for\r\n\r\nObjectives with `Source: \"SystemEvent\"` are advanced by the backend itself from\r\ntheir `Triggers` list — board rolls, store purchases, marketplace settlements,\r\nclaiming another quest. There is no call to make: `addQuestProgress` rejects\r\nthem, and adding a client-side counter for them double-counts nothing but wastes\r\na request.\r\n\r\nWhen such an objective moves, the progress rides back on the envelope of\r\nwhatever call caused it (a roll, a purchase, a claim) as\r\n`QuestProgress: QuestProgressUpdate[]`. The client applies it to the cached user\r\nstate automatically, so quest UI just needs to re-read the cache — do not poll\r\n`getUserQuestState` for it.\r\n\r\n`Source: \"ServerApi\"` objectives are moved only by a CloudCode script calling\r\n`server.AddQuestProgress(metricID, value)`. Same rule: nothing for the game to\r\ncall.\r\n\r\n### Claim a milestone once the points track crosses a rung\r\n\r\n```ts\r\nconst points = client.data.user.getQuestPointsProgress(\"cycleA\");\r\nconst claimedAlready = points?.Milestone?.ClaimedIDs?.includes(\"m1\") ?? false;\r\n\r\nif (\r\n !claimedAlready &&\r\n (points?.Balance?.Current ?? 0) >= /* milestone.RequiredProgress */ 100\r\n) {\r\n const res = await client.quest.claimMilestoneReward(\"cycleA\", \"m1\");\r\n if (!res.ok) return showError(res.error);\r\n res.data.PointsTotalEarned; // lifetime points earned this cycle, for display\r\n}\r\n```\r\n\r\nMilestone eligibility is judged against the points token's **lifetime total**\r\n(`Balance.TotalEarned`, mirrored into `PointsCurrent`/`PointsTotalEarned` on\r\n`QuestPointsTrackView` — for this token they're always equal, since points are\r\nonly ever granted, never spent). Points land in that balance when a quest with\r\n`PointsReward > 0` is **claimed** (`claimQuestReward`/batch) — completing a\r\nquest alone does not add points, claiming it does, in the same atomic\r\ntransaction as the quest's own reward. So a player reaches milestone `m1` by\r\nclaiming enough individual quest rewards across the cycle — milestone claiming\r\nis independent of any _single_ quest's claim, but not of claiming in general.\r\n\r\n### Claim a group-completion grand reward\r\n\r\n```ts\r\nconst res = await client.quest.claimGroupCompletionReward(\r\n \"cycleA\",\r\n \"dailyGroupBonus\",\r\n);\r\nif (!res.ok) return showError(res.error); // e.g. \"not enough quests completed in group\"\r\nres.data.CompletedGroupQuests; // e.g. 3\r\nres.data.RequiredGroupQuests; // e.g. 3\r\n```\r\n\r\nEligibility counts quests in the group that reached `\"Completed\"` **or**\r\n`\"Claimed\"` — you don't need to claim every quest's own reward first, just\r\nfinish them. The required count is `RequiredCompletedQuests` if set, otherwise\r\n**every** quest currently in that group/cycle (0 means \"all\"). This is\r\nrecomputed live against the current catalog at claim time (not a snapshot from\r\nwhenever the player finished the quests), so a group whose quest list changed\r\nafter the player completed them can shift the totals. Once claimed, the id is\r\nrecorded in `cycle.ClaimedGroupCompletionIDs` — check that list to hide an\r\nalready-claimed banner.\r\n\r\n### Batch claim several quests/milestones at once\r\n\r\n```ts\r\nconst res = await client.quest.claimQuestRewardsBatch([\r\n { CycleID: \"cycleA\", QuestID: \"q1\" },\r\n { CycleID: \"cycleA\", QuestID: \"q2\" },\r\n { QuestID: \"intro\" }, // permanent quest: CycleID omitted\r\n]);\r\nif (!res.ok) return showError(res.error);\r\nfor (const item of res.data) {\r\n if (item.Success) applyOk(item.Id);\r\n else showItemError(item.Id, item.Error); // this one was rejected\r\n}\r\n```\r\n\r\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\r\neach element's `Success`/`Error` tells you whether that item applied — one\r\nalready-claimed quest in the batch doesn't sink the others. `claimMilestoneRewardsBatch`\r\nworks the same way with `MilestoneClaimRef[]`.\r\n\r\n### Force a cycle refresh (e.g. on app resume)\r\n\r\n```ts\r\nconst res = await client.quest.refreshQuestCycles();\r\nif (res.ok) {\r\n await client.quest.getUserQuestState(); // reload to pick up the new cycle instance\r\n}\r\n```\r\n\r\n`getUserQuestState()` already auto-refreshes cycles by default\r\n(`autoRefreshCycles: true`), so most apps never need to call this directly —\r\nreach for it when you want to roll cycles forward (e.g. after detecting a\r\nday/week boundary while the app was backgrounded) without waiting on a full\r\nstate reload, or want the two steps as separate UI beats (spinner → \"New\r\nquests!\" toast).\r\n\r\n## Gotchas\r\n\r\n- **Progress is reported by metric, not by quest.** `addQuestProgress` doesn't\r\n target a quest id — it fans one `MetricID` update out to every objective\r\n across every active quest (and cycle) that listens to it. Call it once per\r\n underlying game action, not once per quest you think might care.\r\n- **Claiming has three independent tracks.** A quest's own `Reward`, its\r\n cycle's points-track `Milestones`, and its group's `GroupCompletions` are\r\n claimed through three different methods and three different cache locations\r\n (`Quest.Cycles[...].Quests`, `EventToken.Quest`, `Quest.Cycles[...]\r\n.ClaimedGroupCompletionIDs`). Completing a quest can make all three\r\n claimable at once — don't assume claiming one auto-claims the others.\r\n- **Milestone/points state lives in the event-token cache, not `Quest`.**\r\n `client.data.user.state?.Quest` holds quest/objective progress; the points\r\n balance and claimed-milestone ids live at\r\n `client.data.user.state?.EventToken?.Quest`, keyed by `cycleID` or\r\n `\"cycleID:instanceKey\"` for recurring cycles. Use the\r\n `client.data.user.getQuestPointsProgress(cycleID)` helper instead of\r\n indexing the bucket yourself — it normalizes the composite key for you.\r\n- **Guard against double-submit.** Each call mints a fresh idempotency key\r\n (`RelatedEntityID`), so two separate calls are two real operations — a\r\n double-clicked \"Claim\" can attempt to claim twice (the second simply fails\r\n as already-claimed, but don't rely on that for UX). Disable the control\r\n while a call is in flight. Firing the same endpoint again within the\r\n throttle window (default 600 ms) is rejected with `reason: \"throttled\"`\r\n rather than duplicated.\r\n- **`RequiredQuestIDs` can gate progress, not just claiming.** A quest's\r\n `PrerequisiteMode` decides whether unmet prerequisites block progress from\r\n accruing at all (`BlockProgressAndClaim`) or only block the final claim\r\n (`BlockClaimOnly`) — check which mode a quest uses before assuming progress\r\n bars will move.\r\n- **Batch charges/prereqs are evaluated per item, independently.** Unlike some\r\n other modules' batch upgrades, quest/milestone batch claims aren't chained —\r\n each item is judged against state at the start of the call, so claiming\r\n `q1` and `q2` in the same batch where `q2` requires `q1` completed (not\r\n claimed) still works, but don't expect claim-order effects within one batch\r\n call.\r\n- **Cycles roll forward wholesale, not incrementally.** When a cycle's schedule\r\n window rotates (e.g. midnight UTC for a daily), the server replaces that\r\n cycle's entire `Quests` map and `ClaimedGroupCompletionIDs` with a fresh,\r\n empty state — there is no partial carry-over of yesterday's progress. Always\r\n call `getUserQuestState()` (or `refreshQuestCycles()` + a reload) after\r\n detecting a boundary rather than trusting a stale cached cycle.\r\n- **Cache patches for an unknown cycle silently no-op.** `claimQuestReward` and\r\n `claimGroupCompletionReward` only patch the local cache if that `CycleID`\r\n already exists in `client.data.user.state.Quest.Cycles` — if you call them\r\n for a cycle the client hasn't loaded yet (e.g. right after a cold start with\r\n a stale cache), the call still succeeds server-side but the UI won't reflect\r\n it until you `getUserQuestState()` again. Load state before wiring up claim\r\n buttons.\r\n- **Render from the cache, handle the error from the result.** The happy path\r\n updates the cache + emits an event; the failure path gives you `reason` +\r\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\r\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\r\n\r\n## Full reference\r\n\r\n[references/data-model.md](references/data-model.md) — every config and state\r\nfield, the objective/prerequisite/schedule/limit/gate blocks, and how the\r\npoints-track and milestone plumbing ties into the shared event-token cache.\r\nRead it when building config-driven UI (objective progress bars, milestone\r\nladders, cycle countdowns) or when an error message points at a config rule you\r\nneed to understand.\r\n",
3
+ "description": "Build a quest / daily-task system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.quest (QuestService): load quest and cycle definitions, load the player's quest progress state, add progress toward a metric, claim a completed quest's reward, claim a points-track milestone reward, claim a group-completion (grand) reward, and refresh cycles (dailies/ weeklies) forward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest screens, task lists, objective/progress trackers, battle-pass-style points tracks, milestone reward ladders, quest-group completion bonuses, or otherwise touches client.quest, QuestService, QuestDefinitions, UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they don't name the module explicitly.",
4
+ "content": "---\nname: quest-system\ndescription: >-\n Build a quest / daily-task system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.quest (QuestService): load quest and cycle\n definitions, load the player's quest progress state, add progress toward a\n metric, claim a completed quest's reward, claim a points-track milestone\n reward, claim a group-completion (grand) reward, and refresh cycles (dailies/\n weeklies) forward. Use this whenever the user is working in the iDosGames TS\n SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest\n screens, task lists, objective/progress trackers, battle-pass-style points\n tracks, milestone reward ladders, quest-group completion bonuses, or\n otherwise touches client.quest, QuestService, QuestDefinitions,\n UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they\n don't name the module explicitly.\n---\n\n# Quest system (iDosGames TS SDK)\n\nThe Quest module runs a title's task/quest board: dailies, weeklies, permanent\nquests, and one-off event quests, each made of objectives that accrue progress\ntoward a metric. Everything is **server-authoritative**: the backend tracks\nprogress, decides when a quest is `Completed`, and validates every claim. The\nclient asks the backend to report progress or claim a reward, and the SDK\nmirrors the confirmed result into a local cache your UI reads. You never\ncompute quest status yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `QuestService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(objective not met, already claimed, prerequisite quest incomplete) — surface\nthe error, don't try to reproduce the check client-side.\n\n## The two 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 quest cycles (dailies/weeklies/permanent), the quests inside each cycle,\n their objectives/rewards/prerequisites, and the cycle's milestone points\n track and group-completion grand rewards. Fetched with\n `getQuestDefinitions()`.\n2. **User quest state** (state, per player) — this player's live progress:\n which cycle instances are active, each quest's `Status` and per-objective\n `CurrentValue`, and the points-track balance/claimed-milestone ids for each\n cycle. Fetched with `getUserQuestState()`.\n\nA `QuestDefinition` carries **only `QuestID` at its root**; everything else is\nsplit into named blocks — `Identity` (name/description/icon), `Linking`\n(cycles, group label, prerequisites), `Availability` (window, gate, limits),\n`Objectives`, `Reward`. Same layout as `CharacterDefinition`, so read\n`questDef.Identity?.DisplayName`, not `questDef.DisplayName`.\n\nA quest lives either **inside a cycle** (`Linking.CycleIDs` non-empty —\ndailies, weeklies, seasonal) or as a **permanent quest** (empty `CycleIDs` — a\none-time or always-available quest, e.g. onboarding). Most methods take an\noptional/blank `CycleID` to address either; the cache keeps them in separate\nbuckets (`Quest.Cycles[cycleID]` vs `Quest.PermanentQuests`).\n\nBlocks can be **authored** through presets (`QuestDefinitions.Presets`, one\nbinding per block — every block except `Identity`, which is always written inline) so a 42-quest event isn't 42 copies of the same settings —\nbut the backend resolves that when it materializes the title config. What\n`getQuestDefinitions()` hands you is already assembled; a client never merges\nanything.\n\nThree distinct reward mechanisms — don't conflate them:\n\n- **Quest reward** — `Reward.Grant` on one `QuestDefinition`, claimed once that\n quest's objectives are all met (`Status: \"Completed\"`), via\n `claimQuestReward`. Moves the quest to `\"Claimed\"`.\n- **Chain phase** — a cycle whose `Schedule.Mode` is `\"Chained\"` plays its `Phases` one after\n another and then repeats. Each phase is a separate window with its **own** points track and its\n **own** claimed milestones, so a \"season\" of eight weeks is one cycle, not eight. Quests bind to\n phases with `Linking.PhaseIDs`. The live phase arrives in `PointsTracks[cycleID].PhaseID`.\n- **Milestone reward** — a rung on a cycle's **points track** (backend/config\n comments call this \"Achievements\"): claiming a quest with\n `Reward.PointsReward > 0` also grants that many points into a per-cycle point balance — a dedicated\n `EventTokenType.Quest` token, tracked separately from any single quest's own\n claim status — in the same atomic transaction as the quest claim. Each\n `MilestoneDefinition` in `Cycle.Milestones` pays out once that balance's\n lifetime total crosses its `RequiredProgress`. Claimed via\n `claimMilestoneReward`. This is the battle-pass-style ladder — a player can\n hit a milestone from points earned across many different quest claims, and\n milestone eligibility never re-checks any individual quest's status.\n- **Group-completion reward** — a grand bonus in `Cycle.GroupCompletions` that\n pays out once at least `RequiredCompletedQuests` quests sharing a\n `Linking.GroupID` have reached `\"Completed\"` (not necessarily claimed).\n Claimed via `claimGroupCompletionReward`. A \"group\" is nothing but that\n string label — there is no group entity to look up.\n\nAll three can be in flight simultaneously for the same cycle — completing one\nquest can push its points into the milestone track, count toward its group's\ncompletion total, _and_ be individually claimable, all at once.\n\n**Progress** is reported with `addQuestProgress(metricID, progressValue)` — a\ngeneric counter keyed by `MetricID`, not by quest id. The backend fans one\nmetric update out to every objective across every active quest that listens to\nthat `MetricID` (per each objective's own `AggregationMethod`/filters), and\nreturns the list of quests/objectives that changed. You call this from your\ngame-loop code wherever the underlying action happens (e.g. \"enemy defeated\" →\n`addQuestProgress(\"EnemiesDefeated\", 1)`), not once per quest.\n\n**Cycles** (dailies/weeklies) roll forward on a schedule. `getUserQuestState`\ndefaults to auto-refreshing stale cycles for you (`autoRefreshCycles = true`);\ncall `refreshQuestCycles()` directly when you want to force-check for a new\ncycle boundary (e.g. app resumed from background) without re-fetching the\nwhole state.\n\nFor the full field-by-field shape of Definitions and state (objective sources,\nprerequisite modes, schedule/limit/gate blocks, the points-track/milestone\nplumbing), read [references/data-model.md](references/data-model.md). You do\n**not** need 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 quest = client.quest; // the QuestService\n```\n\nEvery quest method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Quest is not completed\",\n\"Already claimed\", \"Prerequisite quest not completed\").\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- |\n| `getQuestDefinitions()` | Load the title's quest/cycle catalog (config). | `QuestDefinitions` |\n| `getUserQuestState(autoRefreshCycles?)` | Load this player's quest progress (state). Defaults to auto-refresh. | `GetUserQuestStateResponse` (`State`, `PointsTracks`) |\n| `refreshQuestCycles()` | Force-check cycle boundaries and roll any stale cycle forward. | `SuccessResponse` |\n| `addQuestProgress(metricID, progressValue)` | Report progress on a metric; fans out to every listening objective. | `AddQuestProgressResponse` (`Updates`) |\n| `claimQuestReward(questID, cycleID?)` | Claim a single completed quest's reward. | `ClaimQuestRewardResponse` (`NewStatus`, `Resources`) |\n| `claimQuestRewardsBatch(quests)` | Claim several quests' rewards in one atomic call. | `BatchItemResult<ClaimQuestRewardResponse>[]` |\n| `claimMilestoneReward(cycleID, milestoneID)` | Claim one points-track milestone reward for a cycle. | `ClaimMilestoneRewardResponse` (`PointsTotalEarned`, `Resources`) |\n| `claimMilestoneRewardsBatch(milestones)` | Claim several milestone rewards in one atomic call. | `BatchItemResult<ClaimMilestoneRewardResponse>[]` |\n| `claimGroupCompletionReward(cycleID, groupCompletionID)` | Claim a cycle's group-completion grand reward. | `ClaimGroupCompletionRewardResponse` (`CompletedGroupQuests`, `Resources`) |\n\n`claimQuestReward` / `claimMilestoneReward` / `claimGroupCompletionReward` all\naccept a blank/absent `CycleID` to mean a permanent quest (quest claim only —\nmilestones and group-completions always belong to a cycle). Each mints its own\n`RelatedEntityID` internally for idempotency; you don't supply one.\n\n`claimQuestRewardsBatch(quests)` takes `QuestClaimRef[]` (`{ CycleID?,\nQuestID? }`, deduped by `CycleID`+`QuestID`); `claimMilestoneRewardsBatch(milestones)`\ntakes `MilestoneClaimRef[]` (`{ CycleID?, MilestoneID? }`, deduped by\n`CycleID`+`MilestoneID`).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Granted resources\n(currencies, items) ride along in `data.Resources` (a `ResourceOperation`, see\n[ResourceModels](../../../packages/core/src/models/_shared/ResourceModels.ts))\nand are already applied to the cached balances, so read updated balances\nstraight from the cache.\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// Quest progress (only present after getUserQuestState()):\nconst cycleA = client.data.user.state?.Quest?.Cycles?.[\"cycleA\"];\ncycleA?.Quests?.[\"q1\"]?.Status; // \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\"\ncycleA?.Quests?.[\"q1\"]?.Objectives?.[\"obj1\"]?.CurrentValue;\ncycleA?.ClaimedGroupCompletionIDs; // string[]\n\nconst permanentQuest =\n client.data.user.state?.Quest?.PermanentQuests?.[\"intro\"];\n\n// Points track (balance + claimed milestone ids), keyed by cycleID (or\n// \"cycleID:instanceKey\" for recurring cycles) — read with the helper so you\n// don't have to know the exact composite key:\nconst points = client.data.user.getQuestPointsProgress(\"cycleA\");\npoints?.Balance?.Current; // current points balance this cycle\npoints?.Balance?.TotalEarned;\npoints?.Milestone?.ClaimedIDs; // milestone ids already claimed\n\n// Definitions (cached after getQuestDefinitions()):\nimport type { QuestDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<QuestDefinitions>(\"Quest\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `quest:definitionsLoaded` → `QuestDefinitions`\n- `quest:userStateLoaded` → `UserQuestState`\n- `quest:cyclesRefreshed` → `void`\n- `quest:progressAdded` → `AddQuestProgressResponse`\n- `quest:rewardClaimed` → `ClaimQuestRewardResponse`\n- `quest:rewardsClaimedBatch` → `ClaimQuestRewardsBatchResponse`\n- `quest:milestoneClaimed` → `ClaimMilestoneRewardResponse`\n- `quest:milestonesClaimedBatch` → `ClaimMilestoneRewardsBatchResponse`\n- `quest:groupCompletionClaimed` → `ClaimGroupCompletionRewardResponse`\n\nThe coarse `user:questUpdated` (and `user:anyUpdated`) also fire on any quest\ncache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"quest:progressAdded\", (r) => {\n for (const u of r.Updates ?? []) {\n console.log(`${u.QuestID} objective ${u.ObjectiveID} -> ${u.NewValue}`);\n }\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the board and render quest cards\n\n```ts\nawait client.quest.getQuestDefinitions();\nawait client.quest.getUserQuestState();\n\nconst defs = client.data.config.getSection<QuestDefinitions>(\"Quest\");\nconst cycles = client.data.user.state?.Quest?.Cycles ?? {};\n\nfor (const [cycleID, cycleDef] of Object.entries(defs?.Cycles ?? {})) {\n const userCycle = cycles[cycleID];\n for (const [questID, questDef] of Object.entries(defs?.Quests ?? {})) {\n if (!questDef.Linking?.CycleIDs?.includes(cycleID)) continue;\n const progress = userCycle?.Quests?.[questID];\n // progress?.Status drives the card state: not-started/Active/Completed/Claimed.\n // questDef.Objectives + progress?.Objectives drives the progress bar(s).\n }\n}\n```\n\nA quest's `Linking.CycleIDs` lists every cycle it can appear in;\ncross-reference against `defs.Cycles` to know which are currently relevant. A quest absent from\n`userCycle.Quests` simply hasn't accrued any progress yet — treat it as\n`\"Active\"` with zero progress, not as an error. The backend creates a quest's\nprogress record (and each objective's) lazily, the first time it accrues\nsomething — it never pre-populates the catalog with zeros.\n\n### Report progress, then claim\n\n```ts\n// Wherever the underlying game action happens:\nconst prog = await client.quest.addQuestProgress(\"EnemiesDefeated\", 1);\nif (!prog.ok) return showError(prog.error);\n\nfor (const u of prog.data.Updates ?? []) {\n if (u.Status === \"Completed\") {\n // Surface a \"claim\" button for u.QuestID / u.CycleID now.\n }\n}\n```\n\n```ts\n// Later, when the player taps Claim:\nconst claim = await client.quest.claimQuestReward(\"q1\", \"cycleA\");\nif (!claim.ok) return showError(claim.error); // e.g. \"Quest is not completed\", \"Already claimed\"\n// cache now shows q1 as \"Claimed\"; balances already credited.\n```\n\nClaiming before every objective is met, or claiming twice, both fail with\n`reason: \"server\"` — the quest must be `\"Completed\"` and not already\n`\"Claimed\"`. There's no client-side shortcut to check this ahead of time beyond\nreading the cached `Status` you already have.\n\nOnly objectives configured with `Source: \"ClientApi\"` can be advanced this way;\nan unrecognized `MetricID` fails with `\"MetricID not allowed for ClientApi\"`.\nNever send an inflated `ProgressValue` \"to be safe\" — if a matching objective\ndeclares `MaxProgressPerCall`, the backend compares your raw value against it\nand **bans the account** on a violation (`\"User banned: Value exceeds\nMaxValuePerCall\"`); it does not just clamp and continue.\n\n### Objectives you must NOT report progress for\n\nObjectives with `Source: \"SystemEvent\"` are advanced by the backend itself from\ntheir `Triggers` list — board rolls, store purchases, marketplace settlements,\nclaiming another quest. There is no call to make: `addQuestProgress` rejects\nthem, and adding a client-side counter for them double-counts nothing but wastes\na request.\n\nWhen such an objective moves, the progress rides back on the envelope of\nwhatever call caused it (a roll, a purchase, a claim) as\n`QuestProgress: QuestProgressUpdate[]`. The client applies it to the cached user\nstate automatically, so quest UI just needs to re-read the cache — do not poll\n`getUserQuestState` for it.\n\n`Source: \"ServerApi\"` objectives are moved only by a CloudCode script calling\n`server.AddQuestProgress(metricID, value)`. Same rule: nothing for the game to\ncall.\n\n### Claim a milestone once the points track crosses a rung\n\n```ts\nconst points = client.data.user.getQuestPointsProgress(\"cycleA\");\nconst claimedAlready = points?.Milestone?.ClaimedIDs?.includes(\"m1\") ?? false;\n\nif (\n !claimedAlready &&\n (points?.Balance?.Current ?? 0) >= /* milestone.RequiredProgress */ 100\n) {\n const res = await client.quest.claimMilestoneReward(\"cycleA\", \"m1\");\n if (!res.ok) return showError(res.error);\n res.data.PointsTotalEarned; // lifetime points earned this cycle, for display\n}\n```\n\nMilestone eligibility is judged against the points token's **lifetime total**\n(`Balance.TotalEarned`, mirrored into `PointsCurrent`/`PointsTotalEarned` on\n`QuestPointsTrackView` — for this token they're always equal, since points are\nonly ever granted, never spent). Points land in that balance when a quest with\n`Reward.PointsReward > 0` is **claimed** (`claimQuestReward`/batch) — completing a\nquest alone does not add points, claiming it does, in the same atomic\ntransaction as the quest's own reward. So a player reaches milestone `m1` by\nclaiming enough individual quest rewards across the cycle — milestone claiming\nis independent of any _single_ quest's claim, but not of claiming in general.\n\n### Claim a group-completion grand reward\n\n```ts\nconst res = await client.quest.claimGroupCompletionReward(\n \"cycleA\",\n \"dailyGroupBonus\",\n);\nif (!res.ok) return showError(res.error); // e.g. \"not enough quests completed in group\"\nres.data.CompletedGroupQuests; // e.g. 3\nres.data.RequiredGroupQuests; // e.g. 3\n```\n\nEligibility counts quests in the group that reached `\"Completed\"` **or**\n`\"Claimed\"` — you don't need to claim every quest's own reward first, just\nfinish them. The required count is `RequiredCompletedQuests` if set, otherwise\n**every** quest currently in that group/cycle (0 means \"all\"). This is\nrecomputed live against the current catalog at claim time (not a snapshot from\nwhenever the player finished the quests), so a group whose quest list changed\nafter the player completed them can shift the totals. Once claimed, the id is\nrecorded in `cycle.ClaimedGroupCompletionIDs` — check that list to hide an\nalready-claimed banner.\n\n### Batch claim several quests/milestones at once\n\n```ts\nconst res = await client.quest.claimQuestRewardsBatch([\n { CycleID: \"cycleA\", QuestID: \"q1\" },\n { CycleID: \"cycleA\", QuestID: \"q2\" },\n { QuestID: \"intro\" }, // permanent quest: CycleID omitted\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id);\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\neach element's `Success`/`Error` tells you whether that item applied — one\nalready-claimed quest in the batch doesn't sink the others. `claimMilestoneRewardsBatch`\nworks the same way with `MilestoneClaimRef[]`.\n\n### Force a cycle refresh (e.g. on app resume)\n\n```ts\nconst res = await client.quest.refreshQuestCycles();\nif (res.ok) {\n await client.quest.getUserQuestState(); // reload to pick up the new cycle instance\n}\n```\n\n`getUserQuestState()` already auto-refreshes cycles by default\n(`autoRefreshCycles: true`), so most apps never need to call this directly —\nreach for it when you want to roll cycles forward (e.g. after detecting a\nday/week boundary while the app was backgrounded) without waiting on a full\nstate reload, or want the two steps as separate UI beats (spinner → \"New\nquests!\" toast).\n\n## Gotchas\n\n- **Config fields live in blocks, not on the quest root.** `QuestDefinition` has\n only `QuestID` at the top level; the name is `Identity.DisplayName`, the\n cycles are `Linking.CycleIDs`, the window is `Availability.Schedule`, the\n payout is `Reward.Grant`, the points are `Reward.PointsReward`. Reading\n `questDef.DisplayName` compiles (the schemas keep `.passthrough()`) and\n silently yields `undefined`. Player **state** is unaffected — `UserQuestState`\n was never blocked.\n- **Progress is reported by metric, not by quest.** `addQuestProgress` doesn't\n target a quest id — it fans one `MetricID` update out to every objective\n across every active quest (and cycle) that listens to it. Call it once per\n underlying game action, not once per quest you think might care.\n- **Claiming has three independent tracks.** A quest's own `Reward`, its\n cycle's points-track `Milestones`, and its group's `GroupCompletions` are\n claimed through three different methods and three different cache locations\n (`Quest.Cycles[...].Quests`, `EventToken.Quest`, `Quest.Cycles[...]\n.ClaimedGroupCompletionIDs`). Completing a quest can make all three\n claimable at once — don't assume claiming one auto-claims the others.\n- **Milestone/points state lives in the event-token cache, not `Quest`.**\n `client.data.user.state?.Quest` holds quest/objective progress; the points\n balance and claimed-milestone ids live at\n `client.data.user.state?.EventToken?.Quest`, keyed by `cycleID` or\n `\"cycleID:instanceKey\"` for recurring cycles. Use the\n `client.data.user.getQuestPointsProgress(cycleID)` helper instead of\n indexing the bucket yourself — it normalizes the composite key for you.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Claim\" can attempt to claim twice (the second simply fails\n as already-claimed, but don't rely on that for UX). Disable the control\n while a call is in flight. Firing the same endpoint again within the\n throttle window (default 600 ms) is rejected with `reason: \"throttled\"`\n rather than duplicated.\n- **`Linking.RequiredQuestIDs` can gate progress, not just claiming.** A quest's\n `Linking.PrerequisiteMode` decides whether unmet prerequisites block progress from\n accruing at all (`BlockProgressAndClaim`) or only block the final claim\n (`BlockClaimOnly`) — check which mode a quest uses before assuming progress\n bars will move.\n- **Batch charges/prereqs are evaluated per item, independently.** Unlike some\n other modules' batch upgrades, quest/milestone batch claims aren't chained —\n each item is judged against state at the start of the call, so claiming\n `q1` and `q2` in the same batch where `q2` requires `q1` completed (not\n claimed) still works, but don't expect claim-order effects within one batch\n call.\n- **Cycles roll forward wholesale, not incrementally.** When a cycle's schedule\n window rotates (e.g. midnight UTC for a daily), the server replaces that\n cycle's entire `Quests` map and `ClaimedGroupCompletionIDs` with a fresh,\n empty state — there is no partial carry-over of yesterday's progress. Always\n call `getUserQuestState()` (or `refreshQuestCycles()` + a reload) after\n detecting a boundary rather than trusting a stale cached cycle.\n- **Cache patches for an unknown cycle silently no-op.** `claimQuestReward` and\n `claimGroupCompletionReward` only patch the local cache if that `CycleID`\n already exists in `client.data.user.state.Quest.Cycles` — if you call them\n for a cycle the client hasn't loaded yet (e.g. right after a cold start with\n a stale cache), the call still succeeds server-side but the UI won't reflect\n it until you `getUserQuestState()` again. Load state before wiring up claim\n buttons.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the objective/prerequisite/schedule/limit/gate blocks, and how the\npoints-track and milestone plumbing ties into the shared event-token cache.\nRead it when building config-driven UI (objective progress bars, milestone\nladders, cycle countdowns) or when an error message points at a config rule you\nneed to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Quest data model — reference\r\n\r\nFull shape of the config (Definitions) and player state, the cycle/schedule\r\nresolution rules, the points-track (\"Achievements\") plumbing, group-completion\r\nmath, and the server-side limits/idempotency rules. All of these are **strictly\r\ntyped in the SDK** — `QuestDefinitions` and every nested block (`QuestDefinition`,\r\n`QuestCycleDefinition`, `QuestObjectiveDefinition`, `QuestGroupCompletionDefinition`,\r\nthe shared `ScheduleSpec`/`SegmentGate`/`LimitSpec`/`MilestoneDefinition`/\r\n`EventTokenDefinition` blocks, …) are exported from `@idosgames/core`, so\r\n`getQuestDefinitions()` and `getSection<QuestDefinitions>(\"Quest\")` give you\r\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field the\r\nbackend adds later still round-trips. Field names are PascalCase (straight from\r\nthe backend JSON).\r\n\r\nBackend source of truth for everything below:\r\n`IDosGamesSDK/API/Client/v2/Quest/Quest.cs`,\r\n`IDosGamesSDK/API/Client/v2/Quest/Models/QuestDefinitions.cs`,\r\n`IDosGamesSDK/API/Client/v2/Quest/Models/UserQuestState.cs`,\r\n`IDosGamesSDK/API/Core/Scheduling/Services/ScheduleResolver.cs`,\r\n`IDosGamesSDK/API/Core/Event/Services/EventTokenService.cs`.\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — what `getUserQuestState()` returns\r\n- [Config: QuestDefinitions](#config-questdefinitions) — what `getQuestDefinitions()` returns\r\n- [QuestCycleDefinition](#questcycledefinition)\r\n- [QuestDefinition](#questdefinition)\r\n- [QuestObjectiveDefinition + progress aggregation](#questobjectivedefinition--progress-aggregation)\r\n- [Prerequisites (`RequiredQuestIDs`)](#prerequisites-requiredquestids)\r\n- [Cycle schedule resolution](#cycle-schedule-resolution)\r\n- [Per-quest schedule (\"staged unlock\" / Achievements)](#per-quest-schedule-staged-unlock--achievements)\r\n- [Points track (\"Achievements\") — the Quest event-token](#points-track-achievements--the-quest-event-token)\r\n- [Group-completion (grand reward) math](#group-completion-grand-reward-math)\r\n- [`AddQuestProgress` server-side rules](#addquestprogress-server-side-rules)\r\n- [Idempotency, atomicity, batch limits](#idempotency-atomicity-batch-limits)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nReturned by `getUserQuestState()` as `{ State, PointsTracks }` and cached at\r\n`client.data.user.state?.Quest` (progress) + `client.data.user.state?.EventToken?.Quest`\r\n(points track — see below). Hand-written interfaces (not `z.infer`) because the\r\ncache-patch methods mutate these objects in place.\r\n\r\n```ts\r\ninterface UserQuestState {\r\n Cycles?: Record<string, UserQuestCycleState>; // key = CycleID\r\n PermanentQuests?: Record<string, UserQuestProgress>; // key = QuestID\r\n LastUpdatedUtc?: string;\r\n}\r\n\r\ninterface UserQuestCycleState {\r\n CycleID?: string;\r\n CycleStartUtc?: string; // current window start, UTC\r\n CycleEndUtc?: string; // current window end, UTC\r\n Quests?: Record<string, UserQuestProgress>; // key = QuestID, THIS window only\r\n ClaimedGroupCompletionIDs?: string[]; // CompletionIDs already claimed this window\r\n}\r\n\r\ninterface UserQuestProgress {\r\n QuestID: string;\r\n Status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\r\n ActivatedAtUtc?: string | null;\r\n CompletedAtUtc?: string | null;\r\n ClaimedAtUtc?: string | null;\r\n Objectives?: Record<string, UserQuestObjectiveProgress>; // key = ObjectiveID\r\n}\r\n\r\ninterface UserQuestObjectiveProgress {\r\n ObjectiveID: string;\r\n CurrentValue: number;\r\n Completed: boolean;\r\n CompletedAtUtc?: string | null;\r\n}\r\n```\r\n\r\n**Lazy initialization** (`Quest.cs` `RefreshQuestCycles` / `CreateQuestProgressFromDefinition`):\r\na quest's `UserQuestProgress` (and each objective's `UserQuestObjectiveProgress`)\r\nis created only the first time progress is reported for it — the server does\r\n**not** pre-populate every configured quest/objective with zeros. A quest absent\r\nfrom `Cycles[cycleID].Quests` (or `PermanentQuests`) simply has zero progress on\r\nevery objective; render it as `\"Active\"`, not as an error or \"unknown\" state.\r\n`Expired` is only ever set for cyclic quests (never for permanent quests) and\r\nonly via the same lazy path — in practice you will see `Active` → `Completed` →\r\n`Claimed` for anything you've touched; a truly stale quest from a rolled-over\r\ncycle is simply absent (the whole cycle bucket gets replaced on rollover, see\r\nbelow), not flagged `Expired` in current code paths.\r\n\r\n---\r\n\r\n## Config: QuestDefinitions\r\n\r\nReturned by `getQuestDefinitions()`; cached via\r\n`client.data.config.getSection<QuestDefinitions>(\"Quest\")`.\r\n\r\n```ts\r\ninterface QuestDefinitions {\r\n Cycles?: Record<string, QuestCycleDefinition>; // key = CycleID\r\n Quests?: Record<string, QuestDefinition>; // key = QuestID\r\n}\r\n```\r\n\r\nA quest is **permanent** iff its `CycleIDs` is null/empty; otherwise it is\r\n**cyclic** and belongs to every cycle listed in `CycleIDs` (a quest can appear\r\nin more than one cycle definition, each with independent progress/claim state).\r\nCycle IDs and quest/objective IDs must all be Mongo-safe (no `.` or `$`) —\r\nthe server rejects unsafe keys outright (`\"Invalid CycleID (mongo-unsafe): …\"`,\r\n`\"QuestID is mongo-unsafe\"`, etc.); this only matters if you let players or\r\nremote config drive raw ids into these fields.\r\n\r\n---\r\n\r\n## QuestCycleDefinition\r\n\r\n`Quest.cs` / `QuestDefinitions.cs`. One recurring or one-off \"board\" (dailies,\r\nweeklies, a scheduled event window, …). Quests do **not** get listed here —\r\neach `QuestDefinition` points back at the cycle via `CycleIDs`.\r\n\r\n```ts\r\ninterface QuestCycleDefinition {\r\n CycleID?: string;\r\n DisplayName?: string;\r\n Schedule?: ScheduleSpec; // cycle window/reset — see below\r\n Milestones?: Record<string, MilestoneDefinition>; // points-track rungs, key = MilestoneID\r\n Phases?: Record<string, QuestPhaseDefinition>; // chain phases, only for Schedule.Mode = \"Chained\"\r\n Presets?: { Milestones?: PresetBinding }; // cycle-level preset wiring (Core/Presets)\r\n AssetPaths?: Record<string, string>;\r\n CustomParams?: Record<string, string>;\r\n Gate?: SegmentGate; // audience gate for the whole cycle; ANDed with each quest's own Gate\r\n PointsToken?: EventTokenDefinition; // global caps/burn for the points track (see below)\r\n GroupCompletions?: Record<string, QuestGroupCompletionDefinition>; // key = CompletionID\r\n}\r\n```\r\n\r\nBackend default when a cycle is authored without an explicit `Schedule`:\r\n`Mode: \"Cyclic\"`, `Cyclic: {}` (i.e. daily calendar reset) —\r\n`QuestCycleDefinition.Schedule` in `QuestDefinitions.cs`.\r\n\r\n---\r\n\r\n## QuestDefinition\r\n\r\n```ts\r\ninterface QuestDefinition {\r\n QuestID?: string;\r\n CycleIDs?: string[]; // null/empty => permanent; else cyclic, one entry per cycle it appears in\r\n DisplayName?: string;\r\n Description?: string;\r\n SortOrder?: number; // lower = earlier in UI\r\n\r\n RequiredQuestIDs?: string[]; // prerequisite QuestIDs — see below\r\n PrerequisiteMode?: \"BlockProgressAndClaim\" | \"BlockClaimOnly\"; // default BlockProgressAndClaim\r\n\r\n PointsReward?: number; // points into the cycle's points track on claim; ignored for permanent quests\r\n Schedule?: ScheduleSpec; // per-quest unlock window; null = inherit the cycle's window (see below)\r\n AccrueProgressWhenLocked?: boolean; // default false — see per-quest schedule section\r\n Gate?: SegmentGate; // ANDed with the cycle's Gate\r\n Limits?: LimitSpec; // per-quest per-source caps on POINTS grants only (not on objective progress)\r\n GroupID?: string; // for UI grouping + QuestGroupCompletionDefinition.GroupID matching\r\n\r\n PhaseIDs?: string[]; // chain phases this quest lives in; empty = all\r\n AssetPaths?: Record<string, string>; // task icon\r\n CustomParams?: Record<string, string>;\r\n Objectives?: Record<string, QuestObjectiveDefinition>; // key = ObjectiveID; ALL must complete\r\n Reward?: ResourceGrant; // claimed via claimQuestReward\r\n}\r\n```\r\n\r\n`PrerequisiteMode` (`QuestDefinition.cs` comment, verbatim intent):\r\n\r\n| Mode | Effect on `RequiredQuestIDs` |\r\n| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\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. |\r\n| `BlockClaimOnly` | Progress accrues immediately; only the final reward claim is blocked until prerequisites are met. |\r\n\r\nA prerequisite is looked up \"where its own progress lives\": permanent →\r\n`PermanentQuests`; cyclic → the same `cycleID` if the prerequisite also belongs\r\nto it, otherwise the prerequisite's own first `CycleIDs` entry\r\n(`ArePrerequisitesMet`, `Quest.cs`). Empty/null `RequiredQuestIDs` = no gating.\r\n\r\n---\r\n\r\n## Chains — a cycle that runs phases one after another\r\n\r\nSet the cycle's `Schedule.Mode` to `\"Chained\"` and fill `Phases`. The chain starts at\r\n`Schedule.Chain.AnchorUtc`, plays its phases in `Order`, then repeats (`MaxCycles`, pauses).\r\n\r\n```ts\r\ninterface QuestPhaseDefinition {\r\n PhaseID?: string; // unique within the chain; referenced by QuestDefinition.PhaseIDs\r\n Order?: number; // position within one full pass (0, 1, 2...)\r\n DurationSec?: number; // how long the phase stays open\r\n ClaimGraceHours?: number;// extra claim window after it ends\r\n DisplayName?: string;\r\n AssetPaths?: Record<string, string>;\r\n CustomParams?: Record<string, string>;\r\n Gate?: SegmentGate; // AND-ed with cycle gate and quest gate\r\n Milestones?: Record<string, MilestoneDefinition>; // null = the cycle's ladder is used\r\n Presets?: { Milestones?: PresetBinding };\r\n PointsToken?: EventTokenDefinition; // null = the cycle's token\r\n}\r\n```\r\n\r\nThree rules worth knowing before designing one:\r\n\r\n- **Every phase owns its progress.** The points-track address includes the instance key, and for\r\n a phase that key is `chain:{cycleIndex}:{phaseID}`. So week 2 starts from zero points with its\r\n own claimed-milestone list — it never inherits week 1. The cycle's quest state resets at a phase\r\n boundary exactly like it resets at midnight for a `Daily` cycle.\r\n- **Unset phase content falls back to the cycle.** Eight identical weeks are eight phases with\r\n only `Order`/`DurationSec` filled — not eight copies of the milestone ladder. The exception is an\r\n *empty* (not absent) `Milestones` object: that explicitly means \"no milestones in this phase\".\r\n- **A `Chained` cycle with no phases never activates.** There is nothing to resolve, so the whole\r\n cycle stays closed and its quests never progress.\r\n\r\nBind a quest to specific phases with `QuestDefinition.PhaseIDs` (empty = every phase). It gates\r\nboth progress and claiming — a week-2 quest cannot be claimed during week 1, in single and batch\r\nclaims alike. `PhaseIDs` is the direct way to say \"this quest belongs to week two\"; the quest's own\r\n`Schedule` with a `Relative` window stays for staged unlocking *within* one phase (\"Day N\").\r\n\r\n`GetUserQuestState` reports the live phase on each track: `PointsTracks[cycleID].PhaseID` and\r\n`.CycleIndex` (both absent/0 for a plain cycle) alongside `CycleStartUtc`/`CycleEndUtc` of that\r\nphase — enough to render \"Week 2 of 8\" and a countdown.\r\n\r\n### Milestone presets (Core/Presets)\r\n\r\n`QuestDefinitions.Presets.Milestones` is a registry of reusable `MilestoneSet`s keyed by PresetID.\r\nA cycle or a phase references one through `Presets.Milestones` (`PresetBinding`): the preset is the\r\nbase, the inline `Milestones` dictionary overrides or adds by MilestoneID, and `Remove` drops keys.\r\nNo PresetID ⇒ inline only. An unknown PresetID silently falls back to inline — it never wipes the\r\nentity's own ladder.\r\n\r\n---\r\n\r\n## QuestObjectiveDefinition + progress aggregation\r\n\r\n```ts\r\ninterface QuestObjectiveDefinition {\r\n ObjectiveID?: string;\r\n Source?: \"ClientApi\" | \"ServerApi\" | \"SystemEvent\"; // what advances it; default ClientApi\r\n MaxProgressPerCall?: number; // ClientApi BAN threshold (not a clamp); 0/absent = no check\r\n MetricID?: string; // ClientApi/ServerApi only: the metric key reported against\r\n Triggers?: TriggerSource[]; // SystemEvent only: in-game events that advance it\r\n TargetValue?: number; // default 1; required value to complete\r\n AggregationMethod?: string; // \"Sum\" | \"Maximum\" | \"Minimum\" | \"Last\"; default \"Sum\"\r\n}\r\n```\r\n\r\n`Source` selects **which field is read** — they are mutually exclusive:\r\n\r\n| Source | Advanced by | Field read |\r\n| ------------- | ----------------------------------------------- | ------------ |\r\n| `ClientApi` | the game calling `addQuestProgress(MetricID, v)` | `MetricID` |\r\n| `ServerApi` | a CloudCode script calling `server.AddQuestProgress(MetricID, v)` | `MetricID` |\r\n| `SystemEvent` | the backend itself, on in-game events | `Triggers` |\r\n\r\n`addQuestProgress` reaches **only** `ClientApi` objectives; a `MetricID` with no\r\nmatching `ClientApi` objective anywhere in the catalog is rejected outright\r\n(`\"MetricID not allowed for ClientApi\"`).\r\n\r\n### `Triggers` — SystemEvent objectives\r\n\r\n`Triggers` is the shared `TriggerSource` used by `EventContent.TokenSources`\r\n(TimedEvent) and `LeaderboardDefinition.ScoreSources`: an event type plus\r\nfilters, with `BaseWeight` as the progress step per fire. The list is OR-ed\r\n(first match wins). Empty/absent ⇒ the objective never advances.\r\n\r\nThe backend emits these event types into quests — anything else in\r\n`EventTokenSourceType` never reaches a quest (use `ClientApi` for\r\nclient-observed actions like watching an ad):\r\n\r\n`BoardTileLanding`, `BoardPassStart`, `BoardAttack`, `BoardRaid`, `BoardBuild`,\r\n`BoardStageComplete`, `BoardSpecialComplete` (GameLoop) · `StorePurchase`\r\n(`Store.Purchase` / `PurchaseBatch`, multiplier = purchase count) ·\r\n`MarketplaceSell` / `MarketplaceBuy` (settlement, **acting player only**) ·\r\n`QuestComplete` (`ClaimQuestReward`, for meta-quests).\r\n\r\n`Params` filters the matcher actually checks: `StorePurchase` → `OfferID`;\r\n`QuestComplete` → `QuestID`, `CycleID`; `Marketplace*` → `CatalogID`, `ItemID`,\r\n`OfferType`; `CustomAction` → `ActionName`. Any other key is stored but ignored.\r\n\r\nSet `ScaleWithRollMultiplier: false` on a quest trigger unless you *want* the\r\nboard's roll multiplier to inflate the step — otherwise \"win 3 raids\" closes on\r\na single x3 raid.\r\n\r\n`SystemEvent` progress is applied **after** the handler succeeds and returns on\r\nthat response's envelope as `QuestProgress: QuestProgressUpdate[]` (absent when\r\nnothing moved). The guarantee is at-most-once: the game action is already\r\ncommitted, so a failure here loses the event rather than rolling the action back.\r\n\r\nAggregation (`ApplyAggregation`, `Quest.cs`), given the objective's current\r\n`CurrentValue` and the incoming call value:\r\n\r\n| Method | New value |\r\n| ------------------------ | ---------------------------------------------------------------------------------------- |\r\n| `Sum` (default) | `current + incoming`, clamped to `long.MaxValue` on overflow; `incoming <= 0` is a no-op |\r\n| `Maximum` | `max(current, incoming)` |\r\n| `Minimum` | `incoming` if `current == 0`, else `min(current, incoming)` |\r\n| `Last` (or unrecognized) | `incoming` (last-write-wins) |\r\n\r\nAfter aggregation, if `TargetValue > 0` the new value is clamped to\r\n`TargetValue` (progress bars never overshoot 100%); if `TargetValue <= 0` there\r\nis no cap. An objective is marked `Completed` once\r\n`(TargetValue <= 0 && newValue > 0) || newValue >= TargetValue`. A quest becomes\r\n`\"Completed\"` once **every** objective the player has a progress record for is\r\n`Completed` **and** every objective in the definition has a progress record —\r\ni.e. an objective with zero recorded progress blocks completion (it's absent\r\nfrom the player's `Objectives` map, so the `All(...)` check in\r\n`EnsureQuestObjectivesAndCompletion` fails for it).\r\n\r\n`MaxProgressPerCall` guards two different things depending on\r\n`AggregationMethod`:\r\n\r\n- If **any** matching `ClientApi` objective for the `MetricID` has\r\n `MaxProgressPerCall > 0`, the server compares the **raw** `ProgressValue` you\r\n sent against the (minimum across matches) cap **before** any clamping. If the\r\n raw value exceeds it, the call is rejected **and the player is banned**\r\n (`service.BanUser(...)`, fire-and-forget) with error `\"User banned: Value\r\nexceeds MaxValuePerCall\"`. This is a hard anti-abuse trip-wire, not a soft\r\n clamp — never let client code send inflated values \"to be safe.\"\r\n- Only for objectives using `Sum` aggregation is the value additionally\r\n clamped to `MaxProgressPerCall` before summing (defense in depth; irrelevant\r\n once the ban-check above has already passed, since raw ⇐ cap by that point).\r\n\r\n---\r\n\r\n## Prerequisites (`RequiredQuestIDs`)\r\n\r\nSee the `PrerequisiteMode` table above. Enforcement points in `Quest.cs`:\r\n\r\n- **Progress accrual** (`AddQuestProgress` internal helper): for permanent\r\n quests and for each cycle a cyclic quest belongs to, prerequisites are\r\n checked (only in `BlockProgressAndClaim` mode) before the quest's\r\n `UserQuestProgress` is even created/updated for that call.\r\n- **Claim** (`ClaimQuestReward` / batch): `ArePrerequisitesMet(...)` is checked\r\n unconditionally (both modes gate the claim) — error\r\n `\"Prerequisite quests are not completed\"`.\r\n\r\n---\r\n\r\n## Cycle schedule resolution\r\n\r\n`QuestCycleDefinition.Schedule` is a `ScheduleSpec` (`_shared/ScheduleModels.ts`\r\n/ `Core/Scheduling`), the same primitive every other module uses. For Quest,\r\n`RefreshQuestCycles` resolves it via `ScheduleResolver.ResolveActive(...)`\r\ninto a `[CycleStartUtc, CycleEndUtc)` window per the active `Mode`:\r\n\r\n- **`Cyclic`** (the practical default for dailies/weeklies): `Cyclic.Reset` picks\r\n the calendar cadence — `Hourly`/`Daily`/`Weekly` (always **Monday** start)/`Monthly`\r\n (always the **1st**)/`Yearly` are calendar-aligned in **UTC**, reset time is\r\n **always 00:00:00 UTC** and is not configurable. `FixedInterval` instead repeats\r\n every `Cyclic.IntervalSeconds` seconds from `Cyclic.AnchorUtc` (default anchor\r\n `2026-01-01T00:00:00Z`, default interval 86400s if unset/≤0); an optional\r\n `PauseBetweenCyclesSec` inserts a dead gap after each active window during\r\n which `CanEarn` is `false` (`IsInPause = true`) but the window has still\r\n technically \"ended\" — new progress does not accrue during the pause, though\r\n already-completed quests remain claimable (claims are never earn-gated).\r\n- **`Scheduled`**: one fixed `[StartUtc, EndUtc]` window; `ClaimGraceHours`\r\n extends claimability past `EndUtc` without extending earning (unless\r\n `AllowEarningAfterEnd` is set).\r\n- **`AlwaysOn`**: always active, no end.\r\n- Any other/inactive resolution (e.g. `Triggered`, or `spec.IsActive === false`)\r\n makes `ComputeCycleWindowUtc` **degrade to a plain UTC calendar day**\r\n `[today 00:00, tomorrow 00:00)` as a fallback — don't configure `Triggered` on\r\n a quest cycle expecting anything else.\r\n\r\n**Rollover behavior** (`RefreshQuestCycles`, called automatically by\r\n`getUserQuestState({autoRefreshCycles: true})` — the default — and before every\r\nmutating Quest action): when the resolved `[start, end)` no longer matches the\r\nstored `CycleStartUtc`/`CycleEndUtc`, the entire `UserQuestCycleState` for that\r\ncycle is **replaced with a brand-new, empty one** (`Quests: {}`,\r\n`ClaimedGroupCompletionIDs` reset) — there is no partial carry-over of\r\nin-progress quests into the new window. Cycles removed from config entirely are\r\ndeleted from the player's state on the next refresh. If the window has **not**\r\nrolled over, the refresh instead walks the player's **existing** quest progress\r\nrecords (only ones already started) and re-evaluates `Completed` status against\r\ncurrent config — it does not add new objectives to already-tracked quests.\r\n\r\n---\r\n\r\n## Per-quest schedule (\"staged unlock\" / Achievements)\r\n\r\n`QuestDefinition.Schedule` is an **independent, optional** `ScheduleSpec` layered\r\non top of the cycle's own schedule — this is how \"Day 2 unlocks 24h after Day 1\"\r\nor an \"Achievements\" track with a `ScheduleSpec`-driven unlock (rather than a\r\nliteral day-count) is built, with any number of stages at any interval, not just\r\nliteral days:\r\n\r\n- `Schedule` absent → the quest simply inherits its cycle's window; earning and\r\n claiming follow the cycle's own `CanEarn`/`CanClaim`.\r\n- `Schedule` present → resolved via `ResolveQuestInstance`, which passes the\r\n **cycle's** resolved instance as the `parent` for `Relative`-mode windows —\r\n so a per-quest `Relative` schedule with `OffsetSecondsFromParentStart` is\r\n \"N seconds after this cycle instance started,\" letting one `Cyclic` cycle\r\n auto-repeat a whole staged sequence without hardcoded absolute dates.\r\n- `AccrueProgressWhenLocked` (default `false`) decides what happens **while**\r\n the cycle's window is open but the quest's own window is not: `false` means a\r\n locked stage accrues **zero** progress (a true lock — progress reported for\r\n its metric while locked is simply dropped for that quest); `true` means\r\n progress accrues the whole time the cycle is active, but the **reward claim**\r\n is still gated on the quest's own `CanClaim` — so you can pre-accrue \"Day 3\"\r\n progress while day 3 is still locked, and only the payout waits.\r\n\r\nEarning gate precedence for a cyclic quest, all of which must pass\r\n(`AddQuestProgress` internal helper): cycle `earningCycles` membership (cycle\r\nitself must be `CanEarn`, i.e. not `IsInPause`) → quest's own\r\n`IsQuestEarnable` (`AccrueProgressWhenLocked` bypasses this specific check) →\r\n`QuestGatesPass` (cycle `Gate` AND quest `Gate`) → prerequisites (only in\r\n`BlockProgressAndClaim` mode).\r\n\r\n---\r\n\r\n## Points track (\"Achievements\") — the Quest event-token\r\n\r\nThis is the mechanism the \"Achievements\" hint in the prompt refers to — it is\r\nreal and it is exactly the cycle's points track, not a separate module. Russian\r\ncomments in `Quest.cs` literally label it «Достижения» (Achievements).\r\n\r\n**How points get earned.** Each cyclic `QuestDefinition.PointsReward` (points,\r\nnot currency) is granted **only on claim** of that quest's own reward — via\r\n`ClaimQuestReward` / `ClaimQuestRewardsBatch`, in the **same atomic transaction**\r\nas the quest's `Reward` grant. It's `long`, defaults to `0`, and is ignored for\r\npermanent quests (`isPermanent` quests never touch the points track). A group\r\ncompletion (below) can **also** add points via its own `PointsReward`, on top of\r\nwhatever its member quests already contributed individually.\r\n\r\n**Where it's addressed.** The points track is backed by a standard\r\n`EventTokenType.Quest` event token (the same primitive TimedEvent/Leaderboard/\r\nCoopEvent/Season points tracks use), addressed at\r\n`EntityID = \"{cycleID}:{instanceKey}\"` where `instanceKey` comes from\r\n`ScheduleResolver`'s resolution of the **cycle's** schedule (`\"all\"` if the\r\ncycle has no resolvable instance). Because the instance key changes when the\r\ncycle's schedule rotates to a new window, **the points balance and claimed-milestone\r\nlist reset automatically on cycle rollover** — there is no explicit\r\n\"reset points\" step; it's a natural consequence of the address changing.\r\n\r\n**Where it lives in state.** `UserQuestState` does **not** carry the points\r\nbalance — it lives in `UserDataDocument.EventToken.Quest[entityID]`\r\n(`UserEventTokenProgress`: `Balance.Current`/`Balance.TotalEarned`,\r\n`Milestone.ClaimedIDs`). `GetUserQuestState` additionally projects a **read-only\r\nsnapshot** per cycle into `GetUserQuestStateResponse.PointsTracks[cycleID]`\r\n(`QuestPointsTrackView`) so the client doesn't have to know the composite key —\r\nthe TS SDK's `patchQuestPointsTracks` writes this into\r\n`client.data.user.state.EventToken.Quest[\"{cycleID}:{instanceKey}\"]` for you,\r\nand `client.data.user.getQuestPointsProgress(cycleID)` resolves the composite\r\nkey back out (`matchesBase`, `util/eventTokenIds.ts`) so you can look it up by\r\nplain `cycleID`.\r\n\r\n```ts\r\ninterface QuestPointsTrackView {\r\n CycleID: string;\r\n InstanceKey?: string | null;\r\n CycleStartUtc?: string | null;\r\n CycleEndUtc?: string | null; // source for a \"resets in\" timer\r\n PointsTotalEarned?: number | null; // lifetime points earned this cycle instance\r\n PointsCurrent?: number | null; // current balance (== TotalEarned; points are never spent)\r\n ClaimedPointMilestoneIDs?: string[] | null;\r\n}\r\n```\r\n\r\n**Milestones** (`QuestCycleDefinition.Milestones`, keyed by `MilestoneID`) are\r\nthe shared Core `MilestoneDefinition` primitive\r\n(`RequiredProgress`, `Rewards`, `BonusRewards`, `SeasonTierRewards`,\r\n`SortOrder`, `IsFeatured`) — see `_shared/MilestoneModels.ts`. Eligibility is\r\njudged **only** against `Balance.TotalEarned` on the points token (never\r\n`Current`, though for Quest the two happen to always be equal since points are\r\nonly ever granted, never spent) — `EventTokenService.ComputeMilestoneClaim`:\r\nfails with `\"Not enough earned. Have: X, need: Y.\"` if under threshold, or\r\n`\"Milestone already claimed.\"` if `MilestoneID` is already in `ClaimedIDs`.\r\nClaiming pushes the id into `ClaimedIDs` via a Mongo `$push` guarded by a\r\n`$nin` filter (OCC — a concurrent duplicate claim loses the race cleanly). The\r\nmilestone's reward itself runs through the shared `MilestoneRewardResolver`\r\n(same resolver Leaderboard/TimedEvent/CommunityChest/Referral use), which\r\napplies the title's progression-multiplier overlay\r\n(`cfg.Reward.MilestoneRewardMultiplier`) if configured — so the actual payout\r\ncan exceed the base `Rewards` grant; read it from the response, don't assume\r\nface value.\r\n\r\n**Caps** on points grants (`BuildPointsGrantContext`): **global** caps come from\r\n`QuestCycleDefinition.PointsToken` (an `EventTokenDefinition` — `DailyEarnCap`,\r\n`MaxBalance`, `MaxPerGrant`); **per-quest-source** caps/cooldown come from\r\n`QuestDefinition.Limits` (a `LimitSpec`, mapped as `DailyWeightCap` →\r\nper-source daily cap, `DailyCap` → per-source daily trigger count,\r\n`CooldownSeconds` → per-source cooldown). Per-source limits only apply in the\r\n**single** `ClaimQuestReward` path — the batch claim path\r\n(`ClaimQuestRewardsBatch`) only enforces the cycle's **global** `PointsToken`\r\ncaps against the **summed** batch amount per address, since per-source limits\r\ndon't make sense once amounts from multiple quests are merged into one token\r\noperation. If a cap fully exhausts the grant, `EventTokenService.ComputeGrant`\r\ncan reduce the amount to `0`, which surfaces as a failed points portion inside\r\nthe resource operation — always read granted amounts from the response, never\r\nassume the full `PointsReward` landed.\r\n\r\n---\r\n\r\n## Group-completion (grand reward) math\r\n\r\n`QuestCycleDefinition.GroupCompletions[completionID]` (`QuestGroupCompletionDefinition`):\r\n\r\n```ts\r\ninterface QuestGroupCompletionDefinition {\r\n CompletionID?: string;\r\n GroupID?: string; // must match QuestDefinition.GroupID on member quests\r\n RequiredCompletedQuests?: number; // 0 = \"ALL quests in this group, per current config\"\r\n Gate?: SegmentGate; // ANDed with the cycle's Gate\r\n Reward?: ResourceGrant;\r\n PointsReward?: number; // additional points into the SAME cycle points track; 0 = none\r\n}\r\n```\r\n\r\n`ClaimGroupCompletionReward` computes eligibility **live**, at claim time, by\r\nscanning the **current** `QuestDefinitions.Quests` for every quest that (a)\r\nlists this `cycleID` in its `CycleIDs` and (b) has `GroupID` equal to the\r\ncompletion's `GroupID` — that's `totalGroupQuests`. Of those, it counts how many\r\nhave reached `Status === \"Completed\"` **or** `\"Claimed\"` in the player's current\r\ncycle state — that's `completedGroupQuests`. The required threshold is\r\n`RequiredCompletedQuests` if `> 0`, otherwise `totalGroupQuests` (i.e. every\r\ngroup quest currently in config). Failure modes:\r\n\r\n- `RequiredCompletedQuests` unset and the group is empty/misconfigured (no\r\n quests currently reference that `GroupID` in that cycle) →\r\n `\"No quests configured for this group\"` (required resolves to `0`, which is\r\n rejected outright — you can never claim an empty group).\r\n- `completedGroupQuests < required` → `\"Not enough completed quests for this\r\ngroup\"`.\r\n- Already in `cycle.ClaimedGroupCompletionIDs` → `\"Group completion already\r\nclaimed\"` (checked in-memory before the DB round-trip, then re-enforced by an\r\n `AnyEq`-negated Mongo filter for the actual OCC guard).\r\n- `completion.GroupID` blank/whitespace on the definition itself →\r\n `\"Group completion has no GroupID\"` (a config error, not a player error).\r\n\r\nBecause the scan is **live against current config**, removing a quest from the\r\ngroup (or from the cycle) between when a player completed it and when they\r\nclaim the group reward can change `totalGroupQuests`/`completedGroupQuests` —\r\nthere's no snapshot of \"the group as it was.\" The response echoes\r\n`CompletedGroupQuests` and `RequiredGroupQuests` (the resolved threshold, not\r\nthe raw config field) so the client can show \"3 / 3\" without recomputing\r\nanything.\r\n\r\n---\r\n\r\n## `AddQuestProgress` server-side rules\r\n\r\nFull request-to-mutation path (`QuestV2.AddQuestProgress` public entry point +\r\nthe internal shared helper), summarized because several rules only make sense\r\ntogether:\r\n\r\n1. `MetricID` required; must match at least one `ClientApi`-sourced objective\r\n in the **entire** quest catalog, or the call fails with `\"MetricID not\r\nallowed for ClientApi\"` before touching the database.\r\n2. `ProgressValue` (`long`) must be `>= 0`.\r\n3. If any matching objective declares `MaxProgressPerCall > 0`, the **raw**\r\n value is checked against the smallest such cap across all matches; exceeding\r\n it **bans the account** (see the objective section above) rather than\r\n clamping — this is a hard security control, not UX guidance.\r\n4. The (possibly `Sum`-clamped) value then fans out to **every** quest/objective\r\n pair across **every currently-earning cycle and every permanent quest**\r\n whose objective's `Source === \"ClientApi\"` and `MetricID` matches — one\r\n `addQuestProgress` call can move several quests (even across different\r\n cycles) simultaneously if they all listen to the same metric.\r\n5. Each matched quest only advances if it isn't already `Completed`/`Claimed`\r\n (`ApplyToQuestInstance` early-returns `false` otherwise) — so calling\r\n `addQuestProgress` for an action a player keeps performing after a quest is\r\n done is safe and a no-op for that quest.\r\n6. The response's `Updates[]` (`QuestProgressUpdate`) lists **only the\r\n quest/objective pairs that actually changed** this call — an objective whose\r\n `Sum` increment was clamped to `0` (already at `TargetValue`) or a locked\r\n quest that accrued nothing produces no entry.\r\n7. This whole path calls `RefreshQuestCycles` first (unless the internal helper\r\n is invoked with `ensureCyclesUpToDate: false`, which the public\r\n `AddQuestProgress` action does to avoid double-refreshing) — so cycle\r\n windows are always current before progress is evaluated.\r\n\r\n---\r\n\r\n## Idempotency, atomicity, batch limits\r\n\r\n- **Idempotency keys** (`ResourceService.ResolveRelatedEntityID`, stable ID\r\n patterns from `Quest.cs`): single quest claim →\r\n `\"{questID}\"` (permanent) or `\"{questID}_{cycleStartUtc:yyyyMMddHHmmss}\"`\r\n (cyclic — so the **same** quest claimed again after the cycle rolls to a new\r\n window is a distinct idempotency key, not a duplicate); milestone claim →\r\n `\"{cycleID}_{milestoneID}_{instanceKey}\"`; group completion →\r\n `\"{cycleID}_{completionID}_{cycleStartUtc:yyyyMMddHHmmss}\"`. You never\r\n construct these yourself — the TS SDK mints its own client-side\r\n `RelatedEntityID` (`quest_claim_…`, `milestone_claim_…`, `group_completion_…`,\r\n each suffixed with a fresh UUID) purely for its own request-level tracking;\r\n the **server-side** idempotency guarantee comes from the stable IDs above\r\n plus the OCC filter on each claim, not from the client's `RelatedEntityID`.\r\n- **Atomicity.** Every claim path (`ClaimQuestReward`, `ClaimMilestoneReward`,\r\n `ClaimGroupCompletionReward`, and both batch variants) runs the reward grant\r\n and the state-mutating patches (status → `Claimed`, milestone `$push`, group\r\n `$addToSet`) inside **one** `ResourceService.ApplyResourceOperationAtomicAsync`\r\n call with an `extraFilter` re-asserting the pre-claim condition (e.g. quest\r\n `Status == Completed`) — if the grant fails for any reason (insufficient\r\n server-side room, a concurrent claim already flipped the filter condition,\r\n etc.) the whole transaction rolls back; there is no partially-applied claim.\r\n- **Resources in batch responses.** For both `ClaimQuestRewardsBatch` and\r\n `ClaimMilestoneRewardsBatch`, all included items' rewards are merged into a\r\n **single** `ResourceBundle` (`BatchSupport.MergeBundles`) and charged/granted\r\n in one call — the merged `ResourceOperation` is attached to only the **first\r\n successful** `BatchItemResult.Data.Resources` in the returned array; every\r\n other successful item's `Data.Resources` is an **empty** `ResourceOperation`\r\n (`new ResourceOperation()`), not a duplicate of the shared one. Don't sum\r\n resources across batch items — read them once from wherever they landed (the\r\n TS SDK's `applyResourceOperation` is only ever called once, on the first\r\n `Resources` it finds, matching this).\r\n- **Batch size.** `BatchSupport.MaxBatchSize = 50`. Both\r\n `claimQuestRewardsBatch` and `claimMilestoneRewardsBatch` accumulate refs from\r\n your array only up to 50 (after deduping by `CycleID+QuestID` /\r\n `CycleID+MilestoneID`); anything past the 50th valid, deduped entry is\r\n **silently dropped** — it never appears in the result array at all, so a\r\n `results.length` shorter than your input isn't necessarily an error. Chunk\r\n larger sets yourself.\r\n- **Batch validity filtering happens before charging.** Each item is\r\n independently checked (mongo-safety, config existence, gates, schedule\r\n window, prerequisites, current `Status`) and rejected into a preset\r\n `BatchItemResult` **before** the shared resource operation runs; only\r\n surviving items contribute to the merged grant and the combined Mongo filter\r\n (`AND` of each item's own OCC filter). That combined filter means: if even\r\n one surviving item's condition is no longer true by the time the transaction\r\n actually commits (e.g. a race with another request), **the entire merged\r\n operation fails** and every surviving item in that batch call reports the\r\n same `apply.Error` — \"partial-aware\" describes the **pre-filtering** stage,\r\n not protection against a mid-flight race on the shared charge.\r\n- **Rate limit / lock.** The whole `QuestV2` function uses\r\n `RateLimitMilliseconds = 500` (per-IP endpoint throttle) and\r\n `LockDurationMilliseconds = 10000` (per-user-action Mongo transaction lock)\r\n inside `ClientRun.Execute` — both are backend-side controls independent of\r\n the TS SDK's own 600ms client-side throttle guard.\r\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
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Timed-event data model — reference\n\nFull shape of the config (Definitions) and player state, the composite\nevent-token key scheme, the milestone self-heal rule, grace-window math, and\nthe bonus-window model. All of these are **strictly typed in the SDK** —\n`TimedEventDefinitions` and every nested block (`TimedEventDefinition`,\n`ChainedEventDefinition`, `EventContent`, `BonusWindowConfig`,\n`ActiveEventInfo`, …) are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<TimedEventDefinitions>(\"TimedEvent\")` give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\nEvery claim in this file traces to a specific backend source line — cited\ninline as `(file:line)` against the iDos_Games_Engine repo.\n\n## Contents\n\n- [Config: TimedEventDefinitions](#config-timedeventdefinitions)\n- [TimedEventDefinition (Scheduled vs Chained)](#timedeventdefinition-scheduled-vs-chained)\n- [EventContent](#eventcontent)\n- [Player state: UserEventTokenProgress](#player-state-usereventtokenprogress)\n- [ActiveEventInfo (getActiveEvents response)](#activeeventinfo-getactiveevents-response)\n- [The composite instance-key scheme](#the-composite-instance-key-scheme)\n- [Grace windows and claim-only instances](#grace-windows-and-claim-only-instances)\n- [Milestone claim rules and the self-heal on read](#milestone-claim-rules-and-the-self-heal-on-read)\n- [Bonus window (Coin-Master-style)](#bonus-window-coin-master-style)\n- [Token sources, matching, and grant math](#token-sources-matching-and-grant-math)\n- [Server-side limits, batching, and idempotency](#server-side-limits-batching-and-idempotency)\n\n---\n\n## Config: TimedEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\")`.\n\n```ts\ninterface TimedEventDefinitions {\n Definitions?: Record<string, TimedEventDefinition>; // key = TimedEventID\n Settings?: LimitedTimeEventsGlobalSettings;\n}\n\ninterface LimitedTimeEventsGlobalSettings {\n MaxConcurrentEvents?: number; // config-mistake guard; default 5\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/TimedEventDefinitions.cs:27-58`)\n\n---\n\n## TimedEventDefinition (Scheduled vs Chained)\n\nOne dictionary holds both kinds; the mode lives in `Schedule.Mode`.\n\n```ts\ninterface TimedEventDefinition {\n TimedEventID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode: \"Scheduled\" | \"Chained\"\n Content?: EventContent; // used when Mode = Scheduled\n Events?: ChainedEventDefinition[]; // used when Mode = Chained\n Gate?: SegmentGate; // audience gate; null = everyone\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:72-130`)\n\n- **Scheduled**: one fixed window (`Schedule.Scheduled: ScheduledWindow` —\n `StartUtc`, `EndUtc`, `AllowEarningAfterEnd`, `ClaimGraceHours`). Content\n lives directly on `Content`.\n- **Chained**: a repeating ordered list of phases (`Events`), timed by\n `Schedule.Chain: ScheduleChain` (`AnchorUtc`, `MaxCycles`,\n `PauseBetweenPhasesSec`, `PauseBetweenCyclesSec`). Each phase has its own\n `Content`. After the last phase, the whole cycle restarts from phase 0\n (unless `MaxCycles` caps the number of repeats).\n\n```ts\ninterface ChainedEventDefinition {\n ChainedEventID?: string; // unique within the chain\n Order?: number; // 0-based position; defines phase sequence\n DurationSec?: number;\n Content?: EventContent;\n ClaimGraceHours?: number; // 0 = no claiming once this phase ends\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:138-180`)\n\n`Gate` is the standard `SegmentGate` (Core/Segment) — `Segments`,\n`MinPremiumTier`, `RequiredPremiumIDs`, `MinLevel`/`MaxLevel`, `Countries`,\n`RegisteredWithinDays`, `ActiveWithinDays`, `Experiment`. A player failing the\ngate does not see the event in `getActiveEvents()` and cannot earn or spend\nits tokens — `GrantTokensInternal` re-checks the gate server-side even if a\nstale client tries to call it directly\n(`IDosGamesSDK/API/Client/v2/TimedEvent/TimedEvent.cs:325-329`).\n\n---\n\n## EventContent\n\nShared shape used by both a `Scheduled` event's `Content` and each\n`ChainedEventDefinition.Content`.\n\n```ts\ninterface EventContent {\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Category?: string; // free-form UI grouping tag\n Token?: EventTokenDefinition; // the event token's own config\n TokenSources?: TriggerSource[]; // whitelist of what earns this token\n ClaimMode?: \"Instant\" | \"AfterEventEnd\" | \"FeaturedAfterEnd\";\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID\n BonusWindow?: BonusWindowConfig; // null = disabled for this event\n}\n```\n\n(`TimedEventDefinitions.cs:192-280`, `Core/Milestone/Models/MilestoneClaimMode.cs:14-36`)\n\n`EventTokenDefinition` (`_shared/EventTokenDefinitionModels.ts`, port of\n`Core/Event/Models/EventTokenModels.cs:399-453`):\n\n```ts\ninterface EventTokenDefinition {\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxBalance?: number; // 0 = unlimited spendable balance cap\n MaxPerGrant?: number; // per-grant clamp; default 1000 server-side\n DailyEarnCap?: number; // 0 = unlimited daily earn total\n BurnOnEventEnd?: boolean; // default true — balance zeroed at event end\n BurnConversion?: EventTokenConversion; // optional leftover→currency conversion\n}\n```\n\n`MilestoneDefinition` is the shared Core/Milestone primitive (also used by\nLeaderboard/Quest/CommunityChest/DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against Balance.TotalEarned\n Rewards?: ResourceGrant; // base reward\n BonusRewards?: ResourceGrant; // added/scaled in during an active bonus window\n SeasonTierRewards?: SeasonTierRewardSet; // not used by TimedEvent\n SortOrder?: number;\n IsFeatured?: boolean; // gates FeaturedAfterEnd behavior\n}\n```\n\n(`Core/Milestone/Models/MilestoneDefinition.cs` via `_shared/MilestoneModels.ts:125-138`)\n\n`TriggerSource` (shared `_shared/ScheduleModels.ts:83-96`, port of\n`Core/Scheduling/Models/TriggerSource.cs`):\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // EventTokenSourceType, e.g. \"BoardTileLanding\"\n BaseWeight?: number; // tokens granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's roll multiplier\n TileTypeFilter?: string[]; // BoardTileLanding only; empty = any\n TileIndexFilter?: number[]; // BoardTileLanding only; empty = any\n ChanceOutcomeFilter?: string[]; // BoardTileLanding Chance tiles only; empty = any\n OutcomeFilter?: string[]; // checked for every source type; empty = any\n Params?: Record<string, string>; // CustomAction: ActionName; Marketplace*: CatalogID/ItemID/OfferType\n Limits?: LimitSpec; // DailyCap / DailyWeightCap / CooldownSeconds\n}\n```\n\n---\n\n## Player state: UserEventTokenProgress\n\nReturned inside `getUserLteState()`'s `Tokens` map and inside each\n`ActiveEventInfo.Progress`.\n\n```ts\ninterface UserEventTokenProgress {\n Balance?: {\n Current: number; // spendable balance; rises on grant, falls on spend\n TotalEarned: number; // lifetime earned in THIS instance; monotonic; milestone math uses this\n TotalSpent: number; // lifetime spent in this instance; analytics only\n };\n Daily?: {\n Date: string; // UTC date the counters below apply to; lazy-reset on next grant\n TotalEarned: number;\n EarnedBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyWeightCap\n TriggersBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyCap\n LastTriggerBySource?: Record<string, string>; // vs TriggerSource.Limits.CooldownSeconds; NOT reset daily\n };\n Meta?: {\n JoinedAtUtc?: string; // first grant into this instance's bucket\n LastEarnedAtUtc?: string;\n };\n Milestone?: {\n ClaimedIDs?: string[];\n UnlockedIDs?: string[]; // reached but not yet claimable under AfterEventEnd/FeaturedAfterEnd\n };\n}\n```\n\n(`Core/Event/Models/EventTokenModels.cs:51-179`, mirrored in SDK\n`_shared/EventTokenState.ts:8-38`)\n\nImportant: **spending tokens never affects `TotalEarned`**\n(`EventTokenService.ComputeSpend`, `EventTokenService.cs:311-337` only\ntouches `Balance.Current`/`Balance.TotalSpent`), so a milestone earned and\nthen \"un-afforded\" by spending remains claimable/claimed — milestones track\nlifetime earning, not current balance.\n\n---\n\n## ActiveEventInfo (getActiveEvents response)\n\n```ts\ninterface ActiveEventInfo {\n Type?: \"Scheduled\" | \"Chained\";\n TimedEventID?: string;\n CurrentChainedEventID?: string | null; // null for Scheduled\n Content?: EventContent | null; // resolved content for the current/ended instance\n Progress?: UserEventTokenProgress | null;\n ComputedStartUtc?: string | null;\n ComputedEndUtc?: string | null;\n CanEarn?: boolean | null; // tokens can still be granted for this instance\n CanClaim?: boolean | null; // still inside claim/grace window\n NextMilestone?: MilestoneDefinition | null; // lowest RequiredProgress not yet in ClaimedIDs\n BonusWindow?: BonusWindowState | null; // computed; null = no window / disabled\n CurrentCycleIndex?: number | null; // Chained only\n CurrentEventOrder?: number | null; // Chained only: 1-based position... (see note)\n TotalEventsInChain?: number | null; // Chained only\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/UserTimedEventState.cs:23-78`)\n\nNote: the backend populates `CurrentEventOrder` from\n`ChainedEventDefinition.Order`, which is documented as 0-based\n(`TimedEventDefinitions.cs:148-152`) — the SDK's own doc-comment calling it\n\"1-based\" is aspirational UI framing, not a code guarantee; treat it as \"the\nphase's configured `Order` value\" and don't assume it starts at 1.\n\n`getActiveEvents()` can return **more than one `ActiveEventInfo` for the same\n`Chained` `TimedEventID`** in a single response: the currently active phase,\nplus any phase(s) that already ended but are still inside their\n`ClaimGraceHours` window (`CanEarn: false`, `CanClaim: true`)\n(`TimedEvent.cs:149-169`, `EnumerateEndedInGraceChainInstances`,\n`TimedEvent.cs:801-836`). Disambiguate them by `CurrentCycleIndex` +\n`CurrentChainedEventID`.\n\n---\n\n## The composite instance-key scheme\n\nEvery event **instance** — not just every event — gets its own progress\nbucket, milestone-claimed list, and (for chains) bonus-window timeline. The\nbucket key (`EventTokenAddress.EntityID`, stored under\n`UserDataDocument.EventToken.TimedEvent[EntityID]`) is:\n\n```\nEntityID = \"{TimedEventID}:{InstanceKey}\"\n```\n\n(`TimedEvent.cs:1044-1057`, `BuildTokenAddress`)\n\nWhere `InstanceKey` depends on the resolved mode\n(`Core/Scheduling/Services/ScheduleInstanceKey.cs:14-23`):\n\n| Mode | `InstanceKey` format | Example |\n| ----------- | ------------------------------ | --------------- |\n| `AlwaysOn` | `\"all\"` | `all` |\n| `Scheduled` | `\"s{yyyyMMddHHmm}\"` (StartUtc) | `s202607010000` |\n| `Chained` | `\"{cycleIndex}:{phaseID}\"` | `4:boss_phase` |\n\nSo a `Scheduled` event's `EntityID` is effectively\n`\"summer_sale:s202607010000\"`, and a `Chained` event's is\n`\"raid_rotation:4:boss_phase\"`. This is why re-running the same\n`TimedEventID` (a new Scheduled window with a different `StartUtc`, or the\nnext chain cycle) starts every player at a fresh `Balance`/`Milestone`\nbucket — nothing carries over, by design.\n\nThe SDK's `UserTimedEventStateResponse.Tokens` map uses these same composite\nkeys. Cache helpers that need to find \"the bucket for this `LteID`, whatever\nits current instance suffix is\" use `matchesBase(key, lteID)`\n(`packages/core/src/util/eventTokenIds.ts:4-6`): a key belongs to a base id\nif it equals it exactly or starts with `\"{lteID}:\"`. `getUserLteState()` is a\nflat dump of every bucket the player has ever touched (including stale\nfinished instances) — don't assume one entry per `LteID`.\n\n---\n\n## Grace windows and claim-only instances\n\nOnce an instance's window ends, tokens can no longer be earned\n(`CanEarn` flips to `false`), but the milestone rewards already reached can\nstill be claimed until a grace deadline:\n\n```\nClaimDeadlineUtc = EndUtc + ClaimGraceHours\n```\n\n- `Scheduled`: `ClaimGraceHours` comes from `Schedule.Scheduled.ClaimGraceHours`\n (`TimedEvent.cs:728`). `AllowEarningAfterEnd` (also on `ScheduledWindow`)\n lets earning continue past `EndUtc` if set — independent of the grace\n window, which only governs _claiming_.\n- `Chained`: `ClaimGraceHours` comes from the specific\n `ChainedEventDefinition.ClaimGraceHours` (`TimedEvent.cs:718,773,826`) —\n each phase can have its own grace period. `AllowEarningAfterEnd` is always\n `false` for chain phases (`TimedEvent.cs:719`) — earning always stops the\n instant the phase ends.\n- `now > ClaimDeadlineUtc` ⇒ the instance is gone entirely: `ResolveScheduled`\n / `ScheduleResolver.ResolveChainInstance` return `null`\n (`Core/Scheduling/Services/ScheduleResolver.cs:127-145,361-406`), and any\n spend/grant/claim call against it fails with `\"Event not found or not\nactive.\"` / `\"...not in claim window.\"`.\n\n`EnumerateEndedInGraceChainInstances` walks backward through past chain\ncycles (hard-capped at 200 lookback instances,\n`ScheduleResolver.cs:414-484`) collecting every phase whose\n`now ∈ (EndUtc, EndUtc + ClaimGraceHours]`, **only for instances where the\nplayer has existing progress** (`TimedEvent.cs:156-159` — buckets with no\nprogress are skipped, so a phase the player never touched doesn't clutter\nthe active-events list). These are returned with `CanEarn: false,\nCanClaim: true` and must be addressed by their own `CycleIndex` +\n`ChainedEventID` when spending/claiming (`ResolveEventFromArgs`,\n`TimedEvent.cs:672-686`, only takes the explicit-instance path when **both**\n`CycleIndex` and `ChainedEventID` are supplied — omitting either resolves to\nwhatever instance is currently active instead).\n\n---\n\n## Milestone claim rules and the self-heal on read\n\n**Claim gate** (`ClaimMilestone`, `TimedEvent.cs:518-658`, and the batch\npaths mirror this via `CheckMilestoneClaimMode`, `TimedEvent.cs:1767-1775`):\n\n1. The resolved instance must have `CanClaim: true` (inside its window or\n grace), else `\"Claim window has expired.\"`.\n2. The milestone id must exist in the resolved content's `Milestones`, else\n `\"Milestone '<id>' not found.\"`.\n3. `Content.ClaimMode` gate:\n - `Instant` — always allowed once reached.\n - `AfterEventEnd` — rejected with `\"Milestone can only be claimed after\nevent ends.\"` until `now > EndUtc`.\n - `FeaturedAfterEnd` — same rejection (`\"Featured milestone can only be\nclaimed after event ends.\"`) but **only** when `MilestoneDefinition.IsFeatured\n=== true`; non-featured milestones under this mode behave like `Instant`.\n4. `EventTokenService.ComputeMilestoneClaim` (`EventTokenService.cs:343-366`):\n fails with `\"No progress for this event token.\"` if the bucket doesn't\n exist at all, `\"Not enough earned. Have: {X}, need: {Y}.\"` if\n `Balance.TotalEarned < RequiredProgress`, or `\"Milestone already\nclaimed.\"` if the id is already in `ClaimedIDs`.\n\n**Self-heal on `GetActiveEvents` read** (`SanitizeMilestoneState`,\n`TimedEvent.cs:1070-1131`, invoked from `BuildActiveEventInfo` at\n`TimedEvent.cs:1143` and staged as background `$pullAll` patches at\n`TimedEvent.cs:112-187`):\n\n- Trigger condition: for the **specific instance bucket being read**, any id\n present in that bucket's `Milestone.ClaimedIDs` or `Milestone.UnlockedIDs`\n whose corresponding `MilestoneDefinition.RequiredProgress` is **greater\n than that same bucket's own `Balance.TotalEarned`** is stale. An id with no\n matching entry in the resolved content's `Milestones` dictionary is also\n stripped (nothing to verify it against). The check is\n `totalEarned >= def.RequiredProgress` per id\n (`TimedEvent.cs:1076-1080`, local function `Reached`).\n- Why it's safe: `TotalEarned` is monotonically non-decreasing\n (`EventTokenService.ComputeGrant` only ever increments it,\n `EventTokenService.cs:226,271,283`), so a milestone legitimately claimed\n (earned had already reached the threshold _at claim time_) can never later\n have `TotalEarned` fall back below `RequiredProgress`. The only ids this\n can strip are ones inconsistent with their own bucket's recorded earnings\n — e.g. leftover data from before per-instance keying was introduced, not\n anything a normal claim flow can produce.\n- Effect: the returned `ActiveEventInfo.Progress.Milestone.ClaimedIDs`/\n `UnlockedIDs` (and therefore `NextMilestone`, which is computed from the\n sanitized `ClaimedIDs`) are already clean in the response you receive — you\n never see the stale ids. Separately, the same removals are persisted to\n the DB via `$pullAll` on `{entryPath}.Milestone.ClaimedIDs` /\n `...UnlockedIDs` (`TimedEvent.cs:1114-1131`) so the fix is permanent; this\n DB write is best-effort and wrapped in a swallowed try/catch\n (`TimedEvent.cs:177-187`) — a failed cleanup simply retries on the next\n `GetActiveEvents` call and never fails the read itself.\n- This only runs from `GetActiveEvents` (both the currently-active-instance\n path and the ended-in-grace path) — `GetUserLteState` returns the raw\n bucket as stored, unsanitized, which is one more reason to treat it as a\n secondary/debug view rather than the milestone UI's source of truth.\n\n---\n\n## Bonus window (Coin-Master-style)\n\n`EventContent.BonusWindow` (nullable) describes a repeating sequence of\nphases layered on top of the event's own timeline, used to scale milestone\nrewards during \"boosted\" windows:\n\n```ts\ninterface BonusWindowConfig {\n Schedule?: BonusWindowPhase[]; // ordered by Order; empty = disabled\n RepeatCycle?: boolean; // true: restart from phase 0 after the last phase\n MaxCycles?: number; // 0 = infinite (bounded only by the event's own end)\n}\n\ninterface BonusWindowPhase {\n Order: number; // 0-based, unique within Schedule\n Type: \"Cooldown\" | \"Bonus\" | \"MultipliedBonus\";\n DurationSec: number; // must be > 0\n BonusMultiplier?: number; // MultipliedBonus only; default 1.5\n}\n```\n\n(`TimedEventDefinitions.cs:315-395`)\n\nComputed per-request (never stored) by `BonusWindowHelpers.ComputePhase`\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Services/BonusWindowHelpers.cs:29-75`),\nanchored at the **event/phase's own start time** — so the phase schedule is\nidentical for every player and simply depends on wall-clock time since that\nstart:\n\n```ts\ninterface BonusWindowState {\n IsActive: boolean; // true only during Bonus/MultipliedBonus phases\n CurrentPhaseEndUtc: string;\n NextBonusStartUtc?: string; // null = no more bonus phases will occur\n CurrentCycleIndex: number; // 0-based pass through the whole Schedule\n CurrentPhaseIndex: number; // the active phase's Order\n ActiveBonusMultiplier: number; // Bonus=1.0, MultipliedBonus=phase.BonusMultiplier, else 0\n}\n```\n\n`ComputePhase` returns `null` when: `BonusWindow` is `null`/has an empty\n`Schedule`, the event hasn't started yet, or all cycles are exhausted\n(`RepeatCycle=false` and the one pass already completed, or `MaxCycles`\nreached) — treat a `null` `ActiveEventInfo.BonusWindow` as \"no boosted\nrewards available,\" not an error.\n\nAt claim time, the server independently recomputes the same\n`BonusWindowState` for the resolved instance's own `StartUtc`\n(`TimedEvent.cs:602-617`) — it is never trusted from a prior client read —\nand if `IsActive`, merges `MilestoneDefinition.BonusRewards` into the base\n`Rewards` via `MilestoneRewardResolver`/`BonusWindowHelpers.MergeRewards`,\nscaling the bonus part by `ActiveBonusMultiplier` when the phase type is\n`MultipliedBonus` (`BonusWindowHelpers.cs:136-176`, entries rounded via\n`Math.Round`). You cannot predict the exact reward amount client-side when a\n`MultipliedBonus` phase is active mid-window — read it from the claim\nresponse's `Rewards`.\n\n---\n\n## Token sources, matching, and grant math\n\nA grant (`grantTokens`/`grantTokensBatch`, and internally for board/quest/\nstore/marketplace triggers) resolves as follows\n(`GrantTokensInternal`, `TimedEvent.cs:273-435`):\n\n1. Resolve the target instance (current active, unless a chain ref supplies\n `CycleIndex`+`ChainedEventID`). Fails `\"Event not found or not active.\"`\n if unresolved, `\"Earning is not allowed.\"` if `CanEarn` is false.\n2. `TriggerMatcher.FindMatch` walks `Content.TokenSources` in order and\n returns the first `TriggerSource` whose `SourceType` matches and whose\n filters all pass (AND-ed) — see `TriggerMatcher.cs:25-59` for the exact\n per-`SourceType` filter rules (`BoardTileLanding` checks\n `TileTypeFilter`/`TileIndexFilter`/`ChanceOutcomeFilter`; `CustomAction`\n checks `Params[\"ActionName\"]`; `MarketplaceSell`/`MarketplaceBuy` check\n `Params[\"CatalogID\"]`/`[\"ItemID\"]`/`[\"OfferType\"]`; `OutcomeFilter` is\n checked for every source type). No match ⇒ `\"Source '<type>' is not\nallowed for this event.\"`.\n3. `baseAmount = amountOverride ?? source.BaseWeight`; must be `> 0` else\n `\"Base amount must be > 0.\"`.\n4. `adjustedAmount = ModifierService.Apply(baseAmount, ctx).FinalValue` where\n `ctx` only carries the roll multiplier, and only if\n `source.ScaleWithRollMultiplier` is true (`TimedEvent.cs:350-353`).\n5. `EventTokenService.ComputeGrant` (`EventTokenService.cs:156-305`) applies,\n **in order**: `DailyEarnCap` (global daily total) →\n `DailyCapFromSource`/`source.Limits.DailyWeightCap` (per-source daily\n amount) → `DailyTriggerCap`/`source.Limits.DailyCap` (per-source daily\n trigger _count_) → `CooldownSeconds` (per-source, not reset daily) →\n `MaxBalance` (spendable balance ceiling) — any of these can reject the\n grant outright (`EventTokenGrantFailure` reason string). If accepted, the\n amount is then **clamped** (not rejected) by `MaxPerGrant`, remaining\n daily headroom, and remaining balance headroom, in that order\n (`EventTokenService.cs:205-224`) — so a grant can silently apply for less\n than requested near a cap, rather than failing.\n\n`BuildBoardTokenOperations`/`BuildMarketplaceTokenOperations`\n(`TimedEvent.cs:900-995`) are the server-internal helpers other modules\n(GameLoop, Marketplace) use to fan a single gameplay action out to every\nmatching active event — not something client code calls directly, but useful\ncontext for why a single board roll can grant several different event\ntokens at once.\n\n---\n\n## Server-side limits, batching, and idempotency\n\n- **Max batch size: 50** entries per call (`BatchSupport.MaxBatchSize`,\n `IDosGamesSDK/API/Client/v2/_Shared/BatchSupport.cs:35`), enforced\n identically for `ClaimMilestonesBatch`, `SpendTokensBatch`, and\n `GrantTokensBatch` (`TimedEvent.cs:1200,1306,1475,1583`). Entries beyond 50\n are silently dropped during normalization — they never appear in the\n response at all, so chunk larger sets into multiple calls yourself.\n- **Dedup**: `ClaimMilestonesBatch` dedupes by `(instance key)|(MilestoneID)`\n (`TimedEvent.cs:1195-1201`); `SpendTokensBatch` dedupes by instance key\n (`TimedEvent.cs:1470-1477`); `GrantTokensBatch` dedupes by\n `(instance key)|SourceType|Outcome` on input, **and separately rejects a\n second grant to the same resolved token address** within one batch with\n `\"Duplicate event instance in grant batch — send it as a separate\nrequest.\"` (`TimedEvent.cs:1634-1637`) because two grants to one address\n in the same Mongo update would conflict.\n- **Atomicity**: each batch call resolves every entry, then applies **one**\n atomic `ResourceService.ApplyResourceOperationAtomicAsync` for the whole\n batch. For `SpendTokensBatch`/`GrantTokensBatch` this means the _entire_\n batch's resource change succeeds or fails together — a single\n insufficient-balance/over-cap item fails the whole apply and every\n successfully-resolved item in that batch reports the same `Error`\n (`TimedEvent.cs:1518-1552,1659-1695`). Items that failed to even _resolve_\n (bad instance ref, unknown milestone, claim-mode gate) are filtered out\n **before** the atomic apply and get their own independent preset error —\n those don't block the rest of the batch.\n `ClaimMilestonesBatch`/`ClaimAllMilestones` are slightly more granular:\n milestones are grouped **per resolved token address** so multiple\n milestones on the _same_ event instance share one `$push`, but the\n token-threshold/already-claimed check\n (`EventTokenService.ComputeMilestoneClaimBatch`) still runs per address\n before the shared atomic apply, so a milestone that fails its own\n threshold/already-claimed check is rejected independently of the others\n (`TimedEvent.cs:1372-1413`).\n- **Idempotency (`reason` / `RelatedEntityID`)**: every mutating call passes\n a `reason` string to `ApplyResourceOperationAtomicAsync` built from the\n action, the resolved `Type`+`EntityID` (and `MilestoneID`/`sourceKey`\n where relevant), and — for single-item calls — the caller's optional\n `RelatedEntityID` folded in via `ResourceService.ResolveRelatedEntityID`\n (e.g. `\"SpendTokens:spend_{Type}_{EntityID}_{RelatedEntityID}\"`,\n `TimedEvent.cs:484-489,619-624,405-413`). Including `Type` guards against a\n `Scheduled` and `Chained` event that happen to share an `LteID`; including\n the instance-keyed `EntityID` guards against collisions across chain\n instances or across unrelated events reusing the same `RelatedEntityID`\n string (e.g. `\"roll_42\"`). Batch calls build one shared reason from all\n included item keys (`BatchSupport.BuildBatchReason`) rather than one per\n item.\n- **Where `Resources` live in batch responses**: for `SpendTokensBatch`\n /`GrantTokensBatch`, the single merged `ResourceOperation` from the one\n atomic apply is attached to the **first successfully-applied item only**\n (`attached` flag, `TimedEvent.cs:1536-1552,1677-1693`) — every other\n successful item in that batch gets an **empty** `ResourceOperation` in its\n `Data.Resources`. The SDK's `spendTokensBatch`/`grantTokensBatch` already\n account for this: they scan for the first item with a non-empty\n `Resources` and apply that once to the cache\n (`TimedEventService.ts:209-221,238-250`) — don't assume every batch item\n carries its own independent `Resources`/`Rewards` payload; read cache\n balances after the call instead of summing per-item deltas.\n- **Rate limit**: the v2 pipeline's per-IP endpoint limit for\n `TimedEventV2` is 500 ms (`RateLimitMilliseconds`,\n `TimedEvent.cs:18`); per-user/action transaction lock is 10 s\n (`LockDurationMilliseconds`, `TimedEvent.cs:19`). The SDK's own client-side\n throttle is a separate, smaller 600 ms guard per endpoint\n (`packages/core/src/transport/throttle.ts:4`, `DEFAULT_THROTTLE_MS`).\n"
8
+ "content": "# Timed-event data model — reference\n\nFull shape of the config (Definitions) and player state, the composite\nevent-token key scheme, the milestone self-heal rule, grace-window math, and\nthe bonus-window model. All of these are **strictly typed in the SDK** —\n`TimedEventDefinitions` and every nested block (`TimedEventDefinition`,\n`ChainedEventDefinition`, `EventContent`, `BonusWindowConfig`,\n`ActiveEventInfo`, …) are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<TimedEventDefinitions>(\"TimedEvent\")` give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\nEvery claim in this file traces to a specific backend source line — cited\ninline as `(file:line)` against the iDos_Games_Engine repo.\n\n## Contents\n\n- [Config: TimedEventDefinitions](#config-timedeventdefinitions)\n- [TimedEventDefinition (Scheduled vs Chained)](#timedeventdefinition-scheduled-vs-chained)\n- [EventContent](#eventcontent)\n- [Player state: UserEventTokenProgress](#player-state-usereventtokenprogress)\n- [ActiveEventInfo (getActiveEvents response)](#activeeventinfo-getactiveevents-response)\n- [The composite instance-key scheme](#the-composite-instance-key-scheme)\n- [Grace windows and claim-only instances](#grace-windows-and-claim-only-instances)\n- [Milestone claim rules and the self-heal on read](#milestone-claim-rules-and-the-self-heal-on-read)\n- [Bonus window (Coin-Master-style)](#bonus-window-coin-master-style)\n- [Token sources, matching, and grant math](#token-sources-matching-and-grant-math)\n- [Server-side limits, batching, and idempotency](#server-side-limits-batching-and-idempotency)\n\n---\n\n## Config: TimedEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<TimedEventDefinitions>(\"TimedEvent\")`.\n\n```ts\ninterface TimedEventDefinitions {\n Definitions?: Record<string, TimedEventDefinition>; // key = TimedEventID\n Settings?: LimitedTimeEventsGlobalSettings;\n}\n\ninterface LimitedTimeEventsGlobalSettings {\n MaxConcurrentEvents?: number; // config-mistake guard; default 5\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/TimedEventDefinitions.cs:27-58`)\n\n---\n\n## TimedEventDefinition (Scheduled vs Chained)\n\nOne dictionary holds both kinds; the mode lives in `Schedule.Mode`.\n\n```ts\ninterface TimedEventDefinition {\n TimedEventID?: string;\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Schedule?: ScheduleSpec; // Mode: \"Scheduled\" | \"Chained\"\n Content?: EventContent; // used when Mode = Scheduled\n Events?: ChainedEventDefinition[]; // used when Mode = Chained\n Gate?: SegmentGate; // audience gate; null = everyone\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:72-130`)\n\n- **Scheduled**: one fixed window (`Schedule.Scheduled: ScheduledWindow` —\n `StartUtc`, `EndUtc`, `AllowEarningAfterEnd`, `ClaimGraceHours`). Content\n lives directly on `Content`.\n- **Chained**: a repeating ordered list of phases (`Events`), timed by\n `Schedule.Chain: ScheduleChain` (`AnchorUtc`, `MaxCycles`,\n `PauseBetweenPhasesSec`, `PauseBetweenCyclesSec`). Each phase has its own\n `Content`. After the last phase, the whole cycle restarts from phase 0\n (unless `MaxCycles` caps the number of repeats).\n\n```ts\ninterface ChainedEventDefinition {\n ChainedEventID?: string; // unique within the chain\n Order?: number; // 0-based position; defines phase sequence\n DurationSec?: number;\n Content?: EventContent;\n ClaimGraceHours?: number; // 0 = no claiming once this phase ends\n CustomParams?: Record<string, string>;\n}\n```\n\n(`TimedEventDefinitions.cs:138-180`)\n\n`Gate` is the standard `SegmentGate` (Core/Segment) — `Segments`,\n`MinPremiumTier`, `RequiredPremiumIDs`, `MinLevel`/`MaxLevel`, `Countries`,\n`RegisteredWithinDays`, `ActiveWithinDays`, `Experiment`. A player failing the\ngate does not see the event in `getActiveEvents()` and cannot earn or spend\nits tokens — `GrantTokensInternal` re-checks the gate server-side even if a\nstale client tries to call it directly\n(`IDosGamesSDK/API/Client/v2/TimedEvent/TimedEvent.cs:325-329`).\n\n---\n\n## EventContent\n\nShared shape used by both a `Scheduled` event's `Content` and each\n`ChainedEventDefinition.Content`.\n\n```ts\ninterface EventContent {\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Category?: string; // free-form UI grouping tag\n Token?: EventTokenDefinition; // the event token's own config\n TokenSources?: TriggerSource[]; // whitelist of what earns this token\n ClaimMode?: \"Instant\" | \"AfterEventEnd\" | \"FeaturedAfterEnd\";\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID\n BonusWindow?: BonusWindowConfig; // null = disabled for this event\n}\n```\n\n(`TimedEventDefinitions.cs:192-280`, `Core/Milestone/Models/MilestoneClaimMode.cs:14-36`)\n\n`EventTokenDefinition` (`_shared/EventTokenDefinitionModels.ts`, port of\n`Core/Event/Models/EventTokenModels.cs:399-453`):\n\n```ts\ninterface EventTokenDefinition {\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxBalance?: number; // 0 = unlimited spendable balance cap\n MaxPerGrant?: number; // per-grant clamp; default 1000 server-side\n DailyEarnCap?: number; // 0 = unlimited daily earn total\n BurnOnEventEnd?: boolean; // default true — balance zeroed at event end\n BurnConversion?: EventTokenConversion; // optional leftover→currency conversion\n}\n```\n\n`MilestoneDefinition` is the shared Core/Milestone primitive (also used by\nLeaderboard/Quest/CommunityChest/DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against Balance.TotalEarned\n Rewards?: ResourceGrant; // base reward\n BonusRewards?: ResourceGrant; // added/scaled in during an active bonus window\n SeasonTierRewards?: SeasonTierRewardSet; // not used by TimedEvent\n SortOrder?: number;\n IsFeatured?: boolean; // gates FeaturedAfterEnd behavior\n}\n```\n\n(`Core/Milestone/Models/MilestoneDefinition.cs` via `_shared/MilestoneModels.ts:125-138`)\n\n`TriggerSource` (shared `_shared/ScheduleModels.ts:83-96`, port of\n`Core/Scheduling/Models/TriggerSource.cs`):\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // EventTokenSourceType, e.g. \"BoardTileLanding\"\n BaseWeight?: number; // tokens granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's roll multiplier\n TileTypeFilter?: string[]; // BoardTileLanding only; empty = any\n TileIndexFilter?: number[]; // BoardTileLanding only; empty = any\n ChanceOutcomeFilter?: string[]; // BoardTileLanding Chance tiles only; empty = any\n OutcomeFilter?: string[]; // checked for every source type; empty = any\n Params?: Record<string, string>; // CustomAction: ActionName; Marketplace*: CatalogID/ItemID/OfferType\n Limits?: LimitSpec; // DailyCap / DailyWeightCap / CooldownSeconds\n}\n```\n\n---\n\n## Player state: UserEventTokenProgress\n\nReturned inside `getUserLteState()`'s `Tokens` map and inside each\n`ActiveEventInfo.Progress`.\n\n```ts\ninterface UserEventTokenProgress {\n Balance?: {\n Current: number; // spendable balance; rises on grant, falls on spend\n TotalEarned: number; // lifetime earned in THIS instance; monotonic; milestone math uses this\n TotalSpent: number; // lifetime spent in this instance; analytics only\n };\n Daily?: {\n Date: string; // UTC date the counters below apply to; lazy-reset on next grant\n TotalEarned: number;\n EarnedBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyWeightCap\n TriggersBySource?: Record<string, number>; // vs TriggerSource.Limits.DailyCap\n LastTriggerBySource?: Record<string, string>; // vs TriggerSource.Limits.CooldownSeconds; NOT reset daily\n };\n Meta?: {\n JoinedAtUtc?: string; // first grant into this instance's bucket\n LastEarnedAtUtc?: string;\n };\n Milestone?: {\n ClaimedIDs?: string[];\n UnlockedIDs?: string[]; // reached but not yet claimable under AfterEventEnd/FeaturedAfterEnd\n };\n}\n```\n\n(`Core/Event/Models/EventTokenModels.cs:51-179`, mirrored in SDK\n`_shared/EventTokenState.ts:8-38`)\n\nImportant: **spending tokens never affects `TotalEarned`**\n(`EventTokenService.ComputeSpend`, `EventTokenService.cs:311-337` only\ntouches `Balance.Current`/`Balance.TotalSpent`), so a milestone earned and\nthen \"un-afforded\" by spending remains claimable/claimed — milestones track\nlifetime earning, not current balance.\n\n---\n\n## ActiveEventInfo (getActiveEvents response)\n\n```ts\ninterface ActiveEventInfo {\n Type?: \"Scheduled\" | \"Chained\";\n TimedEventID?: string;\n CurrentChainedEventID?: string | null; // null for Scheduled\n Content?: EventContent | null; // resolved content for the current/ended instance\n Progress?: UserEventTokenProgress | null;\n ComputedStartUtc?: string | null;\n ComputedEndUtc?: string | null;\n CanEarn?: boolean | null; // tokens can still be granted for this instance\n CanClaim?: boolean | null; // still inside claim/grace window\n NextMilestone?: MilestoneDefinition | null; // lowest RequiredProgress not yet in ClaimedIDs\n BonusWindow?: BonusWindowState | null; // computed; null = no window / disabled\n CurrentCycleIndex?: number | null; // Chained only\n CurrentEventOrder?: number | null; // Chained only: 1-based position... (see note)\n TotalEventsInChain?: number | null; // Chained only\n}\n```\n\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Models/UserTimedEventState.cs:23-78`)\n\nNote: the backend populates `CurrentEventOrder` from\n`ChainedEventDefinition.Order`, which is documented as 0-based\n(`TimedEventDefinitions.cs:148-152`) — the SDK's own doc-comment calling it\n\"1-based\" is aspirational UI framing, not a code guarantee; treat it as \"the\nphase's configured `Order` value\" and don't assume it starts at 1.\n\n`getActiveEvents()` can return **more than one `ActiveEventInfo` for the same\n`Chained` `TimedEventID`** in a single response: the currently active phase,\nplus any phase(s) that already ended but are still inside their\n`ClaimGraceHours` window (`CanEarn: false`, `CanClaim: true`)\n(`TimedEvent.cs:149-169`, `EnumerateEndedInGraceChainInstances`,\n`TimedEvent.cs:801-836`). Disambiguate them by `CurrentCycleIndex` +\n`CurrentChainedEventID`.\n\n---\n\n## The composite instance-key scheme\n\nEvery event **instance** — not just every event — gets its own progress\nbucket, milestone-claimed list, and (for chains) bonus-window timeline. The\nbucket key (`EventTokenAddress.EntityID`, stored under\n`UserDataDocument.EventToken.TimedEvent[EntityID]`) is:\n\n```\nEntityID = \"{TimedEventID}:{InstanceKey}\"\n```\n\n(`TimedEvent.cs:1044-1057`, `BuildTokenAddress`)\n\nWhere `InstanceKey` depends on the resolved mode\n(`Core/Scheduling/Services/ScheduleInstanceKey.cs:14-23`):\n\n| Mode | `InstanceKey` format | Example |\n| ----------- | ------------------------------ | --------------- |\n| `AlwaysOn` | `\"all\"` | `all` |\n| `Scheduled` | `\"s{yyyyMMddHHmm}\"` (StartUtc) | `s202607010000` |\n| `Chained` | `\"{cycleIndex}:{phaseID}\"` | `4:boss_phase` |\n\nSo a `Scheduled` event's `EntityID` is effectively\n`\"summer_sale:s202607010000\"`, and a `Chained` event's is\n`\"raid_rotation:4:boss_phase\"`. This is why re-running the same\n`TimedEventID` (a new Scheduled window with a different `StartUtc`, or the\nnext chain cycle) starts every player at a fresh `Balance`/`Milestone`\nbucket — nothing carries over, by design.\n\n### Addressing an event from title config (short form)\n\nThe composite key above is a **runtime** address — the cycle index and the\nwindow start are unknowable when a reward is authored. So a reward written in\ntitle config (a Special-mode choice on the board, a store offer, a quest\npayout…) addresses the event by name instead:\n\n| `Address.EntityID` in config | Meaning |\n| ---------------------------- | ---------------------------------------------------------- |\n| `\"raid_rotation\"` | whichever instance of that event is live at grant time |\n| `\"raid_rotation:boss_phase\"` | that chain phase, current cycle — skipped when it isn't live |\n\nThe backend expands it right before the grant (`EventTokenAddressResolver`,\ncalled from `ResourceService`), stamping the instance suffix that is active at\nthat moment. A grant whose event is paused, off, or currently in another phase\nis dropped rather than written to a bucket nobody reads; a *consume* keeps the\nshort address so the price can never silently become free. Already-composite\naddresses pass through untouched, so this is safe to re-apply.\n\nThe SDK's `UserTimedEventStateResponse.Tokens` map uses these same composite\nkeys. Cache helpers that need to find \"the bucket for this `LteID`, whatever\nits current instance suffix is\" use `matchesBase(key, lteID)`\n(`packages/core/src/util/eventTokenIds.ts:4-6`): a key belongs to a base id\nif it equals it exactly or starts with `\"{lteID}:\"`. `getUserLteState()` is a\nflat dump of every bucket the player has ever touched (including stale\nfinished instances) — don't assume one entry per `LteID`.\n\n---\n\n## Grace windows and claim-only instances\n\nOnce an instance's window ends, tokens can no longer be earned\n(`CanEarn` flips to `false`), but the milestone rewards already reached can\nstill be claimed until a grace deadline:\n\n```\nClaimDeadlineUtc = EndUtc + ClaimGraceHours\n```\n\n- `Scheduled`: `ClaimGraceHours` comes from `Schedule.Scheduled.ClaimGraceHours`\n (`TimedEvent.cs:728`). `AllowEarningAfterEnd` (also on `ScheduledWindow`)\n lets earning continue past `EndUtc` if set — independent of the grace\n window, which only governs _claiming_.\n- `Chained`: `ClaimGraceHours` comes from the specific\n `ChainedEventDefinition.ClaimGraceHours` (`TimedEvent.cs:718,773,826`) —\n each phase can have its own grace period. `AllowEarningAfterEnd` is always\n `false` for chain phases (`TimedEvent.cs:719`) — earning always stops the\n instant the phase ends.\n- `now > ClaimDeadlineUtc` ⇒ the instance is gone entirely: `ResolveScheduled`\n / `ScheduleResolver.ResolveChainInstance` return `null`\n (`Core/Scheduling/Services/ScheduleResolver.cs:127-145,361-406`), and any\n spend/grant/claim call against it fails with `\"Event not found or not\nactive.\"` / `\"...not in claim window.\"`.\n\n`EnumerateEndedInGraceChainInstances` walks backward through past chain\ncycles (hard-capped at 200 lookback instances,\n`ScheduleResolver.cs:414-484`) collecting every phase whose\n`now ∈ (EndUtc, EndUtc + ClaimGraceHours]`, **only for instances where the\nplayer has existing progress** (`TimedEvent.cs:156-159` — buckets with no\nprogress are skipped, so a phase the player never touched doesn't clutter\nthe active-events list). These are returned with `CanEarn: false,\nCanClaim: true` and must be addressed by their own `CycleIndex` +\n`ChainedEventID` when spending/claiming (`ResolveEventFromArgs`,\n`TimedEvent.cs:672-686`, only takes the explicit-instance path when **both**\n`CycleIndex` and `ChainedEventID` are supplied — omitting either resolves to\nwhatever instance is currently active instead).\n\n---\n\n## Milestone claim rules and the self-heal on read\n\n**Claim gate** (`ClaimMilestone`, `TimedEvent.cs:518-658`, and the batch\npaths mirror this via `CheckMilestoneClaimMode`, `TimedEvent.cs:1767-1775`):\n\n1. The resolved instance must have `CanClaim: true` (inside its window or\n grace), else `\"Claim window has expired.\"`.\n2. The milestone id must exist in the resolved content's `Milestones`, else\n `\"Milestone '<id>' not found.\"`.\n3. `Content.ClaimMode` gate:\n - `Instant` — always allowed once reached.\n - `AfterEventEnd` — rejected with `\"Milestone can only be claimed after\nevent ends.\"` until `now > EndUtc`.\n - `FeaturedAfterEnd` — same rejection (`\"Featured milestone can only be\nclaimed after event ends.\"`) but **only** when `MilestoneDefinition.IsFeatured\n=== true`; non-featured milestones under this mode behave like `Instant`.\n4. `EventTokenService.ComputeMilestoneClaim` (`EventTokenService.cs:343-366`):\n fails with `\"No progress for this event token.\"` if the bucket doesn't\n exist at all, `\"Not enough earned. Have: {X}, need: {Y}.\"` if\n `Balance.TotalEarned < RequiredProgress`, or `\"Milestone already\nclaimed.\"` if the id is already in `ClaimedIDs`.\n\n**Self-heal on `GetActiveEvents` read** (`SanitizeMilestoneState`,\n`TimedEvent.cs:1070-1131`, invoked from `BuildActiveEventInfo` at\n`TimedEvent.cs:1143` and staged as background `$pullAll` patches at\n`TimedEvent.cs:112-187`):\n\n- Trigger condition: for the **specific instance bucket being read**, any id\n present in that bucket's `Milestone.ClaimedIDs` or `Milestone.UnlockedIDs`\n whose corresponding `MilestoneDefinition.RequiredProgress` is **greater\n than that same bucket's own `Balance.TotalEarned`** is stale. An id with no\n matching entry in the resolved content's `Milestones` dictionary is also\n stripped (nothing to verify it against). The check is\n `totalEarned >= def.RequiredProgress` per id\n (`TimedEvent.cs:1076-1080`, local function `Reached`).\n- Why it's safe: `TotalEarned` is monotonically non-decreasing\n (`EventTokenService.ComputeGrant` only ever increments it,\n `EventTokenService.cs:226,271,283`), so a milestone legitimately claimed\n (earned had already reached the threshold _at claim time_) can never later\n have `TotalEarned` fall back below `RequiredProgress`. The only ids this\n can strip are ones inconsistent with their own bucket's recorded earnings\n — e.g. leftover data from before per-instance keying was introduced, not\n anything a normal claim flow can produce.\n- Effect: the returned `ActiveEventInfo.Progress.Milestone.ClaimedIDs`/\n `UnlockedIDs` (and therefore `NextMilestone`, which is computed from the\n sanitized `ClaimedIDs`) are already clean in the response you receive — you\n never see the stale ids. Separately, the same removals are persisted to\n the DB via `$pullAll` on `{entryPath}.Milestone.ClaimedIDs` /\n `...UnlockedIDs` (`TimedEvent.cs:1114-1131`) so the fix is permanent; this\n DB write is best-effort and wrapped in a swallowed try/catch\n (`TimedEvent.cs:177-187`) — a failed cleanup simply retries on the next\n `GetActiveEvents` call and never fails the read itself.\n- This only runs from `GetActiveEvents` (both the currently-active-instance\n path and the ended-in-grace path) — `GetUserLteState` returns the raw\n bucket as stored, unsanitized, which is one more reason to treat it as a\n secondary/debug view rather than the milestone UI's source of truth.\n\n---\n\n## Bonus window (Coin-Master-style)\n\n`EventContent.BonusWindow` (nullable) describes a repeating sequence of\nphases layered on top of the event's own timeline, used to scale milestone\nrewards during \"boosted\" windows:\n\n```ts\ninterface BonusWindowConfig {\n Schedule?: BonusWindowPhase[]; // ordered by Order; empty = disabled\n RepeatCycle?: boolean; // true: restart from phase 0 after the last phase\n MaxCycles?: number; // 0 = infinite (bounded only by the event's own end)\n}\n\ninterface BonusWindowPhase {\n Order: number; // 0-based, unique within Schedule\n Type: \"Cooldown\" | \"Bonus\" | \"MultipliedBonus\";\n DurationSec: number; // must be > 0\n BonusMultiplier?: number; // MultipliedBonus only; default 1.5\n}\n```\n\n(`TimedEventDefinitions.cs:315-395`)\n\nComputed per-request (never stored) by `BonusWindowHelpers.ComputePhase`\n(`IDosGamesSDK/API/Client/v2/TimedEvent/Services/BonusWindowHelpers.cs:29-75`),\nanchored at the **event/phase's own start time** — so the phase schedule is\nidentical for every player and simply depends on wall-clock time since that\nstart:\n\n```ts\ninterface BonusWindowState {\n IsActive: boolean; // true only during Bonus/MultipliedBonus phases\n CurrentPhaseEndUtc: string;\n NextBonusStartUtc?: string; // null = no more bonus phases will occur\n CurrentCycleIndex: number; // 0-based pass through the whole Schedule\n CurrentPhaseIndex: number; // the active phase's Order\n ActiveBonusMultiplier: number; // Bonus=1.0, MultipliedBonus=phase.BonusMultiplier, else 0\n}\n```\n\n`ComputePhase` returns `null` when: `BonusWindow` is `null`/has an empty\n`Schedule`, the event hasn't started yet, or all cycles are exhausted\n(`RepeatCycle=false` and the one pass already completed, or `MaxCycles`\nreached) — treat a `null` `ActiveEventInfo.BonusWindow` as \"no boosted\nrewards available,\" not an error.\n\nAt claim time, the server independently recomputes the same\n`BonusWindowState` for the resolved instance's own `StartUtc`\n(`TimedEvent.cs:602-617`) — it is never trusted from a prior client read —\nand if `IsActive`, merges `MilestoneDefinition.BonusRewards` into the base\n`Rewards` via `MilestoneRewardResolver`/`BonusWindowHelpers.MergeRewards`,\nscaling the bonus part by `ActiveBonusMultiplier` when the phase type is\n`MultipliedBonus` (`BonusWindowHelpers.cs:136-176`, entries rounded via\n`Math.Round`). You cannot predict the exact reward amount client-side when a\n`MultipliedBonus` phase is active mid-window — read it from the claim\nresponse's `Rewards`.\n\n---\n\n## Token sources, matching, and grant math\n\nA grant (`grantTokens`/`grantTokensBatch`, and internally for board/quest/\nstore/marketplace triggers) resolves as follows\n(`GrantTokensInternal`, `TimedEvent.cs:273-435`):\n\n1. Resolve the target instance (current active, unless a chain ref supplies\n `CycleIndex`+`ChainedEventID`). Fails `\"Event not found or not active.\"`\n if unresolved, `\"Earning is not allowed.\"` if `CanEarn` is false.\n2. `TriggerMatcher.FindMatch` walks `Content.TokenSources` in order and\n returns the first `TriggerSource` whose `SourceType` matches and whose\n filters all pass (AND-ed) — see `TriggerMatcher.cs:25-59` for the exact\n per-`SourceType` filter rules (`BoardTileLanding` checks\n `TileTypeFilter`/`TileIndexFilter`/`ChanceOutcomeFilter`; `CustomAction`\n checks `Params[\"ActionName\"]`; `MarketplaceSell`/`MarketplaceBuy` check\n `Params[\"CatalogID\"]`/`[\"ItemID\"]`/`[\"OfferType\"]`; `OutcomeFilter` is\n checked for every source type). No match ⇒ `\"Source '<type>' is not\nallowed for this event.\"`.\n3. `baseAmount = amountOverride ?? source.BaseWeight`; must be `> 0` else\n `\"Base amount must be > 0.\"`.\n4. `adjustedAmount = ModifierService.Apply(baseAmount, ctx).FinalValue` where\n `ctx` only carries the roll multiplier, and only if\n `source.ScaleWithRollMultiplier` is true (`TimedEvent.cs:350-353`).\n5. `EventTokenService.ComputeGrant` (`EventTokenService.cs:156-305`) applies,\n **in order**: `DailyEarnCap` (global daily total) →\n `DailyCapFromSource`/`source.Limits.DailyWeightCap` (per-source daily\n amount) → `DailyTriggerCap`/`source.Limits.DailyCap` (per-source daily\n trigger _count_) → `CooldownSeconds` (per-source, not reset daily) →\n `MaxBalance` (spendable balance ceiling) — any of these can reject the\n grant outright (`EventTokenGrantFailure` reason string). If accepted, the\n amount is then **clamped** (not rejected) by `MaxPerGrant`, remaining\n daily headroom, and remaining balance headroom, in that order\n (`EventTokenService.cs:205-224`) — so a grant can silently apply for less\n than requested near a cap, rather than failing.\n\n`BuildBoardTokenOperations`/`BuildMarketplaceTokenOperations`\n(`TimedEvent.cs:900-995`) are the server-internal helpers other modules\n(GameLoop, Marketplace) use to fan a single gameplay action out to every\nmatching active event — not something client code calls directly, but useful\ncontext for why a single board roll can grant several different event\ntokens at once.\n\n---\n\n## Server-side limits, batching, and idempotency\n\n- **Max batch size: 50** entries per call (`BatchSupport.MaxBatchSize`,\n `IDosGamesSDK/API/Client/v2/_Shared/BatchSupport.cs:35`), enforced\n identically for `ClaimMilestonesBatch`, `SpendTokensBatch`, and\n `GrantTokensBatch` (`TimedEvent.cs:1200,1306,1475,1583`). Entries beyond 50\n are silently dropped during normalization — they never appear in the\n response at all, so chunk larger sets into multiple calls yourself.\n- **Dedup**: `ClaimMilestonesBatch` dedupes by `(instance key)|(MilestoneID)`\n (`TimedEvent.cs:1195-1201`); `SpendTokensBatch` dedupes by instance key\n (`TimedEvent.cs:1470-1477`); `GrantTokensBatch` dedupes by\n `(instance key)|SourceType|Outcome` on input, **and separately rejects a\n second grant to the same resolved token address** within one batch with\n `\"Duplicate event instance in grant batch — send it as a separate\nrequest.\"` (`TimedEvent.cs:1634-1637`) because two grants to one address\n in the same Mongo update would conflict.\n- **Atomicity**: each batch call resolves every entry, then applies **one**\n atomic `ResourceService.ApplyResourceOperationAtomicAsync` for the whole\n batch. For `SpendTokensBatch`/`GrantTokensBatch` this means the _entire_\n batch's resource change succeeds or fails together — a single\n insufficient-balance/over-cap item fails the whole apply and every\n successfully-resolved item in that batch reports the same `Error`\n (`TimedEvent.cs:1518-1552,1659-1695`). Items that failed to even _resolve_\n (bad instance ref, unknown milestone, claim-mode gate) are filtered out\n **before** the atomic apply and get their own independent preset error —\n those don't block the rest of the batch.\n `ClaimMilestonesBatch`/`ClaimAllMilestones` are slightly more granular:\n milestones are grouped **per resolved token address** so multiple\n milestones on the _same_ event instance share one `$push`, but the\n token-threshold/already-claimed check\n (`EventTokenService.ComputeMilestoneClaimBatch`) still runs per address\n before the shared atomic apply, so a milestone that fails its own\n threshold/already-claimed check is rejected independently of the others\n (`TimedEvent.cs:1372-1413`).\n- **Idempotency (`reason` / `RelatedEntityID`)**: every mutating call passes\n a `reason` string to `ApplyResourceOperationAtomicAsync` built from the\n action, the resolved `Type`+`EntityID` (and `MilestoneID`/`sourceKey`\n where relevant), and — for single-item calls — the caller's optional\n `RelatedEntityID` folded in via `ResourceService.ResolveRelatedEntityID`\n (e.g. `\"SpendTokens:spend_{Type}_{EntityID}_{RelatedEntityID}\"`,\n `TimedEvent.cs:484-489,619-624,405-413`). Including `Type` guards against a\n `Scheduled` and `Chained` event that happen to share an `LteID`; including\n the instance-keyed `EntityID` guards against collisions across chain\n instances or across unrelated events reusing the same `RelatedEntityID`\n string (e.g. `\"roll_42\"`). Batch calls build one shared reason from all\n included item keys (`BatchSupport.BuildBatchReason`) rather than one per\n item.\n- **Where `Resources` live in batch responses**: for `SpendTokensBatch`\n /`GrantTokensBatch`, the single merged `ResourceOperation` from the one\n atomic apply is attached to the **first successfully-applied item only**\n (`attached` flag, `TimedEvent.cs:1536-1552,1677-1693`) — every other\n successful item in that batch gets an **empty** `ResourceOperation` in its\n `Data.Resources`. The SDK's `spendTokensBatch`/`grantTokensBatch` already\n account for this: they scan for the first item with a non-empty\n `Resources` and apply that once to the cache\n (`TimedEventService.ts:209-221,238-250`) — don't assume every batch item\n carries its own independent `Resources`/`Rewards` payload; read cache\n balances after the call instead of summing per-item deltas.\n- **Rate limit**: the v2 pipeline's per-IP endpoint limit for\n `TimedEventV2` is 500 ms (`RateLimitMilliseconds`,\n `TimedEvent.cs:18`); per-user/action transaction lock is 10 s\n (`LockDurationMilliseconds`, `TimedEvent.cs:19`). The SDK's own client-side\n throttle is a separate, smaller 600 ms guard per endpoint\n (`packages/core/src/transport/throttle.ts:4`, `DEFAULT_THROTTLE_MS`).\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "user-profile",
3
3
  "description": "Work with the player's own account/session state in the iDosGames TS SDK (@idosgames/core) via client.user (UserService): bootstrap the whole per-player cache at login (ClientState — title config + every module's user state), load the raw inventory snapshot (currencies, items, unstackable instances), read usage-time / session stats, change the username, and delete the account. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants login/session bootstrapping, a profile or account screen, usage-time / playtime tracking, username changes, account deletion, raw inventory reads, or otherwise touches client.user, UserService, ClientState, UserState, UserInventoryState, UsageTimeStats, or client.data.user.state — even if they don't name the module explicitly.",
4
- "content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\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// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
4
+ "content": "---\nname: user-profile\ndescription: >-\n Work with the player's own account/session state in the iDosGames TS SDK\n (@idosgames/core) via client.user (UserService): bootstrap the whole\n per-player cache at login (ClientState — title config + every module's user\n state), load the raw inventory snapshot (currencies, items, unstackable\n instances), read usage-time / session stats, change the username, and delete\n the account. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants login/session\n bootstrapping, a profile or account screen, usage-time / playtime tracking,\n username changes, account deletion, raw inventory reads, or otherwise touches\n client.user, UserService, ClientState, UserState, UserInventoryState,\n UsageTimeStats, or client.data.user.state — even if they don't name the\n module explicitly.\n---\n\n# User profile & session (iDosGames TS SDK)\n\n`UserService` is the root/session module: it has no gameplay concept of its\nown (no \"profile\" entity to level up), and instead owns **the state bootstrap\nthat every other module builds on**. When a player logs in, `UserService` is\nwhat fetches the entire per-player state tree (`ClientState`) and the title's\npublic config in one call, mirrors both into the cache, and only then does the\nrest of the SDK have anything to read. Past login, it also covers a handful of\naccount-level actions that don't belong to any feature module: raw inventory\nreads, usage-time tracking, username changes, and account deletion.\n\nThis skill is for **using** the production `UserService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule —\nsurface the error, don't try to reproduce the check client-side.\n\n## Mental model: ClientState is the trunk, every module is a branch\n\n`client.data.user.state` (type `UserState`) is **one shared object**. Most\nfeature modules (`Character`, `Quest`, `Store`, `Lootbox`, `Reward`,\n`Leaderboard`, `Season`, `Premium`, `Match`, `Collection`, `CoopEvent`,\n`DealOffer`, `Referral`, `Social`, `CustomData`, `GameLoop`, `Blockchain`, …)\nown one key on it and write there through their own service. `UserService`\ndoesn't own most of those keys — it owns the **mechanism that first populates\nthe whole tree**, plus a few keys nobody else claims: `InventoryV2` (the only\none it also refreshes into the cache on its own, via `getUserInventory`),\n`EventToken` (read via `getEventTokens`, result-only), and the ambient\n`UserID` / `PublicData` / `Usage` / `EconomyTuning` fields that ride along on\nthe login `ClientState.User`.\n\n**Two module keys are declared on `UserState` but never populated by\n`getClientState`/`getClientStateExcept`: `TimedBoost` and `Marketplace`.** The\nbackend's `ClientState.User` builder (`UserV2` in `User.cs`) only copies\n`InventoryV2`, `EventToken`, `Premium`, `PublicData`, `Social`, `Quest`,\n`GameLoop`, `Season`, `CoopEvent`, `Collection`, `Lootbox`, `Store`,\n`DealOffer`, `Referral`, `Leaderboard`, `EconomyTuning`, `Usage`,\n`CustomData`, `Blockchain`, `Reward`, `Character`, and `Match` — `TimedBoost`\nand `Marketplace` are absent from both its default field list and its\nfield-copier table, even though the underlying DB document has both. Those\ntwo modules populate their own cache keys exclusively through their own\nfetch calls (`client.timedBoost.getActiveTimedBoosts()` →\n`applyTimedBoost`, `client.marketplace.getMyState()` →\n`applyMarketplaceState`) — never assume `state?.TimedBoost` or\n`state?.Marketplace` is populated just because you called a `ClientState`\nmethod. See each module's own skill for how to load them.\n\n`AuthenticationService` calls `UserService.getClientStateExcept(...)` internally\non every login method (`loginWithDeviceID`, etc.) — you don't normally call\n`getClientState`/`getClientStateExcept` yourself. It's exposed because:\n\n- a mid-session hard refresh (\"resync everything\") is a legitimate thing to\n trigger from a debug menu or a stale-cache recovery path;\n- `getClientStateExcept` lets you refetch everything **except** a field you\n want to preserve (the SDK itself uses this for `GameLoop`, which is loaded\n per-stage by the GameLoop feature and would otherwise get wiped by a\n mid-session state refresh).\n\n### `ClientState.Title` is often absent on the wire — and that is not an error\n\nThe title config is identical for every player and changes rarely, so the SDK\ncaches it across sessions. Each response carries `ClientState.TitleConfigVersion`;\nthe SDK stores it next to the config and sends it back as\n`KnownTitleConfigVersion` on the next call. When it still matches, the backend\n**omits the `Title` key entirely** and only the player state travels.\n\n`UserService` resolves this for you — it re-fills `result.data.Title` from local\nstorage before applying it, so `client.data.config.titlePublicConfiguration` is\nalways populated and nothing in game code changes. What you must **not** do is\nread `Title` straight off a raw envelope you captured yourself (a network log, a\nhand-rolled fetch) and conclude the config is gone.\n\nStorage is `localStorage` with a memory fallback; pass `configStorage` to\n`createIDosGamesClient` to supply your own (React Native, a native shell). Any\nstorage failure degrades to the previous behaviour — a full config download —\nnever to a broken launch. The cached config is public title data, not player\ndata, so it deliberately survives logout.\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// client.data.user.state and client.data.config are already populated here.\n\nconst user = client.user; // the UserService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |\n| `getClientState()` | Fetch the state tree using the backend's **default** field set (which omits `Usage`/`EconomyTuning`/`CustomData`) and replace the cache wholesale. | `ClientState` |\n| `getClientStateExcept(excludeFields?, excludeTitleFields?)` | Fetch **every** field except the named ones, and preserve the current cached value of the named `User.*` / `Title.*` keys instead of overwriting them with the response (used at login to protect `GameLoop`). Prefer this for resyncs. | `ClientState` |\n| `getUserInventory()` | Load this player's raw inventory (currencies, stackable/unstackable items). | `UserInventoryState` |\n| `getEventTokens()` | Load the player's event-token buckets (per-feature token balances, e.g. Quest points). | `UserEventTokensState` |\n| `getUsageTime()` | Load aggregated playtime stats (today/week/month/total, sessions, reactivations). | `UsageTimeStats` |\n| `addUsageTime(usageTime, isNewSession, sessionDurationSeconds)` | Report elapsed foreground time for this session (heartbeat call). | `SuccessResponse` |\n| `changeUsername(username)` | Change the player's username. | `ChangeUsernameResponse` (`Username`) |\n| `deleteUserAccount()` | Permanently delete the player's account. | `SuccessResponse` |\n\nOn success, each method emits an event (see below for exactly which), but only\n`getClientState`/`getClientStateExcept` and `getUserInventory` also write the\ncache — `getEventTokens`, `getUsageTime`, `addUsageTime`, `changeUsername`, and\n`deleteUserAccount` hand you the response and leave `client.data` untouched.\n`addUsageTime`'s request is sent with a\n`silent` transport flag, meaning it won't spam the global error/busy UI on\nfailure the way a user-initiated action would; treat it as a background\nheartbeat, not something you need a dedicated error toast for.\n\n## Reading state and reacting to changes\n\n```ts\n// Whole-tree reads (present after any getClientState* call, i.e. after login):\nconst state = client.data.user.state; // UserState | null\nstate?.UserID;\nstate?.PublicData; // denormalized public profile snapshot (Username, AvatarUrl, Level, Power, ...)\nstate?.Usage; // UserUsageState — server-persisted usage summary (see below)\nstate?.InventoryV2; // present after getClientState* or getUserInventory()\n\n// Title config, populated by the same call:\nimport type { TitlePublicConfigurationModel } from \"@idosgames/core\";\nclient.data.config.titlePublicConfiguration; // TitlePublicConfigurationModel | null\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n**UserService's own events** (emitted directly by the methods above):\n\n- `user:clientStateReceived` → `ClientState` — fires from both\n `getClientState()` and `getClientStateExcept()`.\n- `user:inventoryReceived` → `UserInventoryState`\n- `user:eventTokensReceived` → `UserEventTokensState`\n- `user:usageTimeReceived` → `UsageTimeStats`\n- `user:usageTimeAdded` → `SuccessResponse`\n- `user:accountDeleted` → `SuccessResponse`\n- `user:usernameChanged` → `ChangeUsernameResponse`\n\n**Cache-echo events (not UserService's)**: almost every other event under the\n`user:` prefix is the _shared cache namespace_ firing on writes made by\n**other** modules' services, not by `UserService` — e.g. `user:characterUpdated`\n(CharacterService), `user:questUpdated` (QuestService), `user:storeUpdated`\n(StoreService), `user:lootboxUpdated`, `user:rewardUpdated`,\n`user:timedEventUpdated`, `user:leaderboardUpdated`, `user:seasonUpdated`,\n`user:premiumUpdated`, `user:matchUpdated`, `user:collectionUpdated`,\n`user:coopEventUpdated`, `user:dealOfferUpdated`, `user:referralUpdated`,\n`user:socialUpdated`, `user:timedBoostUpdated`, `user:customDataUpdated`,\n`user:gameLoopUpdated`, `user:blockchainUpdated`, `user:marketplaceUpdated`,\n`user:virtualCurrencyUpdated`, `user:eventTokenUpdated`. Don't document or\ntreat those as UserService methods/events — they belong to their own module's\nskill (or, for the last two, are narrower sub-signals of `user:inventoryUpdated`\nfired by the shared resource-operation apply path).\n\nTwo exceptions genuinely belong to the shared cache itself rather than any one\nmodule:\n\n- `user:stateUpdated` — fires whenever `client.data.user.state` is replaced\n wholesale (i.e. after `applyUserState`, which both `getClientState()` and\n `getClientStateExcept()` trigger internally).\n- `user:anyUpdated` — the umbrella event; fires on **every** cache write from\n **every** module, including all of the above. Good for a single \"re-render\n everything\" hook; too coarse to react to a specific change.\n\n`user:inventoryUpdated` (distinct from `user:inventoryReceived`) also fires\nwhenever inventory changes as a side effect of another module's resource\ncharge/grant (equip, purchase, upgrade, etc.) — not just from\n`getUserInventory()`. Read balances from `client.data.user.state?.InventoryV2`\nrather than assuming only `UserService` writes there.\n\n```ts\nconst off = client.on(\"user:clientStateReceived\", (state) => {\n console.log(\"logged in as\", state.User?.UserID);\n});\n// later: off();\n```\n\n## Recipes\n\n### Bootstrap after login (already done for you)\n\n```ts\nawait client.auth.loginWithDeviceID();\n// AuthenticationService already called getClientStateExcept([\"GameLoop\"], [\"GameLoop\"])\n// internally. client.data.user.state and client.data.config are populated now.\nconst state = client.data.user.state;\n```\n\nYou rarely need to call `getClientState()` / `getClientStateExcept()` yourself\n— only for an explicit \"resync\" action (e.g. a debug/settings-screen\n\"Force refresh\" button) or recovering from a suspected stale cache.\n\n### Force a full resync mid-session, without losing the active board\n\n```ts\nconst res = await client.user.getClientStateExcept([\"GameLoop\"], [\"GameLoop\"]);\nif (!res.ok) return showError(res.error);\n// Every module's User.* state and the title config are now fresh, except\n// GameLoop, which keeps whatever was cached before this call (the response's\n// GameLoop, if any, is discarded and the previous cached value re-applied).\n```\n\n### Show a profile / account screen\n\n```ts\nconst res = await client.user.getUsageTime();\nif (!res.ok) return showError(res.error);\nconst usage = res.data; // UsageTimeStats — Today/CurrentWeek/Total/TotalSessions, seconds\nconst publicData = client.data.user.state?.PublicData;\n\n// publicData.Username / AvatarUrl / Level / Power / BoardRank — denormalized\n// snapshot also used by other modules (leaderboards, social, PvP opponents).\n```\n\nRead the stats off the **result** — `getUsageTime()` does not write the cache.\n`client.data.user.state?.Usage` is a different type (`UserUsageState`, the\npersisted per-day aggregate) and is only as fresh as the last\n`getClientStateExcept` fetch (i.e. login/resync).\n\n### Report playtime (session heartbeat)\n\n```ts\nconst res = await client.user.addUsageTime(\n elapsedSeconds, // usageTime: seconds since the last heartbeat\n isFirstHeartbeatThisSession, // isNewSession\n sessionDurationSeconds, // total session length so far\n);\nif (!res.ok) return; // silent transport call — fail quietly, retry next tick\n```\n\nCall this periodically (e.g. every N seconds of foreground time) rather than\nonce at session end, so playtime survives an unexpected app kill.\n\n### Change username\n\n```ts\nconst res = await client.user.changeUsername(\"NewName123\");\nif (!res.ok) return showError(res.error); // \"INVALID_USERNAME\" — must be 3–24 chars after trimming\nconsole.log(res.data.Username); // the trimmed name the server stored\n```\n\nUsernames are a **display field**, not a login identity: the backend trims the\ninput, checks 3–24 characters, and stores it as-is — there is no uniqueness\ncheck, so two players can share a name. Note the SDK does **not** patch the\ncached `PublicData.Username` after this call — update your UI from\n`res.data.Username` (or re-fetch client state) rather than re-reading the cache.\n\n### Delete account\n\n```ts\nconst res = await client.user.deleteUserAccount();\nif (!res.ok) return showError(res.error);\nclient.auth.logout(); // clear local session/cache after a confirmed deletion\n```\n\nThere's no undo client-side or server-side — gate this behind an explicit\nconfirmation step in the UI; the SDK does not add its own \"are you sure\"\nprompt. The backend does a hard delete of this title's player document\n(matched by `UserID` + `TitleID`) — it removes this game's data for this\nplayer only, not other titles' data for the same platform account.\n\n### Read raw inventory (currencies + items)\n\n```ts\nawait client.user.getUserInventory();\nconst inv = client.data.user.state?.InventoryV2;\ninv?.VirtualCurrencies; // { currencyID: { Amount, Recharge?, Daily? } }\ninv?.CryptoCurrencies; // { currencyID: { Amount, Frozen, ... } } — decimal strings\ninv?.Items; // { itemID: { StackableAmount, UnstackableAmount, TotalAmount } }\ninv?.UnstackableItems; // { itemInstanceID: UnstackableItemInstanceState }\n```\n\nMost feature modules (Item, Character, Store, Lootbox) already keep\n`InventoryV2` current via their own resource-operation cache writes — you only\nneed to call `getUserInventory()` explicitly for an initial/standalone read or\na forced resync of inventory alone (cheaper than a full `getClientState()`).\n\n## Gotchas\n\n- **Don't call login-path methods redundantly.** `getClientStateExcept` runs\n automatically inside every `auth.*` login method. Calling `getClientState()`\n again right after login just re-fetches what you already have.\n- **`getClientStateExcept`'s exclusion is cache-side, not server-side.** The\n server still returns the excluded fields (or doesn't include them — either\n way the SDK ignores what it got back for them); the SDK's `applyClientState`\n re-applies the _previously cached_ value if the fresh response doesn't carry\n one. Use this to protect a key another feature is actively managing\n mid-session (the SDK itself only special-cases `GameLoop` today, but the\n mechanism is generic to any `UserState`/title-config key).\n- **`user:anyUpdated` is too coarse for targeted UI.** It fires on literally\n every cache write from every module. Prefer the specific event\n (`user:clientStateReceived`, `user:inventoryReceived`, a module's own\n `user:<domain>Updated`) unless you genuinely want a blanket re-render.\n- **`state?.TimedBoost` and `state?.Marketplace` are never filled by a\n `ClientState` fetch.** They exist on the `UserState` type, but the backend's\n `GetClientState`/`GetClientStateExcept` builder simply doesn't copy them —\n they're populated only after you call\n `client.timedBoost.getActiveTimedBoosts()` / `client.marketplace.getMyState()`\n at least once. If a profile/debug screen dumps `client.data.user.state` right\n after login, don't be surprised these two keys are missing even though\n everything else is populated.\n- **`addUsageTime` is a `silent` call.** It won't trigger the SDK's global\n error/busy signaling on failure the way a normal action does — build your\n own light retry/backoff for it if playtime accuracy matters, rather than\n relying on a global error handler to surface a problem.\n- **`PublicData` is a snapshot, not live state.** `UserState.PublicData` (and\n the same shape embedded in other modules' responses — leaderboard entries,\n social timeline actors, PvP/raid opponents, coop group members) is a\n denormalized copy taken at write time; it can lag behind the player's own\n live `InventoryV2`/`Character`/etc. Don't use it as a substitute for reading\n your own state.\n- **Crypto amounts are decimal strings.** `InventoryV2.CryptoCurrencies[id].Amount`\n and `.Frozen` are strings, not numbers — use a decimal library (the SDK uses\n `decimal.js` internally) for arithmetic, never native float math.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",