@idosgames/mcp 0.1.1 → 0.1.3

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.
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "quest-system",
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 quest lives either **inside a cycle** (`CycleID` set — dailies, weeklies,\nseasonal) or as a **permanent quest** (no `CycleID` — a one-time or\nalways-available quest, e.g. onboarding). Most methods take an optional/blank\n`CycleID` to address either; the cache keeps them in separate buckets\n(`Quest.Cycles[cycleID]` vs `Quest.PermanentQuests`).\n\nThree distinct reward mechanisms — don't conflate them:\n\n- **Quest reward** — the `Reward` on one `QuestDefinition`, claimed once that\n quest's objectives are all met (`Status: \"Completed\"`), via\n `claimQuestReward`. Moves the quest to `\"Claimed\"`.\n- **Milestone reward** — a rung on a cycle's **points track** (backend/config\n comments call this \"Achievements\"): claiming a quest with `PointsReward > 0`\n 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 `GroupID`\n have reached `\"Completed\"` (not necessarily claimed). Claimed via\n `claimGroupCompletionReward`.\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.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 `CycleIDs` lists every cycle it can appear in; cross-reference\nagainst `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### 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`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- **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- **`RequiredQuestIDs` can gate progress, not just claiming.** A quest's\n `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",
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",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Quest data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nresolution rules, the points-track (\"Achievements\") plumbing, group-completion\nmath, and the server-side limits/idempotency rules. All of these are **strictly\ntyped in the SDK** — `QuestDefinitions` and every nested block (`QuestDefinition`,\n`QuestCycleDefinition`, `QuestObjectiveDefinition`, `QuestGroupCompletionDefinition`,\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- [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}\n```\n\nA quest is **permanent** iff its `CycleIDs` is null/empty; otherwise it is\n**cyclic** and belongs to every cycle listed in `CycleIDs` (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 `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 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\n```ts\ninterface QuestDefinition {\n QuestID?: string;\n CycleIDs?: string[]; // null/empty => permanent; else cyclic, one entry per cycle it appears in\n DisplayName?: string;\n Description?: string;\n SortOrder?: number; // lower = earlier in UI\n\n RequiredQuestIDs?: string[]; // prerequisite QuestIDs — see below\n PrerequisiteMode?: \"BlockProgressAndClaim\" | \"BlockClaimOnly\"; // default BlockProgressAndClaim\n\n PointsReward?: number; // points into the cycle's points track on claim; ignored for permanent quests\n Schedule?: ScheduleSpec; // per-quest unlock window; null = inherit 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-quest per-source caps on POINTS grants only (not on objective progress)\n GroupID?: string; // for UI grouping + QuestGroupCompletionDefinition.GroupID matching\n\n Objectives?: Record<string, QuestObjectiveDefinition>; // key = ObjectiveID; ALL must complete\n Reward?: ResourceGrant; // claimed via claimQuestReward\n}\n```\n\n`PrerequisiteMode` (`QuestDefinition.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 `CycleIDs` entry\n(`ArePrerequisitesMet`, `Quest.cs`). Empty/null `RequiredQuestIDs` = no gating.\n\n---\n\n## QuestObjectiveDefinition + progress aggregation\n\n```ts\ninterface QuestObjectiveDefinition {\n ObjectiveID?: string;\n Source?: \"ClientApi\" | \"ServerApi\" | \"SystemEvent\"; // who may report progress; default SystemEvent\n MaxProgressPerCall?: number; // per-call delta cap (ClientApi abuse guard); 0/absent = no cap\n MetricID?: string; // the metric key addQuestProgress reports against\n TargetValue?: number; // default 1; required value to complete\n AggregationMethod?: string; // \"Sum\" | \"Maximum\" | \"Minimum\" | \"Last\"; default \"Sum\"\n Filters?: Record<string, string>; // optional metric refinement (not enforced client-side)\n}\n```\n\nOnly objectives with `Source: \"ClientApi\"` are reachable from `addQuestProgress`\n— `Source: \"ServerApi\"` / `\"SystemEvent\"` objectives are advanced by other\nbackend systems, never by the client, and a `MetricID` with no matching\n`ClientApi` objective anywhere in the catalog is rejected outright\n(`\"MetricID not allowed for ClientApi\"`).\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`QuestDefinition.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- `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 quest `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.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`QuestDefinition.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 QuestDefinition.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 `CycleIDs` and (b) has `GroupID` equal to the\ncompletion's `GroupID` — that's `totalGroupQuests`. Of those, it counts how many\nhave reached `Status === \"Completed\"` **or** `\"Claimed\"` in the player's current\ncycle state — that's `completedGroupQuests`. The required threshold is\n`RequiredCompletedQuests` if `> 0`, otherwise `totalGroupQuests` (i.e. every\ngroup quest currently in config). Failure modes:\n\n- `RequiredCompletedQuests` unset and the group is empty/misconfigured (no\n quests currently reference that `GroupID` in that cycle) →\n `\"No quests configured for this group\"` (required resolves to `0`, which is\n rejected outright — you can never claim an empty group).\n- `completedGroupQuests < required` → `\"Not enough completed quests for this\ngroup\"`.\n- Already in `cycle.ClaimedGroupCompletionIDs` → `\"Group completion already\nclaimed\"` (checked in-memory before the DB round-trip, then re-enforced by an\n `AnyEq`-negated Mongo filter for the actual OCC guard).\n- `completion.GroupID` blank/whitespace on the definition itself →\n `\"Group completion has no GroupID\"` (a config error, not a player error).\n\nBecause the scan is **live against current config**, removing a quest from the\ngroup (or from the cycle) between when a player completed it and when they\nclaim the group reward can change `totalGroupQuests`/`completedGroupQuests` —\nthere's no snapshot of \"the group as it was.\" The response echoes\n`CompletedGroupQuests` and `RequiredGroupQuests` (the resolved threshold, not\nthe raw config field) so the client can show \"3 / 3\" without recomputing\nanything.\n\n---\n\n## `AddQuestProgress` server-side rules\n\nFull request-to-mutation path (`QuestV2.AddQuestProgress` public entry point +\nthe internal shared helper), summarized because several rules only make sense\ntogether:\n\n1. `MetricID` required; must match at least one `ClientApi`-sourced objective\n in the **entire** quest catalog, or the call fails with `\"MetricID not\nallowed for ClientApi\"` before touching the database.\n2. `ProgressValue` (`long`) must be `>= 0`.\n3. If any matching objective declares `MaxProgressPerCall > 0`, the **raw**\n value is checked against the smallest such cap across all matches; exceeding\n it **bans the account** (see the objective section above) rather than\n clamping — this is a hard security control, not UX guidance.\n4. The (possibly `Sum`-clamped) value then fans out to **every** quest/objective\n pair across **every currently-earning cycle and every permanent quest**\n whose objective's `Source === \"ClientApi\"` and `MetricID` matches — one\n `addQuestProgress` call can move several quests (even across different\n cycles) simultaneously if they all listen to the same metric.\n5. Each matched quest only advances if it isn't already `Completed`/`Claimed`\n (`ApplyToQuestInstance` early-returns `false` otherwise) — so calling\n `addQuestProgress` for an action a player keeps performing after a quest is\n done is safe and a no-op for that quest.\n6. The response's `Updates[]` (`QuestProgressUpdate`) lists **only the\n quest/objective pairs that actually changed** this call — an objective whose\n `Sum` increment was clamped to `0` (already at `TargetValue`) or a locked\n quest that accrued nothing produces no entry.\n7. This whole path calls `RefreshQuestCycles` first (unless the internal helper\n is invoked with `ensureCyclesUpToDate: false`, which the public\n `AddQuestProgress` action does to avoid double-refreshing) — so cycle\n windows are always current before progress is evaluated.\n\n---\n\n## Idempotency, atomicity, batch limits\n\n- **Idempotency keys** (`ResourceService.ResolveRelatedEntityID`, stable ID\n patterns from `Quest.cs`): single quest claim →\n `\"{questID}\"` (permanent) or `\"{questID}_{cycleStartUtc:yyyyMMddHHmmss}\"`\n (cyclic — so the **same** quest claimed again after the cycle rolls to a new\n window is a distinct idempotency key, not a duplicate); milestone claim →\n `\"{cycleID}_{milestoneID}_{instanceKey}\"`; group completion →\n `\"{cycleID}_{completionID}_{cycleStartUtc:yyyyMMddHHmmss}\"`. You never\n construct these yourself — the TS SDK mints its own client-side\n `RelatedEntityID` (`quest_claim_…`, `milestone_claim_…`, `group_completion_…`,\n each suffixed with a fresh UUID) purely for its own request-level tracking;\n the **server-side** idempotency guarantee comes from the stable IDs above\n plus the OCC filter on each claim, not from the client's `RelatedEntityID`.\n- **Atomicity.** Every claim path (`ClaimQuestReward`, `ClaimMilestoneReward`,\n `ClaimGroupCompletionReward`, and both batch variants) runs the reward grant\n and the state-mutating patches (status → `Claimed`, milestone `$push`, group\n `$addToSet`) inside **one** `ResourceService.ApplyResourceOperationAtomicAsync`\n call with an `extraFilter` re-asserting the pre-claim condition (e.g. quest\n `Status == Completed`) — if the grant fails for any reason (insufficient\n server-side room, a concurrent claim already flipped the filter condition,\n etc.) the whole transaction rolls back; there is no partially-applied claim.\n- **Resources in batch responses.** For both `ClaimQuestRewardsBatch` and\n `ClaimMilestoneRewardsBatch`, all included items' rewards are merged into a\n **single** `ResourceBundle` (`BatchSupport.MergeBundles`) and charged/granted\n in one call — the merged `ResourceOperation` is attached to only the **first\n successful** `BatchItemResult.Data.Resources` in the returned array; every\n other successful item's `Data.Resources` is an **empty** `ResourceOperation`\n (`new ResourceOperation()`), not a duplicate of the shared one. Don't sum\n resources across batch items — read them once from wherever they landed (the\n TS SDK's `applyResourceOperation` is only ever called once, on the first\n `Resources` it finds, matching this).\n- **Batch size.** `BatchSupport.MaxBatchSize = 50`. Both\n `claimQuestRewardsBatch` and `claimMilestoneRewardsBatch` accumulate refs from\n your array only up to 50 (after deduping by `CycleID+QuestID` /\n `CycleID+MilestoneID`); anything past the 50th valid, deduped entry is\n **silently dropped** — it never appears in the result array at all, so a\n `results.length` shorter than your input isn't necessarily an error. Chunk\n larger sets yourself.\n- **Batch validity filtering happens before charging.** Each item is\n independently checked (mongo-safety, config existence, gates, schedule\n window, prerequisites, current `Status`) and rejected into a preset\n `BatchItemResult` **before** the shared resource operation runs; only\n surviving items contribute to the merged grant and the combined Mongo filter\n (`AND` of each item's own OCC filter). That combined filter means: if even\n one surviving item's condition is no longer true by the time the transaction\n actually commits (e.g. a race with another request), **the entire merged\n operation fails** and every surviving item in that batch call reports the\n same `apply.Error` — \"partial-aware\" describes the **pre-filtering** stage,\n not protection against a mid-flight race on the shared charge.\n- **Rate limit / lock.** The whole `QuestV2` function uses\n `RateLimitMilliseconds = 500` (per-IP endpoint throttle) and\n `LockDurationMilliseconds = 10000` (per-user-action Mongo transaction lock)\n inside `ClientRun.Execute` — both are backend-side controls independent of\n the TS SDK's own 600ms client-side throttle guard.\n"
8
+ "content": "# Quest data model — reference\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"
9
9
  }
10
10
  ]
11
11
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "title-custom-data",
3
+ "description": "Read title-wide shared data in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.titleCustomData (TitleCustomDataService): the key-value store every player of a title sees — live event state, global counters and progress bars, server-side thresholds, feature flags, remote config. Use this whenever the user wants a value that is the SAME for all players (a server-wide event, a global goal, a kill switch, a balancing knob changed without a rebuild), or touches client.titleCustomData, TitleCustomDataService, GetPublicTitleDataResponse, TitleDataScope or TitleDataBucket — even if they don't name the module. For per-player values use user-custom-data; to WRITE title data at runtime use cloud-code.",
4
+ "content": "---\nname: title-custom-data\ndescription: >-\n Read title-wide shared data in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.titleCustomData (TitleCustomDataService): the\n key-value store every player of a title sees — live event state, global\n counters and progress bars, server-side thresholds, feature flags, remote\n config. Use this whenever the user wants a value that is the SAME for all\n players (a server-wide event, a global goal, a kill switch, a balancing knob\n changed without a rebuild), or touches client.titleCustomData,\n TitleCustomDataService, GetPublicTitleDataResponse, TitleDataScope or\n TitleDataBucket — even if they don't name the module. For per-player values\n use user-custom-data; to WRITE title data at runtime use cloud-code.\n---\n\n# Title custom data (iDosGames TS SDK)\n\n`TitleCustomDataService` is the title-wide key-value store: one set of values\nshared by **every player** of the title. Use it for anything global — the state\nof a live event, a server-wide progress bar, thresholds the server enforces, a\nfeature flag you want to flip without shipping a build.\n\nFor per-player values use **user-custom-data**. Putting a global value in each\nplayer's data means N copies that immediately disagree.\n\n## Mental model: two scopes, two buckets, zero client writes\n\nEvery record has a **scope** (who may write it) and a **bucket** (who may read\nit).\n\n| Scope | Written by | Cached | Typical content |\n| --------- | --------------------------------------------- | ------ | -------------------------------------------------- |\n| `Static` | publisher / AI Coder (title-data admin tools) | yes | authored config: schedules, texts, balancing knobs |\n| `Runtime` | **CloudCode scripts only** | no | live state: counters, current event phase, winners |\n\n| Bucket | Readable by |\n| --------- | --------------------------------- |\n| `Public` | game clients (this service) |\n| `Private` | server code and CloudCode scripts |\n\n**The client has no write path at all** — not a restricted one, none. That is\nthe point: a value every player reads must not be settable by any player. To\nchange title data at runtime, write a CloudCode handler\n(`server.SetTitleCustomData` / `server.IncrementTitleCustomData`) and call it —\nsee **cloud-code**.\n\n`Static` is cached server-side alongside the title config, so an authored change\npropagates within roughly a minute. `Runtime` is never cached: a counter read\nright after a script incremented it is already correct.\n\nValues are always **strings** — JSON-encode structured data yourself.\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 titleData = client.titleCustomData;\n```\n\nEvery method needs an authenticated session; without one they return\n`{ ok: false, reason: \"unauthorized\" }` rather than throwing.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>` — branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args),\n`\"unauthorized\"`, `\"throttled\"`, `\"connection\"`, `\"validation\"`, or `\"server\"`.\n\n| Method | Purpose | `data` on success |\n| -------------------------------------- | ---------------------------------------------------- | ---------------------------- |\n| `getPublicTitleData(useKnownVersion?)` | Read all public title data (both scopes). | `GetPublicTitleDataResponse` |\n| `getPublicTitleDataKeys(keyIDs)` | Read only the listed keys (max 50 per call). | `GetPublicTitleDataResponse` |\n| `getTitleCustomDataDefinitions()` | Load the schema of public keys + the title's limits. | `TitleCustomDataDefinitions` |\n\n`GetPublicTitleDataResponse`:\n\n| Field | Meaning |\n| ---------------- | ----------------------------------------------------------------------------------------- |\n| `Static` | `Record<string, TitleCustomDataRecord>` — authored values. |\n| `Runtime` | `Record<string, TitleCustomDataRecord>` — live values written by scripts. |\n| `StaticVersion` | Change counter of the authored part. |\n| `RuntimeVersion` | Change counter of the live part — the cheap way to detect \"did anything change\". |\n| `NotModified` | `true` when the server skipped the Runtime payload because nothing changed (see polling). |\n\nEach record carries `Value`, `UpdatedAt`, `Version` (per-key write counter),\n`LastWriter` and `ExpiresAt`.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn.\n\n- `titleCustomData:definitionsLoaded` → `TitleCustomDataDefinitions`\n- `titleCustomData:publicDataLoaded` → `GetPublicTitleDataResponse`\n\n## Recipes\n\n### Read the current event state at startup\n\n```ts\nconst res = await client.titleCustomData.getPublicTitleData();\nif (!res.ok) return showError(res.error);\n\nconst phase = res.data.Runtime?.[\"event_phase\"]?.Value ?? \"idle\";\nconst endsAt = res.data.Static?.[\"event_ends_at\"]?.Value; // ISO string you authored\n```\n\n### Poll a global progress bar cheaply\n\n```ts\n// The service remembers the last RuntimeVersion and sends it along; when nothing\n// changed the server answers NotModified and skips the payload. The service still\n// fills `Runtime` from its own cache, so this branch needs no special handling.\nsetInterval(async () => {\n const res = await client.titleCustomData.getPublicTitleData();\n if (!res.ok) return;\n const done = Number(res.data.Runtime?.[\"global_kills\"]?.Value ?? 0);\n renderProgress(done, GOAL);\n}, 15_000);\n```\n\n### Fetch just the two keys a screen needs\n\n```ts\nconst res = await client.titleCustomData.getPublicTitleDataKeys([\n \"event_phase\",\n \"event_multiplier\",\n]);\n```\n\n### Contribute to a global counter (needs a script)\n\nThe client cannot write, so contribution goes through CloudCode:\n\n```ts\n// server side (published with the CloudCode tooling):\n// handlers.contributeKills = function (args, context) {\n// var res = server.IncrementTitleCustomData(\"Public\", \"global_kills\", args.count | 0);\n// if (!res.Success) throw new Error(res.Error);\n// return { total: res.Data.Value };\n// };\n\nconst res = await client.cloudCode.execute(\"contributeKills\", { count: 3 });\nif (res.ok && !res.data.Error) {\n const total = (res.data.FunctionResult as { total: string }).total;\n}\n```\n\n## Gotchas\n\n- **There is no `set…` here, and that is deliberate.** If you find yourself\n wanting one, the value either belongs to the player (`user-custom-data`) or\n must be written by a CloudCode handler.\n- **`Runtime` values change under you.** They are live, shared, and written by\n scripts while the player is looking at them — render from the last read and\n re-read on a cadence; don't cache one at login and treat it as stable.\n- **`NotModified` is a success, not an error.** The service refills `Runtime`\n from its cache, so `res.data.Runtime` is always populated. Pass\n `getPublicTitleData(false)` to force a full payload.\n- **`Private` never reaches the client.** Not filtered out — never read from the\n database on this path. If a value you expect is missing, it is probably in the\n private bucket and only a script can see it.\n- **Registered keys behave better.** A key registered in the title's\n `TitleCustomData` config section gets its scope/bucket pinned, its value type\n validated, a size limit, an optional TTL, and a `DefaultValue` that the server\n materializes on read while the record does not exist yet. Unregistered keys\n work but nothing protects them.\n- **`getPublicTitleDataKeys` caps at 50 keys** and rejects the whole call past\n that — chunk larger reads yourself.\n- **Expired records simply stop appearing.** A key with a TTL is filtered on\n read once `ExpiresAt` passes; the record is purged on the next write.\n- **`config.TitleCustomData` is the schema, not the data.** The title config\n bundle carries the key definitions (scope, bucket, type, limits); the values\n only come from this service.\n",
5
+ "references": []
6
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "title-system",
3
- "description": "Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap config via client.title (TitleService): the full title public configuration bundle, title-wide public custom data, server time, and the standalone currency/item definitions endpoints. Also documents the config-section registry (`client.data.config.getSection<T>(\"Section\")`) that every other module's skill depends on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app boot/init sequences, server time sync, title-wide custom data, or otherwise touches client.title, TitleService, TitlePublicConfigurationModel, getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions / GetItemDefinitions calls — even if they don't name the module explicitly.",
4
- "content": "---\nname: title-system\ndescription: >-\n Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap\n config via client.title (TitleService): the full title public configuration\n bundle, title-wide public custom data, server time, and the standalone\n currency/item definitions endpoints. Also documents the config-section\n registry (`client.data.config.getSection<T>(\"Section\")`) that every other\n module's skill depends on. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app\n boot/init sequences, server time sync, title-wide custom data, or otherwise\n touches client.title, TitleService, TitlePublicConfigurationModel,\n getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions /\n GetItemDefinitions calls — even if they don't name the module explicitly.\n---\n\n# Title system (iDosGames TS SDK)\n\nThe Title module is **not a gameplay feature** — it's title-level bootstrap\nconfig, config only, no per-player state. It's the place you'd fetch server\ntime, title-wide custom data, and a bundled snapshot of most other modules'\n_config_ (`Currency`, `Item`, `Store`, `Quest`, …) in one call. Most screens\ndon't call `client.title` directly for gameplay data; they call the owning\nmodule's own definitions method instead (e.g.\n`client.character.getCharacterDefinitions()`).\n\nDon't confuse this with the real login bootstrap: `client.auth.loginWithDeviceID()`\n(and every other login method) already calls `UserService.getClientStateExcept(...)`\ninternally, which fetches **both** the title config bundle **and** every\nmodule's per-player `User.*` state in one shot — see the user-profile skill.\n`client.title.getTitlePublicConfiguration()` only gets you the config half of\nthat (no `User.*` state), so you rarely need to call it yourself right after\nlogin; it's more useful for an explicit \"refresh config only\" action, or for\nthe couple of things no other module owns (server time, title custom data).\n\nThis skill is for **using** the production `TitleService`, not for porting or\nextending it.\n\n## The config-section registry (read this even if you're here for another module)\n\nEvery module's `Definitions` (config) getter — `getCharacterDefinitions()`,\n`getStoreDefinitions()` inside StoreService, etc. — follows the same pattern:\non a successful fetch, the owning service calls\n`client.data.config.patchSection(\"<SectionKey>\", result.data)`, storing the\nblob in a single `Map<string, unknown>` keyed by a plain string\n(`TitleConfig.sections`). Your UI code reads it back with a type parameter:\n\n```ts\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\n`getSection<T>(key)` is just `sections.get(key) as T | undefined` — **the cast\nis not runtime-validated**, it only recovers the compile-time type; if a\nmodule hasn't fetched its definitions yet, you get `undefined`, not a runtime\nerror. Each module's own skill documents its section key and payload type —\nthis skill only documents the mechanism itself, not any section's contents.\n\nTwo things live outside that generic map, with their own dedicated getters:\n\n- `client.data.config.titlePublicConfiguration` — the full bundle fetched by\n `getTitlePublicConfiguration()` here in Title (see caveat below on which\n fields it actually contains).\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n — dedicated getters (not `getSection`) that fall back from a standalone\n fetch to the bundled value; see below.\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 title = client.title; // the TitleService\n```\n\nEvery method requires an authenticated session — confirmed server-side, not\njust a client-side gate: the backend's shared `ClientRun.Execute` pipeline\nrequires a Bearer `ClientSessionTicket` and runs `ValidateUserSession` for\nevery Title action with no per-action exception, including `GetServerTime`.\nWithout a session, SDK methods return `{ ok: false, reason: \"unauthorized\" }`\nlocally before any network call — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is\none of `\"client\"`, `\"unauthorized\"`, `\"throttled\"` (600 ms default client-side\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`, or\n`\"server\"`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- |\n| `getTitlePublicConfiguration()` | Fetch the title config bundle (see field-subset caveat below). | `TitlePublicConfigurationModel` |\n| `getPublicTitleCustomData()` | Fetch title-wide public custom data (arbitrary tenant data, not tied to any feature module). | `TitleCustomDataResponse` (`PublicData`) |\n| `getCurrencyDefinitions()` | Fetch just the currency catalog, standalone. | `CurrencyDefinitions` |\n| `getItemDefinitions()` | Fetch just the item catalog, standalone. | `ItemDefinitions` |\n| `getServerTime()` | Fetch authoritative server time. | `SuccessResponse` (`ServerTime`, `IsCompleted`) |\n\nNone of these take parameters — every one builds its request from\n`buildAuthedBaseRequest()` only (`TitleService.ts`'s private `baseRequest()`\nnever fills in any extra field). On success, each method mirrors its result\ninto the cache and emits an event — you don't apply anything by hand.\n\n### `getTitlePublicConfiguration()` returns a fixed field subset, not everything\n\nThe backend's `TitleRequest` supports `Fields`/`ExcludeFields` (and a second\naction, `GetTitlePublicConfigurationExcept`, for the exclude-list variant),\nbut the TS `TitleService.getTitlePublicConfiguration()` never populates\neither — it always calls the plain `GetTitlePublicConfiguration` action with\nan empty field list, which the backend then defaults to its own hard-coded\nsubset (`_defaultFields` in `Title.cs`). As of this writing that subset is:\n`ImageData`, `AssetBundle`, `Currency`, `Item`, `Premium`, `Reward`,\n`TimedEvent`, `CoopEvent`, `Leaderboard`, `Season`, `Collection`, `Craft`,\n`Lootbox`, `Store`, `DealOffer`, `Quest`, `Referral`, `Blockchain`,\n`Multiplayer`.\n\n**`UserCustomData`, `GameLoop`, and `Character` are deliberately left out** of\nthat default list (commented out in the backend source) — calling\n`getTitlePublicConfiguration()` will **not** populate\n`titlePublicConfiguration.Character` or `.GameLoop`, even though those fields\nexist on the `TitlePublicConfigurationModel` TypeScript type. For those three,\ncall the owning module's own `getXDefinitions()` instead (e.g.\n`client.character.getCharacterDefinitions()`), which patches its own section\nindependently of this bundle. `Match` is absent from the backend's field\nlist entirely — it is never returned by this endpoint under any field\nselection, standalone or bundled; use `client.match`'s own definitions call.\nThere is currently no TS-level way to request a different field subset or the\n\"except\" variant; if you need that, it would require extending\n`TitleService`/`TitleApi` to pass `Fields`/`ExcludeFields` through.\n\n## Currency/Item definitions: this module overlaps with currency-system and item-system\n\n`getCurrencyDefinitions()` and `getItemDefinitions()` live on `TitleService`,\nnot on `CurrencyService` or `ItemService` — as of this writing neither of\nthose modules exposes its own definitions-fetch method. If you need the\ncurrency or item catalog, this is currently the only place to get it\nstandalone (or via the full `getTitlePublicConfiguration()` bundle, which\nembeds both under `.Currency` / `.Item` — these two, unlike Character/GameLoop,\n_are_ in the default field subset). For everything else about currencies and\nitems (balances, granting, converting, upgrading item instances, equipping),\nsee the currency-system and item-system skills — this skill only covers\n_fetching the catalog_, not consuming it.\n\nCaching nuance: `getTitlePublicConfiguration()` stores the full bundle\nseparately from the standalone fetches. Reading order:\n\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n return the standalone-fetched value if you've called\n `getCurrencyDefinitions()` / `getItemDefinitions()`, falling back to the\n value embedded in the full bundle (`titlePublicConfiguration.Currency` /\n `.Item`) otherwise. A standalone fetch **overrides** the bundled value in\n this getter, it doesn't merge with it.\n- The full bundle itself is read via\n `client.data.config.titlePublicConfiguration` (not `getSection`).\n\n## Reading state and reacting to changes\n\n```ts\n// Full bundle (only present after getTitlePublicConfiguration()):\nconst cfg = client.data.config.titlePublicConfiguration;\ncfg?.Currency; // CurrencyDefinitions — in the default field subset\ncfg?.TitleCustomData; // { PublicData, PrivateData }\n// cfg?.Character / cfg?.GameLoop are NOT populated by this call — see above.\n\n// Title-wide custom data (only present after getPublicTitleCustomData()):\nconst customData =\n client.data.config.getSection<TitleCustomDataResponse>(\"TitleCustomData\");\n\n// Standalone currency/item catalogs (prefer these getters over reading the bundle directly):\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `title:publicConfigurationReceived` → `TitlePublicConfigurationModel`\n- `title:publicCustomDataReceived` → `TitleCustomDataResponse`\n- `title:currencyDefinitionsReceived` → `CurrencyDefinitions`\n- `title:itemDefinitionsReceived` → `ItemDefinitions`\n- `title:serverTimeReceived` → `SuccessResponse`\n\nThere is no coarse `title:*Updated` / `user:anyUpdated`-style event for this\nmodule — Title carries no per-player state, so none of the `user:*Updated`\nevents fire from it either.\n\n```ts\nconst off = client.on(\"title:serverTimeReceived\", (r) => {\n console.log(\"server time:\", r.ServerTime);\n});\n// later: off();\n```\n\n## Recipes\n\n### App boot: load config alongside login\n\n```ts\nawait client.auth.loginWithDeviceID();\n// Login already fetched Title + User state via getClientStateExcept internally.\n// Call this again only when you explicitly want a config-only refresh:\nconst res = await client.title.getTitlePublicConfiguration();\nif (!res.ok) return showError(res.error);\n\nconst cfg = client.data.config.titlePublicConfiguration;\n// cfg.Currency, cfg.Item, cfg.Store, cfg.Quest, ... are populated.\n// cfg.Character / cfg.GameLoop are NOT — fetch those from their own modules.\n```\n\nUse this when you want a fresh, config-only round-trip after boot (e.g. a\n\"refresh config\" debug action, or recovering from a stale cache) — not as\nyour primary boot path, since login already populated the same cache slot.\n\n### Fetch just the currency/item catalog\n\n```ts\nconst currencies = await client.title.getCurrencyDefinitions();\nconst items = await client.title.getItemDefinitions();\nif (!currencies.ok || !items.ok) return; // handle each independently\n\n// Prefer these getters — they resolve standalone-fetch-overrides-bundle for you:\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nUse this when you only need currencies/items and don't want the full bundle\n— e.g. a store screen that boots faster by skipping Quest/Reward/etc.\n\n### Title-wide custom data\n\n```ts\nconst res = await client.title.getPublicTitleCustomData();\nif (!res.ok) return showError(res.error);\nres.data.PublicData; // Record<string, TitlePublicData>, each { Data, SchemaVersion, UpdatedAt }\n```\n\nThis is free-form tenant-level data (announcements, feature flags, remote\nconfig-style values) — not tied to any single gameplay module. `Data` is a\nraw string; parse it yourself (e.g. JSON) per your title's convention.\n\n### Sync server time\n\n```ts\nconst res = await client.title.getServerTime();\nif (!res.ok) return showError(res.error);\nconst offsetMs = new Date(res.data.ServerTime).getTime() - Date.now();\n// Apply offsetMs when rendering countdowns driven by server-stamped\n// ExpiresAtUtc/StartUtc fields (timed boosts, timed events, offers, etc.),\n// so a skewed device clock doesn't show a wrong countdown.\n```\n\n`res.data.ServerTime` is the backend's UTC clock read at the moment the\nrequest was handled — it is not cached or memoized server-side, each call\nreflects \"now.\"\n\n### Reading any other module's config once it's loaded\n\n```ts\nimport type { CharacterDefinitions } from \"@idosgames/core\";\n\nawait client.character.getCharacterDefinitions(); // fetch + patchSection(\"Character\", ...)\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nThis is the pattern every other module's skill in this repo uses without\nre-explaining it: fetch via that module's own service method, then read back\nthrough `getSection<T>(\"<ThatModule'sSectionKey>\")`. The section key string is\ndocumented per-module (usually the module's own PascalCase name, e.g.\n`\"Character\"`, `\"Quest\"`, `\"Store\"`) — check that module's skill for the exact\nkey and payload shape.\n\n## Gotchas\n\n- **This is config, not gameplay state.** Nothing here is per-player\n progression — there's no \"upgrade\" or \"grant\" method in this module.\n Render from the cache; there's no user-state mirror to reconcile, and no\n `user:*Updated` event fires from Title.\n- **`getTitlePublicConfiguration()` silently omits `Character`, `GameLoop`,\n and `UserCustomData`**, and can never return `Match` at all — see the\n dedicated section above. Don't assume the bundle is a complete snapshot of\n every module; check the field list before relying on a section being\n present in `titlePublicConfiguration`.\n- **Two ways to get currency/item definitions, one source of truth.** The\n standalone `getCurrencyDefinitions()`/`getItemDefinitions()` calls and the\n bundled `getTitlePublicConfiguration()` both hit the same backend catalog —\n don't treat them as independently-versioned. Calling both is harmless but\n redundant; the standalone fetch simply overrides the getter's fallback.\n- **`getPublicTitleCustomData()` is cached under its own section key**\n (`\"TitleCustomData\"`), separate from `TitleCustomData` embedded in the full\n bundle — read it via `getSection`, not off `titlePublicConfiguration`, to\n get the freshest standalone fetch.\n- **Config can be up to ~60 seconds stale.** The backend serves this bundle\n from a process-wide in-memory cache, invalidated by a Redis version counter\n that's itself re-checked at most once every 60 seconds per title. A config\n change made in a title's admin panel isn't guaranteed to be visible to a\n running client instantly — don't build a \"config just changed, refresh now\"\n UX that assumes sub-second propagation.\n- **`getServerTime()` isn't a lightweight unauthenticated ping.** It goes\n through the same full pipeline as every other v2 endpoint: Bearer session\n validation and the title-active/BuildKey check both run before the handler\n executes. A banned/inactive title or an expired session rejects it exactly\n like any other Title call — it will not quietly succeed as a health check\n when the title itself is down.\n- **No batch methods, no write methods.** Every method here is a read; there\n is nothing to guard against double-submit charges for.\n",
3
+ "description": "Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap config via client.title (TitleService): the full title public configuration bundle, server time, and the standalone currency/item definitions endpoints. Also documents the config-section registry (`client.data.config.getSection<T>(\"Section\")`) that every other module's skill depends on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app boot/init sequences or server time sync, or otherwise touches client.title, TitleService, TitlePublicConfigurationModel, getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions / GetItemDefinitions calls — even if they don't name the module explicitly.",
4
+ "content": "---\nname: title-system\ndescription: >-\n Fetch the iDosGames TypeScript SDK (@idosgames/core) title-level bootstrap\n config via client.title (TitleService): the full title public configuration\n bundle, server time, and the standalone currency/item definitions endpoints.\n Also documents the config-section registry (`client.data.config.getSection<T>(\"Section\")`) that every other\n module's skill depends on. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants app\n boot/init sequences or server time sync, or otherwise touches client.title,\n TitleService, TitlePublicConfigurationModel, getTitlePublicConfiguration, or the title-level GetCurrencyDefinitions /\n GetItemDefinitions calls — even if they don't name the module explicitly.\n---\n\n# Title system (iDosGames TS SDK)\n\nThe Title module is **not a gameplay feature** — it's title-level bootstrap\nconfig, config only, no per-player state. It's the place you'd fetch server\ntime and a bundled snapshot of most other modules'\n_config_ (`Currency`, `Item`, `Store`, `Quest`, …) in one call. Most screens\ndon't call `client.title` directly for gameplay data; they call the owning\nmodule's own definitions method instead (e.g.\n`client.character.getCharacterDefinitions()`).\n\nDon't confuse this with the real login bootstrap: `client.auth.loginWithDeviceID()`\n(and every other login method) already calls `UserService.getClientStateExcept(...)`\ninternally, which fetches **both** the title config bundle **and** every\nmodule's per-player `User.*` state in one shot — see the user-profile skill.\n`client.title.getTitlePublicConfiguration()` only gets you the config half of\nthat (no `User.*` state), so you rarely need to call it yourself right after\nlogin; it's more useful for an explicit \"refresh config only\" action, or for\nthe couple of things no other module owns (server time, the config bundle).\n\nThis skill is for **using** the production `TitleService`, not for porting or\nextending it.\n\n## The config-section registry (read this even if you're here for another module)\n\nEvery module's `Definitions` (config) getter — `getCharacterDefinitions()`,\n`getStoreDefinitions()` inside StoreService, etc. — follows the same pattern:\non a successful fetch, the owning service calls\n`client.data.config.patchSection(\"<SectionKey>\", result.data)`, storing the\nblob in a single `Map<string, unknown>` keyed by a plain string\n(`TitleConfig.sections`). Your UI code reads it back with a type parameter:\n\n```ts\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\n`getSection<T>(key)` is just `sections.get(key) as T | undefined` — **the cast\nis not runtime-validated**, it only recovers the compile-time type; if a\nmodule hasn't fetched its definitions yet, you get `undefined`, not a runtime\nerror. Each module's own skill documents its section key and payload type —\nthis skill only documents the mechanism itself, not any section's contents.\n\nTwo things live outside that generic map, with their own dedicated getters:\n\n- `client.data.config.titlePublicConfiguration` — the full bundle fetched by\n `getTitlePublicConfiguration()` here in Title (see caveat below on which\n fields it actually contains).\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n — dedicated getters (not `getSection`) that fall back from a standalone\n fetch to the bundled value; see below.\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 title = client.title; // the TitleService\n```\n\nEvery method requires an authenticated session — confirmed server-side, not\njust a client-side gate: the backend's shared `ClientRun.Execute` pipeline\nrequires a Bearer `ClientSessionTicket` and runs `ValidateUserSession` for\nevery Title action with no per-action exception, including `GetServerTime`.\nWithout a session, SDK methods return `{ ok: false, reason: \"unauthorized\" }`\nlocally before any network call — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`. `reason` is\none of `\"client\"`, `\"unauthorized\"`, `\"throttled\"` (600 ms default client-side\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`, or\n`\"server\"`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------- | -------------------------------------------------------------- | ----------------------------------------------- |\n| `getTitlePublicConfiguration()` | Fetch the title config bundle (see field-subset caveat below). | `TitlePublicConfigurationModel` |\n| `getCurrencyDefinitions()` | Fetch just the currency catalog, standalone. | `CurrencyDefinitions` |\n| `getItemDefinitions()` | Fetch just the item catalog, standalone. | `ItemDefinitions` |\n| `getServerTime()` | Fetch authoritative server time. | `SuccessResponse` (`ServerTime`, `IsCompleted`) |\n\nNone of these take parameters — every one builds its request from\n`buildAuthedBaseRequest()` only (`TitleService.ts`'s private `baseRequest()`\nnever fills in any extra field). On success, each method mirrors its result\ninto the cache and emits an event — you don't apply anything by hand.\n\n### `getTitlePublicConfiguration()` returns a fixed field subset, not everything\n\nThe backend's `TitleRequest` supports `Fields`/`ExcludeFields` (and a second\naction, `GetTitlePublicConfigurationExcept`, for the exclude-list variant),\nbut the TS `TitleService.getTitlePublicConfiguration()` never populates\neither — it always calls the plain `GetTitlePublicConfiguration` action with\nan empty field list, which the backend then defaults to its own hard-coded\nsubset (`_defaultFields` in `Title.cs`). As of this writing that subset is:\n`ImageData`, `AssetBundle`, `Currency`, `Item`, `Premium`, `Reward`,\n`TimedEvent`, `CoopEvent`, `Leaderboard`, `Season`, `Collection`, `Craft`,\n`Lootbox`, `Store`, `DealOffer`, `Quest`, `Referral`, `Blockchain`,\n`Multiplayer`.\n\n**`UserCustomData`, `GameLoop`, and `Character` are deliberately left out** of\nthat default list (commented out in the backend source) — calling\n`getTitlePublicConfiguration()` will **not** populate\n`titlePublicConfiguration.Character` or `.GameLoop`, even though those fields\nexist on the `TitlePublicConfigurationModel` TypeScript type. For those three,\ncall the owning module's own `getXDefinitions()` instead (e.g.\n`client.character.getCharacterDefinitions()`), which patches its own section\nindependently of this bundle. `Match` is absent from the backend's field\nlist entirely — it is never returned by this endpoint under any field\nselection, standalone or bundled; use `client.match`'s own definitions call.\nThere is currently no TS-level way to request a different field subset or the\n\"except\" variant; if you need that, it would require extending\n`TitleService`/`TitleApi` to pass `Fields`/`ExcludeFields` through.\n\n## Currency/Item definitions: this module overlaps with currency-system and item-system\n\n`getCurrencyDefinitions()` and `getItemDefinitions()` live on `TitleService`,\nnot on `CurrencyService` or `ItemService` — as of this writing neither of\nthose modules exposes its own definitions-fetch method. If you need the\ncurrency or item catalog, this is currently the only place to get it\nstandalone (or via the full `getTitlePublicConfiguration()` bundle, which\nembeds both under `.Currency` / `.Item` — these two, unlike Character/GameLoop,\n_are_ in the default field subset). For everything else about currencies and\nitems (balances, granting, converting, upgrading item instances, equipping),\nsee the currency-system and item-system skills — this skill only covers\n_fetching the catalog_, not consuming it.\n\nCaching nuance: `getTitlePublicConfiguration()` stores the full bundle\nseparately from the standalone fetches. Reading order:\n\n- `client.data.config.currencyDefinitions` / `client.data.config.itemDefinitions`\n return the standalone-fetched value if you've called\n `getCurrencyDefinitions()` / `getItemDefinitions()`, falling back to the\n value embedded in the full bundle (`titlePublicConfiguration.Currency` /\n `.Item`) otherwise. A standalone fetch **overrides** the bundled value in\n this getter, it doesn't merge with it.\n- The full bundle itself is read via\n `client.data.config.titlePublicConfiguration` (not `getSection`).\n\n## Reading state and reacting to changes\n\n```ts\n// Full bundle (only present after getTitlePublicConfiguration()):\nconst cfg = client.data.config.titlePublicConfiguration;\ncfg?.Currency; // CurrencyDefinitions — in the default field subset\ncfg?.TitleCustomData; // TitleCustomDataDefinitions — the title-data key SCHEMA, not its values\n// cfg?.Character / cfg?.GameLoop are NOT populated by this call — see above.\n\n// Standalone currency/item catalogs (prefer these getters over reading the bundle directly):\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `title:publicConfigurationReceived` → `TitlePublicConfigurationModel`\n- `title:currencyDefinitionsReceived` → `CurrencyDefinitions`\n- `title:itemDefinitionsReceived` → `ItemDefinitions`\n- `title:serverTimeReceived` → `SuccessResponse`\n\nThere is no coarse `title:*Updated` / `user:anyUpdated`-style event for this\nmodule — Title carries no per-player state, so none of the `user:*Updated`\nevents fire from it either.\n\n```ts\nconst off = client.on(\"title:serverTimeReceived\", (r) => {\n console.log(\"server time:\", r.ServerTime);\n});\n// later: off();\n```\n\n## Recipes\n\n### App boot: load config alongside login\n\n```ts\nawait client.auth.loginWithDeviceID();\n// Login already fetched Title + User state via getClientStateExcept internally.\n// Call this again only when you explicitly want a config-only refresh:\nconst res = await client.title.getTitlePublicConfiguration();\nif (!res.ok) return showError(res.error);\n\nconst cfg = client.data.config.titlePublicConfiguration;\n// cfg.Currency, cfg.Item, cfg.Store, cfg.Quest, ... are populated.\n// cfg.Character / cfg.GameLoop are NOT — fetch those from their own modules.\n```\n\nUse this when you want a fresh, config-only round-trip after boot (e.g. a\n\"refresh config\" debug action, or recovering from a stale cache) — not as\nyour primary boot path, since login already populated the same cache slot.\n\n### Fetch just the currency/item catalog\n\n```ts\nconst currencies = await client.title.getCurrencyDefinitions();\nconst items = await client.title.getItemDefinitions();\nif (!currencies.ok || !items.ok) return; // handle each independently\n\n// Prefer these getters — they resolve standalone-fetch-overrides-bundle for you:\nconst currencyDefs = client.data.config.currencyDefinitions;\nconst itemDefs = client.data.config.itemDefinitions;\n```\n\nUse this when you only need currencies/items and don't want the full bundle\n— e.g. a store screen that boots faster by skipping Quest/Reward/etc.\n\n### Title-wide custom data lives in its own module\n\nValues shared by all players (announcements, feature flags, event state, global\ncounters) are **not** part of this bundle: `cfg.TitleCustomData` is only the key\nschema. Read the values with `client.titleCustomData` — see **title-custom-data**.\n\n### Sync server time\n\n```ts\nconst res = await client.title.getServerTime();\nif (!res.ok) return showError(res.error);\nconst offsetMs = new Date(res.data.ServerTime).getTime() - Date.now();\n// Apply offsetMs when rendering countdowns driven by server-stamped\n// ExpiresAtUtc/StartUtc fields (timed boosts, timed events, offers, etc.),\n// so a skewed device clock doesn't show a wrong countdown.\n```\n\n`res.data.ServerTime` is the backend's UTC clock read at the moment the\nrequest was handled — it is not cached or memoized server-side, each call\nreflects \"now.\"\n\n### Reading any other module's config once it's loaded\n\n```ts\nimport type { CharacterDefinitions } from \"@idosgames/core\";\n\nawait client.character.getCharacterDefinitions(); // fetch + patchSection(\"Character\", ...)\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nThis is the pattern every other module's skill in this repo uses without\nre-explaining it: fetch via that module's own service method, then read back\nthrough `getSection<T>(\"<ThatModule'sSectionKey>\")`. The section key string is\ndocumented per-module (usually the module's own PascalCase name, e.g.\n`\"Character\"`, `\"Quest\"`, `\"Store\"`) — check that module's skill for the exact\nkey and payload shape.\n\n## Gotchas\n\n- **This is config, not gameplay state.** Nothing here is per-player\n progression — there's no \"upgrade\" or \"grant\" method in this module.\n Render from the cache; there's no user-state mirror to reconcile, and no\n `user:*Updated` event fires from Title.\n- **`getTitlePublicConfiguration()` silently omits `Character`, `GameLoop`,\n and `UserCustomData`**, and can never return `Match` at all — see the\n dedicated section above. Don't assume the bundle is a complete snapshot of\n every module; check the field list before relying on a section being\n present in `titlePublicConfiguration`.\n- **Two ways to get currency/item definitions, one source of truth.** The\n standalone `getCurrencyDefinitions()`/`getItemDefinitions()` calls and the\n bundled `getTitlePublicConfiguration()` both hit the same backend catalog —\n don't treat them as independently-versioned. Calling both is harmless but\n redundant; the standalone fetch simply overrides the getter's fallback.\n- **Config can be up to ~60 seconds stale.** The backend serves this bundle\n from a process-wide in-memory cache, invalidated by a Redis version counter\n that's itself re-checked at most once every 60 seconds per title. A config\n change made in a title's admin panel isn't guaranteed to be visible to a\n running client instantly — don't build a \"config just changed, refresh now\"\n UX that assumes sub-second propagation.\n- **`getServerTime()` isn't a lightweight unauthenticated ping.** It goes\n through the same full pipeline as every other v2 endpoint: Bearer session\n validation and the title-active/BuildKey check both run before the handler\n executes. A banned/inactive title or an expired session rejects it exactly\n like any other Title call — it will not quietly succeed as a health check\n when the title itself is down.\n- **No batch methods, no write methods.** Every method here is a read; there\n is nothing to guard against double-submit charges for.\n",
5
5
  "references": []
6
6
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "user-custom-data",
3
3
  "description": "Build a generic per-player key-value data store in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.userCustomData (UserCustomDataService): set/get/delete private (only-you-readable) and public (readable-by-others) string keys, batch set/delete many keys atomically, batch-read public data for many players at once, and load the title's schema-managed key registry. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants player settings/preferences storage, profile flair or badges visible to other players, arbitrary save-data slots, or otherwise touches client.userCustomData, UserCustomDataService, UserCustomDataModels, CustomDataBucket, or UserCustomDataRecord — even if they don't name the module explicitly.",
4
- "content": "---\nname: user-custom-data\ndescription: >-\n Build a generic per-player key-value data store in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.userCustomData\n (UserCustomDataService): set/get/delete private (only-you-readable) and\n public (readable-by-others) string keys, batch set/delete many keys\n atomically, batch-read public data for many players at once, and load the\n title's schema-managed key registry. Use this whenever the user is working\n in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and\n wants player settings/preferences storage, profile flair or badges visible\n to other players, arbitrary save-data slots, or otherwise touches\n client.userCustomData, UserCustomDataService, UserCustomDataModels,\n CustomDataBucket, or UserCustomDataRecord — even if they don't name the\n module explicitly.\n---\n\n# User custom data (iDosGames TS SDK)\n\nUserCustomData is a generic per-player key-value store: arbitrary string\nvalues under string keys, split into buckets by visibility. It's\nself-contained — no coupling to currencies, items, or any other economy\nmodule, and no resource cost is ever charged for using it. Use it for\nanything that doesn't fit a purpose-built module: player settings, UI\npreferences, cosmetic flair shown on a profile, small save-data blobs,\nfeature flags per player, etc.\n\nEverything is **server-authoritative**: the backend owns bucket assignment,\nsize/count limits, and (for schema-registered keys) value-format validation.\nThis skill is for **using** the production `UserCustomDataService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (key not registered, value too large/wrong format, bucket full) —\nsurface the error, don't try to reproduce the check client-side.\n\n## Buckets\n\nEvery key lives in exactly one bucket (`CustomDataBucket`), and the bucket\ndecides both who can read it and who can write it:\n\n- **`Private`** — readable only by the owning player. Set with\n `setPrivateData`.\n- **`Public`** — readable by other players (e.g. via\n `getPublicUserCustomDataOf`). Set with `setPublicData`.\n- **`ReadOnly`** — server-written only (e.g. another backend action marking a\n tutorial step done). The client can read it — it shows up in\n `getMyUserCustomData()`'s `ReadOnly` map and in the cached\n `CustomData.ReadOnly` — but the client SDK exposes no write method for it.\n- **`Internal`** — never returned to the client in any form, by any endpoint\n (including cross-player reads). Backend jobs/admin/analytics only; treat it\n as fully invisible.\n\nOnly `Private` and `Public` are writable/deletable from the client\n(`setPrivateData`/`setPublicData`/`deleteKey`/batch variants all reject other\nbuckets with a client-side validation error before hitting the network — and\nthe backend independently enforces the same restriction).\n\nValues are always **strings**. If you need structured data, JSON-encode it\nyourself. The config registry has a `Json` `ValueType` hint — for\nschema-registered keys the **backend validates** that the string parses as\nthe declared type (`Int`/`Bool`/`Json`) on every write — but the SDK itself\ndoes not parse, validate, or decode it for you; see\n[references/data-model.md](references/data-model.md).\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 customData = client.userCustomData; // the UserCustomDataService\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 — empty/malformed key, empty batch), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the client-side throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. unregistered key, value type/size limit,\nbucket full, too many batch items).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- |\n| `getUserCustomDataDefinitions()` | Load the title's key registry/config. | `UserCustomDataDefinitions` |\n| `getMyUserCustomData()` | Load all of this player's data (all buckets). | `GetMyUserCustomDataResponse` |\n| `getPublicUserCustomDataOf(targetUserID)` | Read another player's `Public` bucket only. | `GetPublicUserCustomDataResponse` |\n| `setPrivateData(keyID, value)` | Set/overwrite one key in `Private`. | `SetUserCustomDataResponse` |\n| `setPublicData(keyID, value)` | Set/overwrite one key in `Public`. | `SetUserCustomDataResponse` |\n| `deleteKey(keyID, bucket)` | Delete one key (`Private` or `Public` only). | `SuccessResponse` |\n| `batchSet(items)` | Set many keys (any mix of buckets) atomically. | `BatchSetUserCustomDataResponse` (`Results`) |\n| `batchDelete(items)` | Delete many keys atomically. | `BatchDeleteUserCustomDataResponse` (`DeletedCount`) |\n| `batchGetPublicUserCustomDataOf(targetUserIDs)` | Read `Public` bucket for many players in one call. | `BatchGetPublicUserCustomDataResponse` |\n\nKey rules (checked client-side before any request, and re-checked\nserver-side): a `KeyID` must be non-empty and must not contain `\".\"` or `\"$\"`\n(MongoDB path-safety rule, same convention as Character/Item IDs elsewhere in\nthe SDK).\n\nOn success, the single-key and batch **write/delete** methods mirror the\nconfirmed change into the cache and emit an event. `getPublicUserCustomDataOf`\nand `batchGetPublicUserCustomDataOf` (reading _another_ player's data) do\n**not** touch the cache — there's nothing local to patch since it's someone\nelse's data; treat their results as transient render data.\n\n## Reading state and reacting to changes\n\n```ts\nconst cd = client.data.user.state?.CustomData;\ncd?.Version; // increments on every local write/delete (client-side change counter)\ncd?.Private?.[\"settings\"]?.Value; // string | undefined\ncd?.Public?.[\"title\"]?.Value;\ncd?.ReadOnly?.[\"serverFlag\"]?.Value; // written server-side only\n\n// Config (registry of known keys, if the title schema-manages them):\nimport type { UserCustomDataDefinitions } from \"@idosgames/core\";\nconst defs =\n client.data.config.getSection<UserCustomDataDefinitions>(\"UserCustomData\");\n```\n\nEach `UserCustomDataRecord` also carries `UpdatedAt`, `Version` (a per-key\ncounter that increments every time that specific key is overwritten —\nunrelated to the bucket-wide `CustomData.Version` change counter),\n`LastWriter` (`\"Client\" | \"Server\" | \"System\"`), and `ExpiresAt` (if the key\nhas a TTL).\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `userCustomData:definitionsLoaded` → `UserCustomDataDefinitions`\n- `userCustomData:myDataLoaded` → `GetMyUserCustomDataResponse`\n- `userCustomData:publicDataLoaded` → `GetPublicUserCustomDataResponse` (cache untouched)\n- `userCustomData:privateDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:publicDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:keyDeleted` → `void`\n- `userCustomData:batchSet` → `BatchSetUserCustomDataResponse`\n- `userCustomData:batchDeleted` → `BatchDeleteUserCustomDataResponse`\n- `userCustomData:batchPublicDataLoaded` → `BatchGetPublicUserCustomDataResponse` (cache untouched)\n\nThe coarse `user:customDataUpdated` (and `user:anyUpdated`) also fire on every\nlocal write/delete — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"userCustomData:privateDataSet\", (r) => {\n console.log(`${r.KeyID} saved at version ${r.Version}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Save and read a player setting privately\n\n```ts\nconst res = await client.userCustomData.setPrivateData(\"theme\", \"dark\");\nif (!res.ok) return showError(res.error);\n\n// later, anywhere in the app:\nconst theme = client.data.user.state?.CustomData?.Private?.[\"theme\"]?.Value;\n```\n\n### Bootstrap all of the player's own data at login\n\n```ts\nawait client.userCustomData.getMyUserCustomData();\nconst cd = client.data.user.state?.CustomData;\n// cd.Private / cd.Public / cd.ReadOnly are now populated from the server.\n// If a schema-registered key has a DefaultValue and the player has never set\n// it, the server materializes that default in the response (Version: 0,\n// LastWriter: \"System\") without writing it to the DB — treat it as a real\n// display value, just don't expect a subsequent read to differ before you Set it.\n```\n\n### Expose a profile badge publicly\n\n```ts\nconst res = await client.userCustomData.setPublicData(\"title\", \"Dragon Slayer\");\nif (!res.ok) return showError(res.error);\n\n// anyone can now read it:\nconst other =\n await client.userCustomData.getPublicUserCustomDataOf(\"player-42\");\nif (other.ok) {\n const badge = other.data.Public?.[\"title\"]?.Value;\n}\n```\n\n### Batch-set several keys in one atomic call\n\n```ts\nimport { CustomDataBucket } from \"@idosgames/core\";\n\nconst res = await client.userCustomData.batchSet([\n { Bucket: CustomDataBucket.Private, KeyID: \"theme\", Value: \"dark\" },\n { Bucket: CustomDataBucket.Public, KeyID: \"title\", Value: \"Dragon Slayer\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Results ?? []) {\n // item.Bucket, item.KeyID, item.Version, item.ExpiresAt\n}\n```\n\nThis is genuinely all-or-nothing: every item is validated (schema bucket\nmatch, value type/size, no `(Bucket, KeyID)` duplicates) **before** anything\ntouches the database, and a single invalid item fails the whole call with no\npartial writes — unlike some other modules' batch APIs, there is no\nper-item `Success`/`Error` result array here to sift through.\n\n### Batch-delete, and batch-read public data for a leaderboard/friends screen\n\n```ts\nawait client.userCustomData.batchDelete([\n { Bucket: CustomDataBucket.Private, KeyID: \"tempFlag\" },\n]);\n\nconst res = await client.userCustomData.batchGetPublicUserCustomDataOf([\n \"player-1\",\n \"player-2\",\n]);\nif (res.ok) {\n for (const profile of res.data.Results ?? []) {\n // profile.UserID, profile.Public\n }\n res.data.NotFoundUserIDs; // ids that don't exist / had nothing public\n}\n```\n\n`batchDelete` is idempotent per item — deleting a key that's already gone (or\nnever existed) is not an error; it's simply excluded from `DeletedCount`. If\nnone of the requested keys exist, the call still succeeds with\n`DeletedCount: 0` and never touches the database.\n\n## Gotchas\n\n- **Values are strings only.** JSON-encode/decode structured data yourself;\n the SDK does not serialize for you. Note that for a schema-registered key\n with `ValueType: \"Json\"`, the **backend** does validate that your string\n parses as JSON (and similarly `Int`→`long.TryParse`, `Bool`→`\"true\"`/`\"false\"`)\n — a malformed value is rejected server-side with a `\"server\"` reason before\n it reaches the database.\n- **`KeyID` can't contain `\".\"` or `\"$\"`.** Same MongoDB path-safety rule as\n other modules' IDs — validated client-side before any network call, and\n re-validated server-side.\n- **`ReadOnly` and `Internal` have no client write path.** `deleteKey` and\n `batchSet`/`batchDelete` explicitly reject buckets other than `Private`/\n `Public` with `reason: \"client\"`. `Internal` is never returned to the client\n at all, by any endpoint — not even your own.\n- **Reading another player's data never touches your cache.** Only your own\n reads/writes (`getMyUserCustomData`, `setPrivateData`, `setPublicData`,\n `deleteKey`, `batchSet`, `batchDelete`) patch `client.data.user.state.CustomData`.\n- **Guard against double-submit.** Each call is a real write with no\n idempotency key — writes are last-write-wins with no compare-and-swap on the\n client API, so a double-clicked \"Save\" can silently overwrite itself twice\n in a row (harmless for a plain overwrite, but a race if two different\n values are in flight). Disable the control while a call is in flight.\n Firing the same endpoint again within the client-side throttle window\n (600 ms) is rejected with `reason: \"throttled\"` rather than sent twice.\n- **Schema enforcement is permissive by default.** Whether an unregistered\n `KeyID` is accepted depends entirely on the title's\n `RejectUnregisteredKeys` flag (default `false`, i.e. permissive/free-form).\n When `true`, only keys listed in the registry's `Keys` map can be written at\n all — check [references/data-model.md](references/data-model.md) and load\n `getUserCustomDataDefinitions()` before assuming a string key will be\n accepted.\n- **Real server-side caps exist, not just per-key size.** Per-title limits\n gate every write: a max value size per key, a max key _count_ per bucket,\n and a max _total_ size across all four buckets combined — see\n [references/data-model.md](references/data-model.md) for the exact fields\n and default values. Hitting any of them fails the write (or the whole batch)\n with a `\"server\"` error naming the limit; nothing is silently dropped or\n truncated.\n- **Batches are capped at 50 items and reject outright past that**, unlike\n some other modules' batches which silently drop overflow entries — sending\n 51+ items in one `batchSet`/`batchDelete`/`batchGetPublicUserCustomDataOf`\n call fails the entire call with a `\"server\"` error; chunk larger sets into\n multiple calls yourself.\n- **TTL is enforced, not cosmetic.** A key with a TTL (from the registry's\n `TtlSeconds` or the title's `DefaultTtlSeconds` for free-form keys) simply\n stops being returned once expired — reads filter it out as if it were never\n set. The expired record itself is only actually purged from storage on the\n _next_ write to that bucket, so don't rely on `ExpiresAt` for anything other\n than \"will this still be readable.\"\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 request/response\ntype, the key-registry config shape, and the exact size/count/TTL limit\nfields and their defaults. Read it when building config-driven UI (e.g.\nshowing max length or TTL before the player types) or when a server error\npoints at a registry rule you need to understand.\n",
4
+ "content": "---\nname: user-custom-data\ndescription: >-\n Build a generic per-player key-value data store in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.userCustomData\n (UserCustomDataService): set/get/delete private (only-you-readable) and\n public (readable-by-others) string keys, batch set/delete many keys\n atomically, batch-read public data for many players at once, and load the\n title's schema-managed key registry. Use this whenever the user is working\n in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and\n wants player settings/preferences storage, profile flair or badges visible\n to other players, arbitrary save-data slots, or otherwise touches\n client.userCustomData, UserCustomDataService, UserCustomDataModels,\n CustomDataBucket, or UserCustomDataRecord — even if they don't name the\n module explicitly.\n---\n\n# User custom data (iDosGames TS SDK)\n\nUserCustomData is a generic per-player key-value store: arbitrary string\nvalues under string keys, split into buckets by visibility. It's\nself-contained — no coupling to currencies, items, or any other economy\nmodule, and no resource cost is ever charged for using it. Use it for\nanything that doesn't fit a purpose-built module: player settings, UI\npreferences, cosmetic flair shown on a profile, small save-data blobs,\nfeature flags per player, etc.\n\n**This is where player data goes.** The project is client-side code in the\nplayer's browser: `localStorage`, module fields and React state survive neither\na device change nor a cleared cache, and the server trusts none of them. If a\ndedicated module owns the data (currency, inventory, quests, characters,\nleaderboards) use that module — it enforces the rules. Everything else that must\npersist goes here. Don't invent a save system.\n\nEverything is **server-authoritative**: the backend owns bucket assignment,\nsize/count limits, and (for schema-registered keys) value-format validation.\nThis skill is for **using** the production `UserCustomDataService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (key not registered, value too large/wrong format, bucket full) —\nsurface the error, don't try to reproduce the check client-side.\n\n## Buckets\n\nEvery key lives in exactly one bucket (`CustomDataBucket`), and the bucket\ndecides both who can read it and who can write it:\n\n- **`Private`** — readable only by the owning player. Set with\n `setPrivateData`.\n- **`Public`** — readable by other players (e.g. via\n `getPublicUserCustomDataOf`). Set with `setPublicData`.\n- **`ReadOnly`** — server-written only (e.g. a script marking a tutorial step\n done). The client can read it — it shows up in `getMyUserCustomData()`'s\n `ReadOnly` map and in the cached `CustomData.ReadOnly` — but the client SDK\n exposes no write method for it.\n- **`Internal`** — never returned to the client in any form, by any endpoint\n (including cross-player reads). Backend jobs/admin/analytics only; treat it\n as fully invisible.\n\nOnly `Private` and `Public` are writable/deletable from the client\n(`setPrivateData`/`setPublicData`/`deleteKey`/batch variants all reject other\nbuckets with a client-side validation error before hitting the network — and\nthe backend independently enforces the same restriction).\n\n**Choosing a bucket is a security decision, not a taste one.** A client-written\nvalue is a value the player can set to anything: they own the browser. Put\npreferences and cosmetics in `Private`/`Public`; put anything worth cheating for\n— progress, unlocks, earned rewards, reward cooldowns, anti-abuse flags — in\n`ReadOnly` or `Internal`.\n\nThose two buckets are written **only from a CloudCode script**\n(`server.SetUserCustomData(\"ReadOnly\", key, value)` and friends). That is the\nwhole point of the split: the write happens on the server, inside logic the\nplayer cannot edit. See **cloud-code** for writing and publishing the handler,\nand call it with `client.cloudCode.execute(...)`. Title-wide values shared by\nevery player belong in **title-custom-data** instead.\n\nValues are always **strings**. If you need structured data, JSON-encode it\nyourself. The config registry has a `Json` `ValueType` hint — for\nschema-registered keys the **backend validates** that the string parses as\nthe declared type (`Int`/`Bool`/`Json`) on every write — but the SDK itself\ndoes not parse, validate, or decode it for you; see\n[references/data-model.md](references/data-model.md).\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 customData = client.userCustomData; // the UserCustomDataService\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 — empty/malformed key, empty batch), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the client-side throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. unregistered key, value type/size limit,\nbucket full, too many batch items).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------- |\n| `getUserCustomDataDefinitions()` | Load the title's key registry/config. | `UserCustomDataDefinitions` |\n| `getMyUserCustomData()` | Load all of this player's data (all buckets). | `GetMyUserCustomDataResponse` |\n| `getPublicUserCustomDataOf(targetUserID)` | Read another player's `Public` bucket only. | `GetPublicUserCustomDataResponse` |\n| `setPrivateData(keyID, value)` | Set/overwrite one key in `Private`. | `SetUserCustomDataResponse` |\n| `setPublicData(keyID, value)` | Set/overwrite one key in `Public`. | `SetUserCustomDataResponse` |\n| `deleteKey(keyID, bucket)` | Delete one key (`Private` or `Public` only). | `SuccessResponse` |\n| `batchSet(items)` | Set many keys (any mix of buckets) atomically. | `BatchSetUserCustomDataResponse` (`Results`) |\n| `batchDelete(items)` | Delete many keys atomically. | `BatchDeleteUserCustomDataResponse` (`DeletedCount`) |\n| `batchGetPublicUserCustomDataOf(targetUserIDs)` | Read `Public` bucket for many players in one call. | `BatchGetPublicUserCustomDataResponse` |\n\nKey rules (checked client-side before any request, and re-checked\nserver-side): a `KeyID` must be non-empty and must not contain `\".\"` or `\"$\"`\n(MongoDB path-safety rule, same convention as Character/Item IDs elsewhere in\nthe SDK).\n\nOn success, the single-key and batch **write/delete** methods mirror the\nconfirmed change into the cache and emit an event. `getPublicUserCustomDataOf`\nand `batchGetPublicUserCustomDataOf` (reading _another_ player's data) do\n**not** touch the cache — there's nothing local to patch since it's someone\nelse's data; treat their results as transient render data.\n\n## Reading state and reacting to changes\n\n```ts\nconst cd = client.data.user.state?.CustomData;\ncd?.Version; // increments on every local write/delete (client-side change counter)\ncd?.Private?.[\"settings\"]?.Value; // string | undefined\ncd?.Public?.[\"title\"]?.Value;\ncd?.ReadOnly?.[\"serverFlag\"]?.Value; // written server-side only\n\n// Config (registry of known keys, if the title schema-manages them):\nimport type { UserCustomDataDefinitions } from \"@idosgames/core\";\nconst defs =\n client.data.config.getSection<UserCustomDataDefinitions>(\"UserCustomData\");\n```\n\nEach `UserCustomDataRecord` also carries `UpdatedAt`, `Version` (a per-key\ncounter that increments every time that specific key is overwritten —\nunrelated to the bucket-wide `CustomData.Version` change counter),\n`LastWriter` (`\"Client\" | \"Server\" | \"System\"`), and `ExpiresAt` (if the key\nhas a TTL).\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `userCustomData:definitionsLoaded` → `UserCustomDataDefinitions`\n- `userCustomData:myDataLoaded` → `GetMyUserCustomDataResponse`\n- `userCustomData:publicDataLoaded` → `GetPublicUserCustomDataResponse` (cache untouched)\n- `userCustomData:privateDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:publicDataSet` → `SetUserCustomDataResponse`\n- `userCustomData:keyDeleted` → `void`\n- `userCustomData:batchSet` → `BatchSetUserCustomDataResponse`\n- `userCustomData:batchDeleted` → `BatchDeleteUserCustomDataResponse`\n- `userCustomData:batchPublicDataLoaded` → `BatchGetPublicUserCustomDataResponse` (cache untouched)\n\nThe coarse `user:customDataUpdated` (and `user:anyUpdated`) also fire on every\nlocal write/delete — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"userCustomData:privateDataSet\", (r) => {\n console.log(`${r.KeyID} saved at version ${r.Version}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Save and read a player setting privately\n\n```ts\nconst res = await client.userCustomData.setPrivateData(\"theme\", \"dark\");\nif (!res.ok) return showError(res.error);\n\n// later, anywhere in the app:\nconst theme = client.data.user.state?.CustomData?.Private?.[\"theme\"]?.Value;\n```\n\n### Bootstrap all of the player's own data at login\n\n```ts\nawait client.userCustomData.getMyUserCustomData();\nconst cd = client.data.user.state?.CustomData;\n// cd.Private / cd.Public / cd.ReadOnly are now populated from the server.\n// If a schema-registered key has a DefaultValue and the player has never set\n// it, the server materializes that default in the response (Version: 0,\n// LastWriter: \"System\") without writing it to the DB — treat it as a real\n// display value, just don't expect a subsequent read to differ before you Set it.\n```\n\n### Expose a profile badge publicly\n\n```ts\nconst res = await client.userCustomData.setPublicData(\"title\", \"Dragon Slayer\");\nif (!res.ok) return showError(res.error);\n\n// anyone can now read it:\nconst other =\n await client.userCustomData.getPublicUserCustomDataOf(\"player-42\");\nif (other.ok) {\n const badge = other.data.Public?.[\"title\"]?.Value;\n}\n```\n\n### Batch-set several keys in one atomic call\n\n```ts\nimport { CustomDataBucket } from \"@idosgames/core\";\n\nconst res = await client.userCustomData.batchSet([\n { Bucket: CustomDataBucket.Private, KeyID: \"theme\", Value: \"dark\" },\n { Bucket: CustomDataBucket.Public, KeyID: \"title\", Value: \"Dragon Slayer\" },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Results ?? []) {\n // item.Bucket, item.KeyID, item.Version, item.ExpiresAt\n}\n```\n\nThis is genuinely all-or-nothing: every item is validated (schema bucket\nmatch, value type/size, no `(Bucket, KeyID)` duplicates) **before** anything\ntouches the database, and a single invalid item fails the whole call with no\npartial writes — unlike some other modules' batch APIs, there is no\nper-item `Success`/`Error` result array here to sift through.\n\n### Batch-delete, and batch-read public data for a leaderboard/friends screen\n\n```ts\nawait client.userCustomData.batchDelete([\n { Bucket: CustomDataBucket.Private, KeyID: \"tempFlag\" },\n]);\n\nconst res = await client.userCustomData.batchGetPublicUserCustomDataOf([\n \"player-1\",\n \"player-2\",\n]);\nif (res.ok) {\n for (const profile of res.data.Results ?? []) {\n // profile.UserID, profile.Public\n }\n res.data.NotFoundUserIDs; // ids that don't exist / had nothing public\n}\n```\n\n`batchDelete` is idempotent per item — deleting a key that's already gone (or\nnever existed) is not an error; it's simply excluded from `DeletedCount`. If\nnone of the requested keys exist, the call still succeeds with\n`DeletedCount: 0` and never touches the database.\n\n## Gotchas\n\n- **Values are strings only.** JSON-encode/decode structured data yourself;\n the SDK does not serialize for you. Note that for a schema-registered key\n with `ValueType: \"Json\"`, the **backend** does validate that your string\n parses as JSON (and similarly `Int`→`long.TryParse`, `Bool`→`\"true\"`/`\"false\"`)\n — a malformed value is rejected server-side with a `\"server\"` reason before\n it reaches the database.\n- **`KeyID` can't contain `\".\"` or `\"$\"`.** Same MongoDB path-safety rule as\n other modules' IDs — validated client-side before any network call, and\n re-validated server-side.\n- **`ReadOnly` and `Internal` have no client write path.** `deleteKey` and\n `batchSet`/`batchDelete` explicitly reject buckets other than `Private`/\n `Public` with `reason: \"client\"`. `Internal` is never returned to the client\n at all, by any endpoint — not even your own. Writing either one means writing\n a CloudCode handler; there is no SDK method that will do it, now or later.\n- **Register the keys a feature depends on.** A key listed in the title's\n `UserCustomData` config section has its bucket pinned, its value type checked\n (`String`/`Int`/`Bool`/`Json`), a size cap, an optional TTL and a default\n value returned on read. Unregistered keys are accepted (unless the title sets\n `RejectUnregisteredKeys`) but nothing then catches a typo or a wrong bucket.\n Add the definition in the same change that adds the code using it.\n- **Reading another player's data never touches your cache.** Only your own\n reads/writes (`getMyUserCustomData`, `setPrivateData`, `setPublicData`,\n `deleteKey`, `batchSet`, `batchDelete`) patch `client.data.user.state.CustomData`.\n- **Guard against double-submit.** Each call is a real write with no\n idempotency key — writes are last-write-wins with no compare-and-swap on the\n client API, so a double-clicked \"Save\" can silently overwrite itself twice\n in a row (harmless for a plain overwrite, but a race if two different\n values are in flight). Disable the control while a call is in flight.\n Firing the same endpoint again within the client-side throttle window\n (600 ms) is rejected with `reason: \"throttled\"` rather than sent twice.\n- **Schema enforcement is permissive by default.** Whether an unregistered\n `KeyID` is accepted depends entirely on the title's\n `RejectUnregisteredKeys` flag (default `false`, i.e. permissive/free-form).\n When `true`, only keys listed in the registry's `Keys` map can be written at\n all — check [references/data-model.md](references/data-model.md) and load\n `getUserCustomDataDefinitions()` before assuming a string key will be\n accepted.\n- **Real server-side caps exist, not just per-key size.** Per-title limits\n gate every write: a max value size per key, a max key _count_ per bucket,\n and a max _total_ size across all four buckets combined — see\n [references/data-model.md](references/data-model.md) for the exact fields\n and default values. Hitting any of them fails the write (or the whole batch)\n with a `\"server\"` error naming the limit; nothing is silently dropped or\n truncated.\n- **Batches are capped at 50 items and reject outright past that**, unlike\n some other modules' batches which silently drop overflow entries — sending\n 51+ items in one `batchSet`/`batchDelete`/`batchGetPublicUserCustomDataOf`\n call fails the entire call with a `\"server\"` error; chunk larger sets into\n multiple calls yourself.\n- **TTL is enforced, not cosmetic.** A key with a TTL (from the registry's\n `TtlSeconds` or the title's `DefaultTtlSeconds` for free-form keys) simply\n stops being returned once expired — reads filter it out as if it were never\n set. The expired record itself is only actually purged from storage on the\n _next_ write to that bucket, so don't rely on `ExpiresAt` for anything other\n than \"will this still be readable.\"\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 request/response\ntype, the key-registry config shape, and the exact size/count/TTL limit\nfields and their defaults. Read it when building config-driven UI (e.g.\nshowing max length or TTL before the player types) or when a server error\npoints at a registry rule you need to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",