@idosgames/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/cli.js +204 -0
- package/package.json +46 -0
- package/registry/host.json +41 -0
- package/registry/index.json +215 -0
- package/registry/modules/board-game.json +121 -0
- package/registry/modules/currency-hud.json +28 -0
- package/registry/modules/idle-rpg.json +89 -0
- package/registry/modules/voxelcraft.json +163 -0
- package/registry/skills/authentication.json +11 -0
- package/registry/skills/blockchain-system.json +11 -0
- package/registry/skills/character-system.json +11 -0
- package/registry/skills/cloud-code.json +6 -0
- package/registry/skills/collection-system.json +11 -0
- package/registry/skills/coop-event-system.json +11 -0
- package/registry/skills/craft-system.json +11 -0
- package/registry/skills/currency-system.json +11 -0
- package/registry/skills/deal-offer-system.json +11 -0
- package/registry/skills/dev-test-loop.json +6 -0
- package/registry/skills/game-loop-system.json +11 -0
- package/registry/skills/idosgames-compose-modules.json +6 -0
- package/registry/skills/idosgames-getting-started.json +6 -0
- package/registry/skills/idosgames-module-contract.json +6 -0
- package/registry/skills/item-system.json +11 -0
- package/registry/skills/leaderboard-system.json +11 -0
- package/registry/skills/lootbox-system.json +11 -0
- package/registry/skills/marketplace-system.json +11 -0
- package/registry/skills/match-system.json +11 -0
- package/registry/skills/multiplayer-realtime.json +11 -0
- package/registry/skills/premium-system.json +11 -0
- package/registry/skills/quest-system.json +11 -0
- package/registry/skills/referral-system.json +11 -0
- package/registry/skills/reward-system.json +11 -0
- package/registry/skills/season-system.json +11 -0
- package/registry/skills/social-system.json +6 -0
- package/registry/skills/store-system.json +11 -0
- package/registry/skills/timed-boost-system.json +11 -0
- package/registry/skills/timed-event-system.json +11 -0
- package/registry/skills/title-system.json +6 -0
- package/registry/skills/user-custom-data.json +11 -0
- package/registry/skills/user-profile.json +11 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
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",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
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"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "referral-system",
|
|
3
|
+
"description": "Build a referral / invite-a-friend system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.referral (ReferralService): load referral config (activation reward, staged follower-count invite rewards, spend-kickback rules), load the player's own referral state (who they're subscribed to, follower count, claimed invite rewards), activate someone else's referral code, and claim a staged invite reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants invite-friend / referral-code / refer-a-friend UIs, follower-milestone reward screens, or otherwise touches client.referral, ReferralService, ReferralDefinitions, UserReferralState, or referral codes — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: referral-system\ndescription: >-\n Build a referral / invite-a-friend system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.referral (ReferralService):\n load referral config (activation reward, staged follower-count invite\n rewards, spend-kickback rules), load the player's own referral state\n (who they're subscribed to, follower count, claimed invite rewards),\n activate someone else's referral code, and claim a staged invite reward.\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants invite-friend / referral-code /\n refer-a-friend UIs, follower-milestone reward screens, or otherwise touches\n client.referral, ReferralService, ReferralDefinitions, UserReferralState,\n or referral codes — even if they don't name the module explicitly.\n---\n\n# Referral system (iDosGames TS SDK)\n\nThe Referral module lets a title run an invite-a-friend loop: every player is\nidentified by their own `UserID` (that _is_ their referral code — there's no\nseparate generated code), a new player **activates** someone else's code once,\nthe referrer's `FollowersCount` goes up, and the referrer can later **claim**\nstaged rewards as that count crosses configured thresholds. A separate\n`SpendRewards` config block describes a percent-of-spend kickback to the\nreferrer — it's config-only from this module (see Gotchas). It's\n**server-authoritative**: the client asks the backend to activate a code or\nclaim a reward, the backend validates and grants, and the SDK mirrors the\nconfirmed result into the local cache. You never compute follower counts or\nreward eligibility yourself — you call a method, check the result, and render\nfrom the cache.\n\nThis skill is for **using** the production `ReferralService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(self-referral, already activated, unknown code, threshold not met) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Key data entities\n\nKeep these two straight; every recipe below is just moving between them.\n\n1. **`ReferralDefinitions`** (config, same for every player) — the title's\n referral rules: whether the system is enabled, the one-time\n `ActivationReward`, the staged `InviteRewards` ladder (keyed by\n `MilestoneID`, each a shared `MilestoneDefinition`), and `SpendRewards`\n (percent-kickback rules per feature). Fetched with `getDefinitions()`.\n2. **`UserReferralState`** (state, per player) — who _this_ player activated\n (`SubscribedToUserID`), whether their activation reward was granted, their\n own `FollowersCount` and `FollowerIDs`, and which `InviteRewards` they've\n claimed (`InviteRewardStates`). Fetched with `getUserState()`.\n\nFor the full field shapes, the invite-reward threshold/claim rules, and how\nthe shared Core/Milestone progression multiplier applies to invite rewards,\nread [references/data-model.md](references/data-model.md). You do **not**\nneed it to call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst referral = client.referral; // the ReferralService\n```\n\nEvery referral method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — empty\n`referralCode`/`inviteRewardID`), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------ | ------------------------------------------------------------------- | ------------------------------ |\n| `getDefinitions()` | Load the title's referral config. | `ReferralDefinitionsResponse` |\n| `getUserState()` | Load this player's referral state. | `UserReferralStateResponse` |\n| `activateReferralCode(referralCode)` | Redeem another player's code (one-time; grants `ActivationReward`). | `ActivateReferralCodeResponse` |\n| `claimInviteReward(inviteRewardID)` | Claim a staged follower-milestone reward. | `ClaimInviteRewardResponse` |\n\n`activateReferralCode` trims and **uppercases** the code client-side before\nsending — pass it in whatever case the player typed\n(`ReferralService.activateReferralCode`, `ReferralModels.ts`/`ReferralService.ts`).\n`inviteRewardID` is a key into `ReferralDefinitions.InviteRewards` (a\n`MilestoneID`); the service trims it but does not change case.\n\nBoth mutating calls set a deterministic `RelatedEntityID` for you —\n`referral_activation_{userID}` for activation,\n`referral_invite_{inviteRewardID}_{userID}` for a claim — which the backend\nuses as the idempotency key (`Referral.cs`: `ResolveRelatedEntityID`, then\n`reason: \"ReferralActivation:...\"` / `\"ReferralInviteReward:...\"` on the\nresource operation). You don't need to pass one yourself.\n\nOn success:\n\n- `activateReferralCode` patches `client.data.user.state?.Referral\n.SubscribedToUserID` to the (uppercased) code and applies\n `data.Resources` (the `ActivationReward`, only present when\n `IsFirstActivation` is true) to cached balances.\n- `claimInviteReward` marks that reward id claimed in\n `client.data.user.state?.Referral.InviteRewardStates` and applies\n `data.Resources` to cached balances.\n- `getUserState` replaces the whole cached `Referral` state with the fresh\n snapshot.\n\nNon-obvious server-side validation to expect from `activateReferralCode`\n(`Referral.cs`, `ActivateReferralCode`):\n\n- **Self-referral is rejected**: `referralCode.ToUpper() == service.UserID.ToUpper()`\n fails with `\"Cannot activate your own referral code\"`.\n- **The code must be a real, existing `UserID`** — an unknown code fails with\n `\"Referral code is invalid\"`.\n- **Re-activating the same code you're already subscribed to fails** with\n `\"Referral code already activated\"` — but activating a _different_ code\n than your current one **succeeds and switches referrers**: the previous\n referrer's `FollowersCount`/`FollowerIDs` are decremented/pulled, the new\n one incremented, and `IsFirstActivation` stays `false` (no second\n `ActivationReward` — `ActivationRewardGranted` is a permanent one-time flag\n that survives a referrer switch).\n- If the switch races with another request, the backend retries the whole\n operation as `\"server\"` failure `\"Referral state was modified concurrently. Please retry.\"`\n — just retry the call.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { ReferralDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\ndefs?.IsEnabled;\ndefs?.ActivationReward; // ResourceGrant paid to the activator\ndefs?.InviteRewards; // Record<MilestoneID, MilestoneDefinition> — staged for the referrer\ndefs?.SpendRewards; // percent kickback rules per feature, enforced elsewhere\n\n// Player state (only present after getUserState(), or after activate/claim patches it):\nconst state = client.data.user.state?.Referral;\nstate?.SubscribedToUserID; // whose code this player activated (null if none)\nstate?.ActivationRewardGranted;\nstate?.FollowersCount; // how many players activated *this* player's code\nstate?.FollowerIDs;\nstate?.InviteRewardStates; // Record<MilestoneID, { IsClaimed, ClaimedAt }>\n```\n\nEach `InviteRewards` entry is a `MilestoneDefinition` (`RequiredProgress`,\n`Rewards`, `BonusRewards`, `DisplayName`, ...) — walk it against\n`FollowersCount` to render \"claimed / claimable / locked\" per milestone; the\nserver is still the source of truth for whether a claim actually succeeds.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `referral:definitionsLoaded` → `ReferralDefinitionsResponse`\n- `referral:userStateLoaded` → `UserReferralStateResponse`\n- `referral:codeActivated` → `ActivateReferralCodeResponse`\n- `referral:inviteRewardClaimed` → `ClaimInviteRewardResponse`\n\nThe coarse `user:referralUpdated` (and umbrella `user:anyUpdated`) also fire\non every referral cache write (`UserData.ts`: `applyReferral`,\n`patchReferralSubscription`, `patchReferralInviteRewardClaimed`) — handy for\na \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"referral:codeActivated\", (r) => {\n if (r.IsFirstActivation)\n console.log(`Welcome bonus applied for ${r.ReferralCode}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state and render the invite screen\n\n```ts\nawait client.referral.getDefinitions();\nawait client.referral.getUserState();\n\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\nconst state = client.data.user.state?.Referral;\n\nconst milestones = Object.entries(defs?.InviteRewards ?? {}).map(\n ([id, def]) => ({\n id,\n def,\n claimed: !!state?.InviteRewardStates?.[id]?.IsClaimed,\n eligible: (state?.FollowersCount ?? 0) >= (def.RequiredProgress ?? 0),\n }),\n);\n```\n\n### Activate a friend's code\n\n```ts\nconst res = await client.referral.activateReferralCode(enteredCode);\nif (!res.ok) return showError(res.error); // e.g. \"Cannot activate your own referral code\", \"Referral code is invalid\"\nif (res.data.IsFirstActivation) showWelcomeToast();\n// cache now has SubscribedToUserID + granted ActivationReward; balances already applied.\n```\n\nA player's referral code **is their `UserID`** — there's no separate\ngenerated invite code to look up or display; show the player their own\n`UserID` (or a share link built from it) as \"their\" code.\n\n### Show a follower-count progress bar\n\n```ts\nawait client.referral.getUserState();\nconst state = client.data.user.state?.Referral;\nconst defs = client.data.config.getSection<ReferralDefinitions>(\"Referral\");\n\nconst next = Object.entries(defs?.InviteRewards ?? {})\n .filter(([id]) => !state?.InviteRewardStates?.[id]?.IsClaimed)\n .sort(\n ([, a], [, b]) => (a.RequiredProgress ?? 0) - (b.RequiredProgress ?? 0),\n )[0];\n// render state.FollowersCount / next?.[1].RequiredProgress\n```\n\n`FollowersCount` only changes when _other_ players activate this player's\ncode — refresh with `getUserState()` (or listen for `user:referralUpdated`)\nafter you expect a new signup; there's no live push for it.\n\n### Claim a follower-milestone reward\n\n```ts\nconst res = await client.referral.claimInviteReward(\"followers_10\");\nif (!res.ok) return showError(res.error); // e.g. \"Not enough followers. Required: 10, current: 4\", \"Reward 'followers_10' already claimed\"\n// cache now marks \"followers_10\" claimed; balances already applied.\n```\n\nThe granted amount can be higher than the configured `Rewards` if the title\nhas a title-wide milestone progression multiplier active — see\n[references/data-model.md](references/data-model.md#invite-reward-payout--the-milestone-resolver).\nTo preview it before claiming, call `client.reward.getMilestoneRewardMultiplier()`\n(from the Reward module) and scale your displayed amount the same way the\nserver will.\n\n### Show spend-kickback earnings\n\nThere is nothing to call here — `SpendRewards` only describes _rules_\n(percent, source/target currency, per feature); Referral itself never grants\na kickback. Read `defs?.SpendRewards` to show the player \"earn N% back when\nyour friends spend,\" but surface any actual kickback payout through whichever\nfeature's own events/cache produced it (see Gotchas).\n\n## Gotchas\n\n- **`SpendRewards` currently has no wiring to apply it.** The doc comments on\n `SpendRewardDefinition` (`ReferralDefinitions.cs`) describe a\n `ReferralV2.ProcessSpendRewardAsync()` that spending features are supposed\n to call after a deduction — but no such method exists anywhere in the\n backend today, and no feature calls it. Treat `SpendRewards` as\n forward-looking config: don't build UI that promises an automatic kickback\n payout, and don't expect a `referral:*` event when a follower spends.\n- **Activating a different code than your current one silently switches\n referrers** — it is not rejected as \"already activated.\" Only re-submitting\n the _same_ code you're already subscribed to fails. Warn the player before\n they overwrite an existing subscription if that matters for your game.\n- **No self-referral, enforced server-side only.** There's no client-side\n guard against entering your own `UserID`; the rejection\n (`\"Cannot activate your own referral code\"`) only comes back after the\n round-trip.\n- **Activating a code also best-effort adds the referrer as a mutual friend**\n (`Referral.cs` calls `Social.TryAddMutualFriendAsync`, capped by the\n Social module's friend limit). This does not update `client.data.user\n.state?.Social` or fire a `social:*` event — refresh via `client.social`\n if your UI shows a friends list right after an activation.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n from `RelatedEntityID`, so two separate calls are two real operations — a\n double-clicked \"Activate\" or \"Claim\" can apply twice. Disable the control\n while a call is in flight.\n- **Code casing is normalized for you** — `activateReferralCode` uppercases\n and trims before sending and before caching `SubscribedToUserID`, so\n display the code in whatever case you want but don't rely on the input's\n original case surviving.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full config/state\nfield shapes, the invite-reward threshold/claim mechanics, and the shared\nCore/Milestone progression-multiplier math (with its exact rounding rule) as\nit applies to `ActivationReward` and `InviteRewards` payouts.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Referral data model — reference\n\nFull shape of the config (`ReferralDefinitions`) and player state\n(`UserReferralState`), the invite-reward threshold/claim mechanics, and the\nshared Core/Milestone progression-multiplier math that scales\n`ActivationReward`/`InviteRewards` payouts. All of these are **strictly typed\nin the SDK** — `ReferralDefinitions`, `UserReferralState`, and the shared\n`MilestoneDefinition`/`RewardProgressionMultiplierSpec` types are exported\nfrom `@idosgames/core`. The zod schemas keep `.passthrough()`, so a field the\nbackend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: ReferralDefinitions](#config-referraldefinitions) — what `getDefinitions()` returns\n- [SpendRewardDefinition](#spendrewarddefinition)\n- [Player state: UserReferralState](#player-state-userreferralstate) — what `getUserState()` returns\n- [Invite-reward payout — the Milestone resolver](#invite-reward-payout--the-milestone-resolver)\n- [Activation flow — server rules](#activation-flow--server-rules)\n- [Claim flow — server rules](#claim-flow--server-rules)\n\n---\n\n## Config: ReferralDefinitions\n\nReturned by `getDefinitions()` as `{ ReferralDefinitions }`; cached via\n`client.data.config.getSection<ReferralDefinitions>(\"Referral\")`.\n\nSource: `Referral.cs` (`GetDefinitions`, reads `config.Referral`),\n`ReferralDefinitions.cs`, `ReferralModels.ts`.\n\n```ts\ninterface ReferralDefinitions {\n IsEnabled?: boolean | null; // default true on the backend; false = ActivateReferralCode rejects with \"Referral system is disabled\"\n ActivationReward?: ResourceGrant | null; // one-time grant to the activator on their first-ever activation\n InviteRewards?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; staged rewards to the REFERRER\n SpendRewards?: SpendRewardDefinition[] | null; // percent-of-spend kickback rules; config only, see below\n}\n```\n\n`ActivationReward` and each `InviteRewards[id].Rewards` are `ResourceGrant` —\nthe same shared type used across every module (currencies, items, event\ntokens, premium-tier bundles). See the `currency-system` skill for its full\nshape if you need it.\n\n`MilestoneDefinition` (shared `Core/Milestone` primitive, also used by Quest,\nLeaderboard, TimedEvent, DealOffer, CommunityChest):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // compared against UserReferralState.FollowersCount, NOT an event-token balance\n Rewards?: ResourceGrant; // base payout\n BonusRewards?: ResourceGrant; // bonus-window overlay — unused by Referral (no bonus-window context is ever passed in)\n SeasonTierRewards?: SeasonTierRewardSet; // season-tier overlay — unused by Referral (no season context is ever passed in)\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nReferral is the \"plain\" consumer of `MilestoneDefinition`: it never supplies a\n`BonusActive`/`SeasonChainID` context (see\n[Invite-reward payout](#invite-reward-payout--the-milestone-resolver)), so in\npractice only `Rewards`, `RequiredProgress`, and the display fields matter for\nthis module — `BonusRewards`/`SeasonTierRewards` are dead weight here even\nthough the type carries them for other modules.\n\n---\n\n## SpendRewardDefinition\n\n```ts\ninterface SpendRewardDefinition {\n FeatureKey?: string; // e.g. \"Store\", \"Marketplace\", \"Reward\", \"Gacha\" — must match what the calling feature passes\n IsEnabled?: boolean; // default true\n Percent?: number; // 0-100; percent of the follower's spend the referrer receives\n SourceCurrencyID?: string; // currency the follower spends\n TargetCurrencyID?: string; // currency the referrer receives (may differ — implies conversion)\n}\n```\n\n**This is config-only today.** `ReferralDefinitions.cs`'s doc comments\ndescribe a `ReferralV2.ProcessSpendRewardAsync()` that spending features are\nsupposed to call after a successful deduction to compute and grant the\nkickback — but grepping the entire backend turns up **zero** definitions or\ncall sites for any such method. No feature (`Store.cs`, `Marketplace*.cs`,\n`Reward.cs`, ...) invokes it. Nothing grants a `SpendRewards` payout right\nnow. Build UI that describes the rule (\"earn N% back\") if you want, but don't\nbuild a claim/notification flow expecting an actual grant or a `referral:*`\nevent tied to a follower's purchase — there is nothing to listen for.\n\n---\n\n## Player state: UserReferralState\n\nReturned by `getUserState()` as `{ Referral }`; cached at\n`client.data.user.state?.Referral`. Source: `Referral.cs` (`GetUserState`,\nprojects only `UserDataDocument.Referral`), `UserReferralState.cs`.\n\n```ts\ninterface UserReferralState {\n SubscribedToUserID?: string | null; // UserID of the code this player activated; null/empty = not subscribed\n ActivationRewardGranted?: boolean; // true once the one-time ActivationReward has been paid to THIS player; stays true across a referrer switch\n FollowersCount?: number; // number of players currently subscribed to THIS player's code (their own UserID)\n FollowerIDs?: string[]; // UserIDs of those followers — kept in sync so a follower switching away can be pulled out correctly\n InviteRewardStates?: Record<string, ReferralInviteRewardState>; // key = MilestoneID; only entries that have been claimed are present\n UpdatedAt?: string; // ISO timestamp of last change\n}\n\ninterface ReferralInviteRewardState {\n RewardID?: string; // == the MilestoneID key\n IsClaimed?: boolean;\n ClaimedAt?: string | null;\n}\n```\n\n`InviteRewardStates` only contains entries that have actually been claimed —\nthere's no \"auto-granted but unclaimed\" pre-population (unlike some other\nmilestone systems); a milestone id absent from the map simply means \"not yet\nclaimed,\" which you should treat as claimable once `FollowersCount` clears its\n`RequiredProgress`.\n\nA player's referral code **is their own `UserID`** — the module has no\nseparate generated/short code. To let a player share \"their\" code, show them\ntheir own `UserID` (or embed it in a deep link); there is no dedicated field\nor endpoint for a display-friendly code.\n\n---\n\n## Invite-reward payout — the Milestone resolver\n\n`claimInviteReward` does not simply grant `InviteRewards[id].Rewards`\nverbatim. The backend runs it through the shared\n`MilestoneRewardResolver.Resolve` (`MilestoneRewardResolver.cs`), the same\nresolver Quest/Leaderboard/TimedEvent/DealOffer/CommunityChest use, with this\ncontext (`Referral.cs`, `ClaimInviteReward`):\n\n```csharp\nvar milestoneGrant = MilestoneRewardResolver.Resolve(rewardDef, new MilestoneRewardContext\n{\n ProgressionMultiplier = config.Reward?.MilestoneRewardMultiplier,\n Player = doc,\n NowUtc = DateTime.UtcNow,\n});\n```\n\nOnly `ProgressionMultiplier`/`Player`/`NowUtc` are populated — `BonusActive`\nand `SeasonChainID` are left at their defaults (`false` / `null`), so\n`MilestoneRewardResolver.Resolve`'s bonus-window and season-tier overlay\nbranches are always skipped for Referral. The **only** overlay that can ever\nchange an invite-reward payout is the title-wide progression multiplier:\n\n1. Read the title's `RewardProgressionMultiplierSpec` from\n `cfg.Reward.MilestoneRewardMultiplier` (same spec object Lootbox and Reward\n also read — configured once per title, not per-module).\n2. If it's `null`, the grant is exactly `InviteRewards[id].Rewards` — no\n scaling.\n3. Otherwise (`RewardProgressionResolver.cs`):\n - Read the player's current progress for `spec.Source`/`spec.SourceKey`\n (`ProgressionSourceResolver.Read`) — e.g. `BoardStageLevel`,\n `CharacterLevel`, `SeasonTier`, `VirtualCurrencyBalance`, etc. This is\n **not** `FollowersCount` — the multiplier's progression axis is\n independent of the referral threshold you're claiming against.\n - Evaluate the multiplier (`EvaluateMultiplier`):\n - `Linear`: `mult = BaseMultiplier + PerUnit * max(0, progress - Anchor)`.\n - `Tiered` (default): walk `Tiers` sorted by `AtProgress`; below the\n first breakpoint → `BaseMultiplier`; at/above the last → that tier's\n `Multiplier`; between two breakpoints → the lower tier's `Multiplier`\n (`TierMode: \"Step\"`) or a linear interpolation between the two\n (`TierMode: \"Linear\"`).\n - Clamp to `[MinMultiplier, MaxMultiplier]` (`MaxMultiplier <= 0` means\n \"no upper clamp\"); `NaN`/`Infinity` collapses to `1.0`.\n - If the resulting multiplier is `~1.0` (within `1e-9`) or the spec is\n `null`, the grant is returned unscaled.\n - Otherwise every **targeted** entry in `Rewards.Standard.Entries` and\n `Rewards.Standard.EventTokens` (and inside each `PremiumTiers[].Resources`)\n is scaled: `spec.ExcludeRewards` wins if it matches; otherwise an empty\n `spec.IncludeRewards` means \"scale everything,\" else only entries listed\n in `IncludeRewards` (matched by `Type` + `CurrencyID`/`ItemID`, or by\n event-token `EntityID`) are scaled. `PremiumBonuses` (percent-based) are\n left alone — they're applied later, after scaling, inside\n `ResourceService`.\n - **Rounding**: each scaled amount goes through the platform-wide\n `ModifierService.Apply` with a `Multiply` step, which finishes with\n `Ceiling` and clamps to `>= 0` — i.e. `finalAmount = ceil(baseAmount *\nmultiplier)`, never negative, never silently truncated down.\n\nTo preview this on the client before the player claims, call\n`client.reward.getMilestoneRewardMultiplier()` (Reward module) — it evaluates\nthe exact same spec/progress/rounding server-side and returns\n`{ Enabled, Multiplier, Progress, Source, SourceKey }` for you to apply to the\ndisplayed `InviteRewards[id].Rewards` amounts. Referral does not expose its\nown copy of this multiplier — it's title-wide, not per-module.\n\n---\n\n## Activation flow — server rules\n\n`activateReferralCode(referralCode)` (`Referral.cs`, `ActivateReferralCode`),\nin order:\n\n1. `ReferralCode` required, else `\"ReferralCode is required\"` (`\"client\"` on\n the SDK side before this is even sent).\n2. Trimmed + uppercased. If it equals the caller's own `UserID` (also\n uppercased): `\"Cannot activate your own referral code\"`.\n3. `config.Referral` must exist: `\"Referral definitions not found\"`.\n4. `IsEnabled` must be true: `\"Referral system is disabled\"`.\n5. The code must resolve to a real user: `\"Referral code is invalid\"`.\n6. If the caller is already subscribed to that **same** code:\n `\"Referral code already activated\"`.\n7. Otherwise the call **succeeds**, whether or not the player had a previous\n referrer:\n - If there _was_ a previous referrer, that referrer's `FollowersCount` is\n atomically decremented (floored at 0 via an `extraFilter Gt(...,0)`) and\n the caller's id is pulled from their `FollowerIDs`.\n - The new referrer's `FollowersCount` is atomically incremented and the\n caller's id added to `FollowerIDs` (`$addToSet`, so re-adding is a\n no-op).\n - `Social.TryAddMutualFriendAsync(caller, referrer)` best-effort adds the\n two as mutual friends (capped by the Social module's friend limit;\n silently skipped if either side is already at the cap).\n - `IsFirstActivation` is `true` only when the caller had **no** previous\n `SubscribedToUserID` **and** `ActivationRewardGranted` was still false.\n When true, `ActivationReward` is granted via\n `ResourceService.ApplyResourceOperationAtomicAsync` (idempotency key\n `ReferralActivation:{RelatedEntityID}`) and `ActivationRewardGranted` is\n set permanently — a later referrer switch will not re-grant it.\n - The patch that sets `SubscribedToUserID` carries an `extraFilter`\n guarding against a concurrent change (matches \"no previous referrer\" or\n \"still the previously-read referrer\"); if that races, the call fails\n with `\"Referral state was modified concurrently. Please retry.\"` and the\n client should just retry.\n\n## Claim flow — server rules\n\n`claimInviteReward(inviteRewardID)` (`Referral.cs`, `ClaimInviteReward`), in\norder:\n\n1. `InviteRewardID` required, else `\"InviteRewardID is required\"`.\n2. Must exist in `config.Referral.InviteRewards`, else\n `\"Invite reward '{id}' not found in configuration\"`.\n3. `state.FollowersCount` must be `>= rewardDef.RequiredProgress`, else\n `\"Not enough followers. Required: {n}, current: {m}\"`.\n4. Must not already be claimed, else `\"Reward '{id}' already claimed\"`.\n5. The resolved grant (see above) is applied atomically with idempotency key\n `ReferralInviteReward:{RelatedEntityID}`, guarded by an `extraFilter` that\n only allows the write when there's no existing claimed state for that\n reward id (protects against a double-claim race the same way step 3/4\n protect against a stale read).\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reward-system",
|
|
3
|
+
"description": "Build daily-login, idle/offline-income, comeback, and generic claimable reward systems in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.reward (RewardService): load reward definitions and the player's reward state, claim a daily-calendar login-streak reward, collect accrued idle/offline income, claim a \"welcome back\" comeback bonus, claim a generic one-shot or repeating reward, and read the milestone reward multiplier. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a daily rewards calendar, login streak, idle/AFK income, offline earnings, a \"welcome back\" bonus, achievement/quest-style claim buttons, or otherwise touches client.reward, RewardService, RewardModels, RewardDefinitions, DailyCalendarDefinition, IdleAccrualDefinition, ComebackRewardDefinition, or ClaimRewardDefinition — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: reward-system\ndescription: >-\n Build daily-login, idle/offline-income, comeback, and generic claimable\n reward systems in a game on the iDosGames TypeScript SDK (@idosgames/core)\n via client.reward (RewardService): load reward definitions and the\n player's reward state, claim a daily-calendar login-streak reward, collect\n accrued idle/offline income, claim a \"welcome back\" comeback bonus, claim a\n generic one-shot or repeating reward, and read the milestone reward\n multiplier. Use this whenever the user is working in the iDosGames TS SDK\n or its game templates (board-game, idle-rpg) and wants a daily rewards\n calendar, login streak, idle/AFK income, offline earnings, a \"welcome\n back\" bonus, achievement/quest-style claim buttons, or otherwise touches\n client.reward, RewardService, RewardModels, RewardDefinitions,\n DailyCalendarDefinition, IdleAccrualDefinition, ComebackRewardDefinition,\n or ClaimRewardDefinition — even if they don't name the module explicitly.\n---\n\n# Reward system (iDosGames TS SDK)\n\nThe Reward module is an umbrella over **four independent subsystems** that all\nshare one shape: config defines what can be earned and under what rule, the\nplayer claims/collects, the backend validates and pays out, and the SDK\nmirrors the confirmed result into a local cache. Everything is\n**server-authoritative** — you call a method, check the result, and render\nfrom the cache. You never compute eligibility, cooldowns, or payout amounts\nyourself.\n\nThe four subsystems, each keyed by its own string ID and independent of the\nothers:\n\n1. **Daily reward calendars** (`DailyCalendars`) — a login-streak calendar of\n ordered days with per-day rewards. Claimed with `claimDailyReward`.\n2. **Idle accruals** (`IdleAccruals`) — income that accumulates while the\n player is away (offline/AFK), at a configured rate, up to a cap. Collected\n with `collectIdleAccrual`.\n3. **Comeback rewards** (`Comebacks`) — a one-time \"welcome back\" bonus tiered\n by how long the player was absent. Claimed with `claimComebackReward`.\n4. **Generic claims** (`Claims`) — the catch-all primitive for one-shot,\n repeating-with-limits, or server-triggered rewards (achievements, quest\n payouts, server-granted claims) that don't fit the other three. Claimed\n with `claimReward`.\n\nAll four ride the same shared reward-payout plumbing (`ResourceGrant` /\n`ResourceOperation`, segment gates, availability windows) — see\n[references/data-model.md](references/data-model.md) for the exact shapes.\nThere's also a standalone **milestone reward multiplier** — a title-wide,\nprogression-based multiplier (e.g. by board rank, character level, event-token\ntotal) that the _other_ milestone-bearing systems (TimedEvent, Leaderboard,\nDealOffer, Quest, CommunityChest, Referral) apply server-side to their own\nmilestone payouts; read it here with `getMilestoneRewardMultiplier()` purely\nfor display/preview.\n\nThis skill is for **using** the production `RewardService`, not for porting or\nextending it. If a claim is rejected, that's the backend enforcing a rule\n(cooldown, cap, window, gate) — surface the error, don't try to reproduce the\ncheck client-side.\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 rewards = client.reward; // the RewardService\n```\n\nEvery reward 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, e.g. an empty or `\".\"`/`\"$\"`-containing id), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window,\ndefault 600 ms), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. \"Daily reward already claimed today for this\ncalendar\", \"Nothing to collect yet\", \"No pending comeback reward\", \"Total\nclaim limit reached (1/1)\").\n\n| Method | Purpose | `data` on success |\n| --------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------- |\n| `getRewardDefinitions()` | Load the title's reward catalog (config): all four subsystems. | `RewardDefinitionsResponse` (`RewardDefinitions`) |\n| `getUserRewardsState()` | Load this player's reward state across all four subsystems. | `UserRewardsStateResponse` (`Rewards`) |\n| `getMilestoneRewardMultiplier()` | Load the current title-wide milestone reward multiplier. | `GetMilestoneRewardMultiplierResponse` |\n| `claimDailyReward(calendarID?)` | Claim today's day in a daily calendar (login streak). | `ClaimDailyRewardResponse` (`DayNumber`, `DailyState`) |\n| `collectIdleAccrual(accrualID)` | Collect accumulated idle/offline income. | `CollectIdleAccrualResponse` (`AccruedSeconds`, `IdleState`) |\n| `claimComebackReward(comebackID)` | Claim the \"welcome back\" bonus for this comeback track. | `ClaimComebackRewardResponse` (`ClaimedTierIndex`, `ComebackState`) |\n| `claimReward(claimID)` | Claim a generic reward (one-shot / repeating / server-triggered). | `ClaimRewardResponse` (`ClaimState`) |\n\nPlus two read-only getters mirroring the last-loaded milestone multiplier\n(default `1.0` / disabled until `getMilestoneRewardMultiplier()` is called):\n\n- `client.reward.milestoneRewardMultiplier` — `number`\n- `client.reward.milestoneRewardMultiplierEnabled` — `boolean`\n\n`claimDailyReward`'s `calendarID` is optional — omit it to use the title's\ndefault calendar (server falls back to `DefaultData.Default`, or the first\nconfigured calendar if that key is missing). `collectIdleAccrual`,\n`claimComebackReward`, and `claimReward` all require their id and validate it\nlocally first: non-empty, no `\".\"` or `\"$\"`. The backend also implements a\n`ClaimRewardsBatch` action (`RewardV2.ClaimRewardsBatch`, one atomic merged\ncharge, same partial-aware `BatchItemResult<T>[]` shape used elsewhere in the\nplatform) — but `RewardService` in this SDK version has **no** wrapper method\nfor it (no `claimRewardsBatch`). Until the SDK adds one, claim each reward\nwith its own `claimReward` call.\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/consumed\nresources ride along in `data.Resources` (a `ResourceOperation`) and are\nalready applied to cached currency/item balances, so read updated balances\nstraight from the cache (e.g. `client.data.user.getVirtualCurrencyAmount(...)`).\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Per-subsystem player state (only present after getUserRewardsState(), or\n// after the matching claim/collect call patches its own slice):\nconst state = client.data.user.state?.Reward;\nstate?.DailyCalendars?.[\"cal1\"]; // UserDailyCalendarState\nstate?.IdleAccruals?.[\"farm1\"]; // UserIdleAccrualState\nstate?.Comebacks?.[\"default\"]; // UserComebackState\nstate?.Claims?.[\"first_win\"]; // UserClaimRewardState\n\n// Definitions (cached after getRewardDefinitions()):\nimport type { RewardDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<RewardDefinitions>(\"Reward\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `reward:definitionsLoaded` → `RewardDefinitions`\n- `reward:userStateLoaded` → `UserRewardState`\n- `reward:milestoneMultiplierLoaded` → `GetMilestoneRewardMultiplierResponse`\n- `reward:dailyClaimed` → `ClaimDailyRewardResponse`\n- `reward:idleCollected` → `CollectIdleAccrualResponse`\n- `reward:comebackClaimed` → `ClaimComebackRewardResponse`\n- `reward:claimed` → `ClaimRewardResponse`\n\nThe coarse `user:rewardUpdated` (and umbrella `user:anyUpdated`) fire on every\nReward cache write — after `getUserRewardsState()` and after each of the four\nclaim/collect calls — handy for a \"re-render everything\" hook. Note\n`getRewardDefinitions()` and `getMilestoneRewardMultiplier()` only patch\n**config**, not user state, so they don't trigger `user:rewardUpdated`.\n\n```ts\nconst off = client.on(\"reward:dailyClaimed\", (r) => {\n console.log(`Day ${r.DayNumber} claimed on calendar ${r.CalendarID}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Daily reward calendar (login streak)\n\n```ts\nawait client.reward.getRewardDefinitions();\nawait client.reward.getUserRewardsState();\n\nconst defs = client.data.config.getSection<RewardDefinitions>(\"Reward\");\nconst cal = defs?.DailyCalendars?.[\"cal1\"];\nconst claimed = client.data.user.state?.Reward?.DailyCalendars?.[\"cal1\"];\n\n// cal.Days[] is the ordered day list; claimed.CollectedDays tells you how\n// many the player has already claimed (used to highlight \"today's\" day —\n// next expected day is CollectedDays + 1, wrapping if cal.IsLooping).\n\nconst res = await client.reward.claimDailyReward(\"cal1\");\nif (!res.ok) return showError(res.error); // e.g. \"already claimed today\"\nconsole.log(res.data.DayNumber, res.data.DailyState.CollectedDays);\n// balances already credited — read from client.data.user cache.\n```\n\nOmit the id to claim the default calendar: `client.reward.claimDailyReward()`.\nA calendar with `ClaimMode: \"CalendarDayUtc\"` (the default) unlocks a new day\nat UTC midnight regardless of when the player claimed yesterday;\n`\"SlidingWindow\"` instead requires `ClaimCooldownSeconds` to elapse since the\nlast claim. Miss the window by more than `MissThresholdMultiplier` (default\n`2.0`) claim-units and `MissBehavior` kicks in — `\"Forgiving\"` (default, no\npenalty), `\"ResetToStart\"` (streak back to day 1), or `\"ResetBy\"` (roll back\n`ResetByDays`, floored at 0). None of this is something to precompute\nclient-side; just show the player's current `CollectedDays` and let the claim\ncall tell you what happened.\n\n### Idle / offline income collection\n\n```ts\nconst res = await client.reward.collectIdleAccrual(\"farm1\");\nif (!res.ok) return showError(res.error); // e.g. \"Nothing to collect yet\"\nres.data.AccruedSeconds; // how many seconds of accrual were paid out (post-cap)\nres.data.AppliedRatePerSecond; // the finalRatePerSecond actually used\nres.data.IdleState.LastCollectAt; // for computing \"time since last collect\" locally\nres.data.IdleState.LastClaimedAmount; // last payout total, cached for UI\n```\n\nShow players an estimate before they tap collect by combining the cached\n`IdleAccrualDefinition.Rate` with elapsed time since\n`IdleState.LastCollectAt` — but treat it as a preview only; the amount\nactually paid is whatever the server returns in `AccruedSeconds` /\n`Resources`. The server clamps elapsed time to `MaxAccumulationSeconds` (0 =\nuncapped) and applies the accrual's own rate formula plus one best-matching\npremium multiplier — see the idle-rate formula in\n[references/data-model.md](references/data-model.md) before trying to mirror\nit in a preview. A collect can also come back \"empty\" as a valid, non-error\nresult (`AccruedSeconds: 0`, no resources) the very first time a player\ntouches a `FirstClaimMode: \"EmptyOnFirstClaim\"` accrual — that's the accrual\ninitializing its clock, not a bug.\n\n### Comeback (\"welcome back\") bonus\n\n```ts\nconst res = await client.reward.claimComebackReward(\"default\");\nif (!res.ok) return showError(res.error); // e.g. \"No pending comeback reward\"\nres.data.ClaimedTierIndex; // which absence tier paid out\nres.data.ComebackState.LastClaimedTierIndex;\n```\n\nA comeback reward is tiered by absence length (`ComebackTier.MinAbsenceSeconds`)\nand only claimable once a pending return is detected — check\n`UserComebackState.PendingTierIndex` / `PendingReturnedAt` in the cached state\n(populated by `getUserRewardsState()`) to decide whether to even show the\ncomeback popup before calling claim. The tier is locked in at the _moment of\nreturn_, not at claim time, specifically so a player can't stall the claim to\nfarm a bigger tier. If `TrackPresenceOnRead` is true (the default) merely\ncalling `getUserRewardsState()` can flip a pending reward into existence —\nrefresh state on app foreground/resume so the popup shows up promptly.\n\n### Generic claim (achievement / quest-style payout)\n\n```ts\nconst res = await client.reward.claimReward(\"first_win\");\nif (!res.ok) return showError(res.error); // e.g. limit/cooldown/window rejection\nres.data.ClaimState.TotalClaims;\nres.data.ClaimState.RecentClaimTimestamps;\n```\n\n`ClaimRewardDefinition.Mode` is `\"Manual\"` (player-initiated, as above) or\n`\"Auto\"` (server-triggered elsewhere in the backend — a client `claimReward`\ncall against an `\"Auto\"` claim is rejected with \"This reward is not claimable\nby client (server-only)\"). Repeat claims are governed by `Limits`\n(`TotalCap`, `MaxPerWindow` + `WindowSeconds`, `CooldownSeconds`, any/all\ncombinable, 0 on an axis = no limit); a rejected repeat claim is normal, not a\nbug — surface `res.error`.\n\n### Milestone reward multiplier\n\n```ts\nawait client.reward.getMilestoneRewardMultiplier();\nif (client.reward.milestoneRewardMultiplierEnabled) {\n const boosted = baseAmount * client.reward.milestoneRewardMultiplier;\n // show as a preview only — the server applies the real multiplier server-side.\n}\n```\n\nThis is a read-only, title-wide modifier (progression-sourced, e.g. by board\nrank or character level) that _other_ systems' milestone rewards get scaled\nby server-side — the Reward module's own four subsystems (daily/idle/comeback/\nclaim) never apply it to their own payouts. Use it only to preview what a\nLeaderboard/Quest/TimedEvent/DealOffer/CommunityChest/Referral milestone bar\nelsewhere in your UI is about to pay out.\n\n## Gotchas\n\n- **No batch claim method on the SDK today, even though the backend has one.**\n `RewardModels.ts` defines a `RewardAction.ClaimRewardsBatch` constant and the\n backend (`RewardV2.ClaimRewardsBatch`) fully implements it, but\n `RewardService` exposes no `claimRewardsBatch` wrapper — call `claimReward`\n once per id until the SDK adds it.\n- **Guard against double-submit.** Each call mints a fresh idempotency-flavored\n `RelatedEntityID` (e.g. `dailyreward_<userID>_<calendarID>_<uuid>`), so two\n separate calls are two real operations — a double-tapped \"Claim\" button can\n double-claim (and get rejected the second time, but only after round-\n tripping). Disable the control while a call is in flight. Firing the same\n endpoint again within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` instead, but don't rely on that for correctness.\n- **IDs are validated locally before any network call — except the daily\n calendar's.** `collectIdleAccrual`, `claimComebackReward`, and `claimReward`\n reject empty ids or ids containing `\".\"`/`\"$\"` with `reason: \"client\"` —\n these characters break MongoDB-style field paths server-side.\n `claimDailyReward`'s `calendarID` is optional and is **not** validated\n client-side the same way; an invalid explicit id still comes back rejected,\n just as a `reason: \"server\"` error instead of `\"client\"`.\n- **Only the four claim/collect calls patch user state.**\n `getRewardDefinitions()` writes to config, and `getMilestoneRewardMultiplier()`\n only updates the in-memory getters — neither touches\n `client.data.user.state?.Reward`, so neither fires `user:rewardUpdated`.\n- **Per-subsystem state is patched independently.** Claiming on one calendar\n only ever writes `Reward.DailyCalendars[thatCalendarID]` (etc. for the other\n three) — it never clears or touches the other subsystems' state, so it's\n safe to mix all four in one screen without stepping on each other's cache.\n- **The idle-accrual equipment bonus knob exists in config but is a\n server-side no-op today.** `IdleRateConfig.EquipmentBonusEnabled` /\n `EquipmentCharacterID` are wired into the rate formula's shape, but the\n backend's current equipment-bonus summation always contributes `0` (it's an\n unfinished internal helper) — don't advertise \"equip gear for more idle\n income\" as a live feature; verify against a live claim response\n (`AppliedRatePerSecond`) before promising it in UI copy.\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 across all four subsystems, the idle-rate formula, tier/limit/gate\nshapes, and the milestone multiplier curve math. Read it when building\nconfig-driven UI (calendar previews, idle-income estimates, comeback tier\ndisplays, claim limit UI) or when an error message points at a config rule you\nneed to understand.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Reward data model — reference\n\nFull shape of the config (Definitions) and player state for all four\nsubsystems, the idle-rate and comeback-tier formulas, the claim-limit rules,\nand the milestone reward multiplier curve. The config side is **strictly\ntyped in the SDK** at the aggregate level — `RewardDefinitions` (and its\ndirectly-nested state types `UserRewardState`, `UserDailyCalendarState`,\n`UserIdleAccrualState`, `UserComebackState`, `UserClaimRewardState`) are\nexported from `@idosgames/core`, so `getRewardDefinitions()` and\n`getSection<RewardDefinitions>(\"Reward\")` give you a concrete type, not\n`unknown`, and the schemas keep `.passthrough()` so a field the backend adds\nlater still round-trips. The deeper nested shapes shown below as plain\n`interface` blocks in this doc (`DailyCalendarDefinition`,\n`IdleAccrualDefinition`, `ComebackRewardDefinition`, `ClaimRewardDefinition`,\n`IdleRateConfig`, `ComebackTier`, `ClaimLimitOverride`,\n`RewardProgressionMultiplierSpec`, …) are reachable structurally through\n`RewardDefinitions`' fields (e.g. `defs.DailyCalendars![\"cal1\"]` is a fully\ntyped `DailyCalendarDefinition`), but — unlike some other modules' definition\ntypes — most of them are **not individually exported by name** from\n`@idosgames/core`'s public entry point today; don't write `import type {\nDailyCalendarDefinition } from \"@idosgames/core\"`, destructure/annotate from\nthe parent `RewardDefinitions` type instead (or use `RewardDefinitions[\"DailyCalendars\"]`\nstyle indexed-access types if you need the standalone name). Per-user state\nobjects beyond the top-level four dictionaries are typed as lenient\npassthrough shapes on the SDK side — the fields documented below are what the\nbackend actually puts on them. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Root config: RewardDefinitions](#root-config-rewarddefinitions)\n- [Tier-reward settings](#tier-reward-settings)\n- [Daily calendars](#daily-calendars) — config, state, claim-mode/miss-behavior math\n- [Idle accruals](#idle-accruals) — config, state, the rate formula\n- [Comeback rewards](#comeback-rewards) — config, state, tier-selection + pending lifecycle\n- [Claim rewards](#claim-rewards) — config, state, limit resolution\n- [Milestone reward multiplier](#milestone-reward-multiplier) — curve math, rounding, targeting\n- [Shared plumbing](#shared-plumbing) — SegmentGate, LimitSpec, ResourceGrant, availability windows\n\n---\n\n## Root config: RewardDefinitions\n\nReturned by `getRewardDefinitions()` as `{ RewardDefinitions }`; cached via\n`client.data.config.getSection<RewardDefinitions>(\"Reward\")`. Source:\n`RewardDefinitions.cs`.\n\n```ts\ninterface RewardDefinitions {\n TierRewards?: TierRewardSettings | null;\n MilestoneRewardMultiplier?: RewardProgressionMultiplierSpec | null;\n DailyCalendars?: Record<string, DailyCalendarDefinition> | null;\n IdleAccruals?: Record<string, IdleAccrualDefinition> | null;\n Comebacks?: Record<string, ComebackRewardDefinition> | null;\n Claims?: Record<string, ClaimRewardDefinition> | null;\n}\n```\n\nEach of the four dictionaries is an **independent subsystem** — a title can\nuse only some of them; an empty/absent dictionary just means that subsystem is\noff. All four grant rewards through the same `ResourceGrant`, so premium\nbonuses/tier overlays (`PremiumBonuses`, `PremiumTiers`) work uniformly across\nall of them via `ResourceService` — see [Shared plumbing](#shared-plumbing).\n\nPlayer state is returned by `getUserRewardsState()` as `{ Rewards }`; cached at\n`client.data.user.state?.Reward`. Source: `UserRewardState.cs`.\n\n```ts\ninterface UserRewardState {\n DailyCalendars?: Record<string, UserDailyCalendarState>;\n IdleAccruals?: Record<string, UserIdleAccrualState>;\n Comebacks?: Record<string, UserComebackState>;\n Claims?: Record<string, UserClaimRewardState>;\n}\n```\n\nAn absent entry in any of the four dictionaries means \"player never touched\nthis ID\" — the server treats it as default/zero state, not an error.\n\n---\n\n## Tier-reward settings\n\n`RewardDefinitions.TierRewards` — **global, title-wide** rules for how tiered\nrewards resolve across _every_ system that has tiers (premium, season, battle\npass, etc.), not just Reward itself. One mode per title.\n\n```ts\ninterface TierRewardSettings {\n RewardMode?: \"Additive\" | \"Replace\"; // default: Additive\n RewardStackLowerTiers?: boolean; // default: false\n}\n```\n\n- `Additive` — tier rewards are added **on top of** the base reward.\n- `Replace` — tier rewards **fully replace** the base reward.\n- `RewardStackLowerTiers: true` — a player at tier 5 gets tiers 1..5 merged;\n `false` (default) — only the best matching tier applies.\n\nThis block is read by `ResourceService`, not by Reward's own claim logic\ndirectly — it's here because `RewardDefinitions` is where it's configured.\n\n---\n\n## Daily calendars\n\n### Config: `DailyCalendarDefinition`\n\n```ts\ninterface DailyCalendarDefinition {\n CalendarID?: string; // key in DailyCalendars; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Days?: DailyRewardDay[]; // day numbers must be unique, starting at 1\n IsLooping?: boolean; // default true: loop back to day 1 after the last day\n MissBehavior?: \"Forgiving\" | \"ResetToStart\" | \"ResetBy\"; // default Forgiving\n ResetByDays?: number; // used only with MissBehavior = \"ResetBy\"\n MissThresholdMultiplier?: number; // default 2.0\n ClaimMode?: \"CalendarDayUtc\" | \"SlidingWindow\"; // default CalendarDayUtc\n ClaimCooldownSeconds?: number; // used only with ClaimMode = \"SlidingWindow\"\n Gate?: SegmentGate; // null/empty = everyone\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface DailyRewardDay {\n DayNumber?: number; // 1-based, unique per calendar\n Rewards?: ResourceGrant;\n IsMilestone?: boolean; // UI hint only (e.g. highlight day 7/14/30); no server effect\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserDailyCalendarState`\n\n```ts\ninterface UserDailyCalendarState {\n CalendarID?: string;\n CollectedDays: number; // days claimed in the current \"run\"; next day = CollectedDays + 1\n LastClaimAt: string; // ISO; \"0001-01-01T00:00:00\" (DateTime.MinValue) = never claimed\n}\n```\n\n### Claim eligibility (`ClaimMode`)\n\nSource: `RewardV2.IsDailyClaimAvailable` (`Reward.cs`).\n\n- **`CalendarDayUtc`** (default): a new claim is available once\n `now.Date > LastClaimAt.Date` (UTC calendar day comparison). Rejects with\n `\"Daily reward already claimed today for this calendar\"` if the player\n already claimed on today's UTC date. Ignores player timezone.\n- **`SlidingWindow`**: a new claim is available once\n `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`. Rejects with\n `\"Daily reward is on cooldown. Try again in {n}s\"` otherwise. If\n `ClaimCooldownSeconds <= 0`, there is no cooldown at all.\n\n### Miss detection and `MissBehavior`\n\nSource: `RewardV2.ApplyMissBehavior`. Runs on _every_ claim after the\nfirst, before the new day is computed.\n\n- Effective threshold: `MissThresholdMultiplier` if `> 0`, else `2.0`.\n- **Miss condition** (was the gap too large?):\n - `CalendarDayUtc`: miss if `(today - LastClaimAt.Date).TotalDays > threshold`.\n - `SlidingWindow`: miss if `(now - LastClaimAt).TotalSeconds > max(1, ClaimCooldownSeconds) * threshold`.\n- **On miss**, `CollectedDays` becomes:\n - `Forgiving` (default) — unchanged (soft streak; only the skipped days'\n rewards are forfeited, the streak count itself survives).\n - `ResetToStart` — `0` (hard streak reset).\n - `ResetBy` — `max(0, CollectedDays - ResetByDays)` (partial penalty, floored\n at 0).\n- **No miss** → `CollectedDays` unchanged going into the day-resolution step.\n\n### Day resolution\n\n`dayToReward = collectedAfterMiss + 1`. If `dayToReward` exceeds the highest\nconfigured `DayNumber`: loops back to `((dayToReward - 1) % maxDayNumber) + 1`\nwhen `IsLooping` is true, otherwise the claim fails with `\"Daily rewards\ncalendar finished\"`. The new `CollectedDays` after a successful claim is\n`collectedAfterMiss + 1` (i.e. it keeps counting past `maxDayNumber` even when\nlooping — only the _day looked up_ wraps, not the counter).\n\nDefault-calendar resolution when `calendarID` is omitted: the server uses\n`DefaultData.Default` if that key exists in `DailyCalendars`, otherwise falls\nback to the first entry in the dictionary.\n\n---\n\n## Idle accruals\n\n### Config: `IdleAccrualDefinition`\n\n```ts\ninterface IdleAccrualDefinition {\n AccrualID?: string; // key in IdleAccruals; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Rate?: IdleRateConfig;\n Rewards?: ResourceGrant; // Standard entries are PER-SECOND unit amounts, scaled at claim time\n MaxAccumulationSeconds: number; // 0 = uncapped (long-run economy risk, by design)\n MinClaimSeconds: number; // 0 = no anti-spam floor between claims\n Requirements?: IdleAccrualRequirements;\n FirstClaimMode?:\n \"EmptyOnFirstClaim\" | \"InitOnFirstAccess\" | \"AccruedFromConfigStart\"; // default EmptyOnFirstClaim\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface IdleRateConfig {\n BaseRatePerSecond: number; // flat, unconditional\n PowerCoefficient: number; // 0 disables; else + PowerCoefficient * UserPublicDataModel.Power\n BoardRankCoefficient: number; // 0 disables; else + BoardRankCoefficient * UserPublicDataModel.BoardRank\n EquipmentBonusEnabled?: boolean; // see note below — currently a no-op server-side\n EquipmentCharacterID?: string; // default DefaultData.Main (\"Main\") when empty\n PremiumMultipliers?: PremiumTierMultiplier[]; // ONE best match applied, not stacked\n}\n\ninterface PremiumTierMultiplier {\n MinPremiumTier?: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n Multiplier?: number;\n}\n\ninterface IdleAccrualRequirements {\n MinCharacterLevel: number; // 0 = not checked\n RequirementsCharacterID?: string; // default DefaultData.Main when empty\n Gate?: SegmentGate; // null/empty = not checked\n RequiredItemIDs?: string[]; // each must have inventory TotalAmount > 0\n RequiredEquippedItemIDs?: string[]; // each must be equipped on RequirementsCharacterID\n}\n```\n\n### State: `UserIdleAccrualState`\n\n```ts\ninterface UserIdleAccrualState {\n AccrualID?: string;\n LastCollectAt: string; // ISO; MinValue = never collected — meaning depends on FirstClaimMode\n LastClaimedAmount: number; // denormalized cache of the last payout total (0 pre-first-claim)\n LastClaimedRate: number; // denormalized cache of the last finalRatePerSecond\n}\n```\n\n### The rate formula (verified against `RewardV2.ComputeIdleFinalRate`, `Reward.cs`)\n\n```\nrawRate = BaseRatePerSecond\n + (PowerCoefficient > 0 ? PowerCoefficient * user.PublicData.Power : 0)\n + (BoardRankCoefficient > 0 ? BoardRankCoefficient * user.PublicData.BoardRank : 0)\n + (EquipmentBonusEnabled ? sum(equipped-item.IdleRateBonus on EquipmentCharacterID) : 0)\n\nbestPremiumMultiplier = the ONE PremiumTierMultiplier with the highest\n MinPremiumTier <= player's MaxActiveTier (and matching\n RequiredPremiumID if set); 1.0 if none match or premium is null\n — multipliers never stack.\n\nfinalRatePerSecond = rawRate * bestPremiumMultiplier // if bestPremiumMultiplier <= 0, treated as 1.0\n```\n\nIf `finalRatePerSecond <= 0`, the claim fails with `\"Effective rate is zero\"`.\n\n**Equipment bonus is currently a server-side no-op.** `ComputeIdleFinalRate`\ncalls a helper (`SumEquipmentIdleBonus`) that is stubbed to always return `0`\nregardless of `EquipmentBonusEnabled`/equipped items — the item-definition\nlookup needed to read each item's `IdleRateBonus` isn't wired up at that call\nsite yet. The config fields exist and round-trip, but don't promise \"gear\nboosts idle income\" in product copy until this is verified live via\n`AppliedRatePerSecond` in a real claim response.\n\n### Accrued time and payout (verified against `RewardV2.CollectIdleAccrual`)\n\n```\neffectiveStart = LastCollectAt, if LastCollectAt != MinValue\n = otherwise, resolved by FirstClaimMode:\n - \"AccruedFromConfigStart\" → AvailableFromUtc ?? now\n - \"InitOnFirstAccess\" / \"EmptyOnFirstClaim\" → now\n\nelapsedSeconds = max(0, now - effectiveStart) in seconds\naccruedSeconds = MaxAccumulationSeconds > 0\n ? min(elapsedSeconds, MaxAccumulationSeconds)\n : elapsedSeconds\n```\n\n- `MinClaimSeconds` gate: if `> 0` and the player has claimed before, and\n `now - LastCollectAt < MinClaimSeconds`, the claim fails with `\"Too soon.\nTry again in {n}s\"`.\n- If `accruedSeconds <= 0` **and** this is the very first claim **and**\n `FirstClaimMode == \"EmptyOnFirstClaim\"`: the server does a special\n zero-payout finalize — sets `LastCollectAt = now`, returns\n `AccruedSeconds: 0`, `AppliedRatePerSecond: 0`, and an empty `Resources`.\n This is a **success**, not an error — it's the accrual \"starting its clock.\"\n- Otherwise, if `accruedSeconds <= 0`: fails with `\"Nothing to collect yet\"`.\n- **Payout scaling**: every `Amount` in `Rewards.Standard.Entries` (items,\n currencies) and `Rewards.Standard.EventTokens` is multiplied by\n `accruedSeconds * finalRatePerSecond`, then rounded with `Math.Round`\n (banker's/round-half-to-even at the .5 boundary, per .NET `Math.Round`\n default). Any entry whose scaled amount rounds to `<= 0` is dropped from the\n grant entirely. `PremiumBonuses`/`PremiumTiers` on `Rewards` pass through\n unscaled and are applied afterward by `ResourceService` as usual.\n- `UserIdleAccrualState.LastClaimedAmount` in the response is the **sum of all\n scaled Standard entry amounts** (not event tokens), for UI/analytics only.\n\n### Requirements gate (checked every claim, not persisted)\n\nAll set conditions are ANDed (source: `RewardV2.CheckIdleAccrualRequirements`):\n\n- `Gate` (SegmentGate) must pass, else `\"Idle accrual is locked behind a\nhigher premium tier\"`.\n- `MinCharacterLevel > 0` → the character at `RequirementsCharacterID`\n (default `\"Main\"`) must have `Level >= MinCharacterLevel`, else `\"Character\n'{id}' level {n} is below required {m}\"`.\n- `RequiredItemIDs` → each must have inventory `TotalAmount > 0`, else\n `\"Required item '{id}' is not in inventory\"`.\n- `RequiredEquippedItemIDs` → each must be equipped somewhere on\n `RequirementsCharacterID`, else `\"Required item '{id}' is not equipped on\n'{charID}'\"`.\n\nFailing a requirement does **not** move `LastCollectAt` — once the\nrequirement is met again, the previously-accrued time (up to the cap) is still\ncollectible.\n\n---\n\n## Comeback rewards\n\n### Config: `ComebackRewardDefinition`\n\n```ts\ninterface ComebackRewardDefinition {\n ComebackID?: string; // key in Comebacks; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tiers?: ComebackTier[];\n ClaimCooldownSeconds: number; // min seconds between consecutive claims of THIS comeback; 0 = none\n ClaimWindowSeconds: number; // seconds a pending reward stays claimable after return; 0 = forever\n TrackPresenceOnRead?: boolean; // default true\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ComebackTier {\n MinAbsenceSeconds: number; // threshold vs (now - LastSeenAt) at the moment of return\n Rewards?: ResourceGrant;\n AssetPaths?: Record<string, string>;\n}\n```\n\n### State: `UserComebackState`\n\n```ts\ninterface UserComebackState {\n ComebackID?: string;\n LastSeenAt: string; // ISO; MinValue = first-ever contact (initializes to now, no absence check)\n LastClaimAt: string; // ISO; MinValue = never claimed\n LastClaimedTierIndex: number; // -1 = never claimed; UI/analytics only\n PendingReturnedAt?: string | null; // set when a return is detected; null = nothing pending\n PendingTierIndex?: number | null; // tier locked in at the moment PendingReturnedAt was set\n}\n```\n\n### Presence tracking and pending lifecycle (`RewardV2.ApplyComebackPresenceTick`)\n\nRuns on **every** claim call for this comeback, and also on\n`getUserRewardsState()` whenever `TrackPresenceOnRead` is true (the default):\n\n1. First-ever contact (`LastSeenAt == MinValue`): set `LastSeenAt = now` and\n stop — no absence to evaluate yet.\n2. If a pending reward already exists (`PendingReturnedAt` + `PendingTierIndex`\n both set): if `ClaimWindowSeconds > 0` and\n `(now - PendingReturnedAt).TotalSeconds > ClaimWindowSeconds`, the pending\n reward **expires** — both fields are cleared. (`ClaimWindowSeconds <= 0`\n means it never expires on its own.)\n3. Otherwise (no pending yet): compute `absenceSeconds = now - LastSeenAt`.\n Pick the tier with the **largest** `MinAbsenceSeconds` that is\n `<= absenceSeconds` (i.e. the best-matching, not-necessarily-first tier —\n ties broken by taking the higher threshold). If a tier matches AND the\n cooldown has cleared (`LastClaimAt == MinValue`, or `ClaimCooldownSeconds\n<= 0`, or `(now - LastClaimAt).TotalSeconds >= ClaimCooldownSeconds`),\n lock in `PendingReturnedAt = now` and `PendingTierIndex = thatTier`.\n4. `LastSeenAt` is always advanced to `now` at the end of the tick.\n\nThe tier is deliberately locked at the **moment of return**, not at claim\ntime — this stops a player from delaying the claim to try to \"grow into\" a\nricher tier.\n\n### Claim (`RewardV2.ClaimComebackReward`)\n\nRequires `PendingReturnedAt` and `PendingTierIndex` both non-null, else fails\nwith `\"No pending comeback reward\"`. On success: grants `Tiers[tierIndex]\n.Rewards`, sets `LastSeenAt = now`, `LastClaimAt = now`,\n`LastClaimedTierIndex = tierIndex`, and clears both `Pending*` fields. The\nidempotency/concurrency guard is keyed off the exact `PendingReturnedAt`\ntimestamp, so a stale pending anchor from a concurrent request can't be\ndouble-spent.\n\n---\n\n## Claim rewards\n\n### Config: `ClaimRewardDefinition`\n\n```ts\ninterface ClaimRewardDefinition {\n ClaimID?: string; // key in Claims; no '.' or '$'\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Mode?: \"Manual\" | \"Auto\"; // default Manual; Auto rejects client claimReward calls\n Rewards?: ResourceGrant;\n Limits?: LimitSpec; // see below — all axes optional/combinable, 0 = no limit on that axis\n PremiumLimitOverrides?: ClaimLimitOverride[]; // ONE best match applied, not stacked\n Gate?: SegmentGate;\n AvailableFromUtc?: string;\n AvailableUntilUtc?: string;\n}\n\ninterface ClaimLimitOverride {\n MinPremiumTier: number;\n RequiredPremiumID?: string; // null = any subscription at MinPremiumTier\n CooldownSeconds?: number | null; // null = don't override; positive = override; base 0 clears\n MaxClaimsPerWindow?: number | null; // null = don't override; 0 = remove the limit for this tier\n WindowSeconds?: number | null;\n TotalClaimLimit?: number | null;\n}\n```\n\n`LimitSpec` (shared block, `Core/Limits/Models/LimitSpec.cs`) as used here maps\n`TotalCap` → total-claim cap, `MaxPerWindow` + `WindowSeconds` → sliding-window\ncap, `CooldownSeconds` → minimum gap between claims. `DailyCap`,\n`DailyWeightCap`, and `PerActivationCap` are part of the shared `LimitSpec`\nshape but are **not read** by `RewardV2.PrepareClaimReward` — only\n`TotalCap`/`MaxPerWindow`/`WindowSeconds`/`CooldownSeconds` are enforced here.\n\n### State: `UserClaimRewardState`\n\n```ts\ninterface UserClaimRewardState {\n ClaimID?: string;\n TotalClaims: number; // monotonically increasing; never resets\n RecentClaimTimestamps?: string[]; // ISO, ascending; only populated when MaxPerWindow+WindowSeconds are set\n LastClaimAt: string; // ISO; MinValue = never claimed\n}\n```\n\n### Limit resolution (`RewardV2.ResolveEffectiveClaimLimits`)\n\nBase limits come from `Limits`. If `PremiumLimitOverrides` is non-empty and\nthe player has an active premium tier, the **one** override with the highest\n`MinPremiumTier <= player tier` (matching `RequiredPremiumID` if set) wins —\noverrides never stack. Each of that override's four fields is applied only if\nnon-null; a null field falls back to the base `Limits` value, not to \"no\nlimit.\"\n\n### Claim validation order (`RewardV2.PrepareClaimReward`)\n\n1. Claim exists in config, `Mode == \"Manual\"` (else `\"This reward is not\nclaimable by client (server-only)\"`), and `Rewards` is configured.\n2. Availability window (`AvailableFromUtc`/`AvailableUntilUtc`).\n3. `Gate` passes (else `\"Reward is locked behind a higher premium tier\"`).\n4. Resolve effective limits (base + best override).\n5. `TotalClaimLimit > 0 && TotalClaims >= TotalClaimLimit` →\n `\"Total claim limit reached ({have}/{limit})\"`.\n6. `CooldownSeconds > 0` and elapsed-since-last-claim `< CooldownSeconds` →\n `\"Reward is on cooldown. Try again in {n}s\"`.\n7. `MaxClaimsPerWindow > 0 && WindowSeconds > 0`: filter\n `RecentClaimTimestamps` to those `> now - WindowSeconds`; if the filtered\n count `>= MaxClaimsPerWindow` → `\"Window limit reached ({have}/{limit} per\n{window}s)\"`.\n8. On success, `now` is appended to the window list, then the list is\n trimmed to `min(MaxClaimsPerWindow, 100)` entries (a hard server-side cap\n on stored history — `CLAIM_HISTORY_HARD_CAP = 100` — regardless of how\n large a designer sets `MaxClaimsPerWindow`; older entries are dropped\n first). `TotalClaims` increments by 1 regardless of window/cooldown\n settings.\n\n`Mode: \"Auto\"` claims are for server-triggered payouts (background jobs, GM\ngrants, anti-fraud compensation) — there is no client path to trigger them; a\nclient `claimReward` call against one is always rejected.\n\n### Batch claiming (backend-only today)\n\n`RewardV2.ClaimRewardsBatch` (action `ClaimRewardsBatch`) exists server-side:\nit dedupes `ClaimIDs` (ordinal string comparison), clamps to\n`BatchSupport.MaxBatchSize`, validates + resolves each id's grant\nindependently (invalid/ineligible ids are filtered out and reported before any\ncharge), then applies the merged valid set as a single atomic operation with\none combined `Resources` payload attached to the first successful result\nelement and empty ones on the rest — the same `BatchItemResult<T>[]`\npartial-aware pattern used by Character/Leaderboard batch endpoints. As of\nthis SDK version, `RewardService` has no `claimRewardsBatch` wrapper method,\nso this path is not reachable from the TS client yet.\n\n---\n\n## Milestone reward multiplier\n\n`RewardDefinitions.MilestoneRewardMultiplier` is a\n`RewardProgressionMultiplierSpec` (shared block, also used by Lootbox — see\n`_shared/MilestoneModels.ts`). It is **not** applied by any of Reward's own\nfour subsystems; it's a title-wide overlay that other milestone-bearing\nsystems (TimedEvent, Leaderboard, DealOffer, Quest, CommunityChest, Referral)\napply to their own milestone payouts via `MilestoneRewardResolver`, as the\n_last_ overlay in their reward-resolution chain.\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: ProgressionSource; // metric the multiplier is driven by\n SourceKey?: string; // disambiguator when Source needs one\n CurveType?: \"Tiered\" | \"Linear\"; // default Tiered\n Tiers?: { AtProgress?: number; Multiplier?: number }[]; // used when CurveType = Tiered\n TierMode?: \"Step\" | \"Linear\"; // interpolation BETWEEN tier breakpoints; default Step\n BaseMultiplier?: number; // floor value below the first tier / Linear curve's base\n PerUnit?: number; // used when CurveType = Linear\n Anchor?: number; // used when CurveType = Linear\n MinMultiplier?: number; // hard floor after evaluation\n MaxMultiplier?: number; // hard ceiling after evaluation; <= 0 = no ceiling\n IncludeRewards?: ResourceBundle; // empty/absent = applies to every reward entry\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\n`ProgressionSource` values (from `MilestoneModels.ts` /\n`Core/Milestone/Models/RewardProgressionMultiplierSpec.cs`): `BoardStageLevel`,\n`BoardRank`, `BoardCyclesCompleted`, `CharacterLevel`, `SeasonTier`,\n`EventTokenTotalEarned`, `VirtualCurrencyBalance`, `PlayerLevel`.\n\n### Multiplier curve (`RewardProgressionResolver.EvaluateMultiplier`)\n\n```\nif spec == null: multiplier = 1.0 (Enabled = false in the response)\n\nif CurveType == \"Linear\":\n raw = BaseMultiplier + PerUnit * max(0, progress - Anchor)\n\nelse (CurveType == \"Tiered\", default):\n tiers sorted ascending by AtProgress\n if no tiers: raw = BaseMultiplier\n else if progress < tiers[0].AtProgress: raw = BaseMultiplier\n else if progress >= tiers[last].AtProgress: raw = tiers[last].Multiplier\n else: find bracketing tiers (lo, hi) where progress falls between AtProgress values\n if TierMode == \"Linear\": raw = lo.Multiplier + frac * (hi.Multiplier - lo.Multiplier)\n where frac = (progress - lo.AtProgress) / (hi.AtProgress - lo.AtProgress)\n else (\"Step\", default): raw = lo.Multiplier\n\nfinal = clamp(raw): NaN/Infinity -> 1.0; then floor at MinMultiplier;\n then ceiling at MaxMultiplier only if MaxMultiplier > 0\n```\n\n`GetMilestoneRewardMultiplier()` returns `Enabled: false, Multiplier: 1.0,\nProgress: 0` when no spec is configured; otherwise `Enabled: true` with the\nlive `Multiplier`, the raw `Progress` value read from the player's current\nprogression state, and echoes of `Source`/`SourceKey`.\n\n### How the multiplier is actually applied to a reward (for context — not something Reward itself calls)\n\n`RewardProgressionResolver.Apply(grant, spec, mult)`: if `mult` is within\n`1e-9` of `1.0`, the grant passes through unchanged (no-op fast path).\nOtherwise, every matching `ResourceEntry.Amount` (and event-token `Amount`) in\n`grant.Standard` and in each `PremiumTierBundle.Resources` is scaled via the\nplatform's canonical `ModifierService.Apply`, which for a pure multiply step\ncomputes `Ceiling(amount * mult)` clamped to `[0, long.MaxValue]` — a\n**different rounding rule than idle-accrual's `Math.Round`**. An entry\nmatches the spec's targeting when: it is **not** present in `ExcludeRewards`\n(checked first, always wins), AND (`IncludeRewards` is empty/absent — meaning\n\"apply to everything\" — OR the entry is present in `IncludeRewards`).\nMatching for items is by `ItemID`; for currencies/event-tokens, by\n`CurrencyID`/token `EntityID`. `PremiumBonuses` (percentage-based) are\nuntouched by this step — they're applied afterward, on top of the\nalready-scaled `Standard` bundle, by `ResourceService`.\n\n---\n\n## Shared plumbing\n\nThese blocks are reused by all four subsystems (and the rest of the\nplatform) — full details live in their own modules; summarized here only as\nthey affect Reward.\n\n- **`SegmentGate`** (`_shared/SegmentModels.ts`) — the audience/premium gate\n used by `Gate` fields on `DailyCalendarDefinition`,\n `IdleAccrualRequirements`, `ComebackRewardDefinition`, and\n `ClaimRewardDefinition`. Includes `MinPremiumTier` / `RequiredPremiumIDs`\n among its conditions. Resolved server-side via `SegmentGateEvaluator.Passes`;\n a failing gate always surfaces as `reason: \"server\"` with a\n \"locked behind a higher premium tier\"-style message — there is no\n client-visible breakdown of _which_ gate condition failed.\n- **`LimitSpec`** (`_shared/LimitModels.ts`) — the generic \"how much / how\n often\" spec. Reward's `ClaimRewardDefinition.Limits` only consumes\n `TotalCap`, `MaxPerWindow`, `WindowSeconds`, `CooldownSeconds` — the other\n two axes (`DailyCap`, `DailyWeightCap`, `PerActivationCap`) are part of the\n shared type but ignored by `RewardV2`.\n- **`ResourceGrant` / `ResourceOperation`** (`currency-system` skill) — every\n subsystem's `Rewards` field and every claim response's `data.Resources` use\n these. `ResourceGrant.Standard.Entries[].Amount` is nullable at the schema\n level (`zVcAmount.nullish()`), but a granted entry always carries a concrete\n amount by the time it reaches the client.\n- **Availability windows** — `AvailableFromUtc` / `AvailableUntilUtc` on every\n one of the four definition types follow the same rule:\n `now < AvailableFromUtc` → `\"Reward is not yet available\"`;\n `now >= AvailableUntilUtc` → `\"Reward is no longer available\"`. Either or\n both may be absent for \"no bound.\"\n- **Dynamic-key validation** — every dictionary key used as a Mongo path\n segment (`CalendarID`, `AccrualID`, `ComebackID`, `ClaimID`) is rejected\n server-side if it contains `.` or `$`; the SDK mirrors this client-side for\n the three id-taking methods (not `claimDailyReward`'s optional\n `calendarID`) so you get an instant `reason: \"client\"` instead of a round\n trip for the common typo case.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "season-system",
|
|
3
|
+
"description": "Build a season / battle-pass-style meta-progression system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService): load season chain definitions, fetch the currently active season in a chain, load the player's per-chain season state, grant status tokens (season XP/points) that advance a tier track, and claim a reached tier's reward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a season pass, battle pass, status track, tier-reward system, seasonal meta-progression, or otherwise touches client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition, SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: season-system\ndescription: >-\n Build a season / battle-pass-style meta-progression system in a game on the\n iDosGames TypeScript SDK (@idosgames/core) via client.season (SeasonService):\n load season chain definitions, fetch the currently active season in a chain,\n load the player's per-chain season state, grant status tokens (season\n XP/points) that advance a tier track, and claim a reached tier's reward. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants a season pass, battle pass, status\n track, tier-reward system, seasonal meta-progression, or otherwise touches\n client.season, SeasonService, SeasonDefinitions, SeasonChainDefinition,\n SeasonTierDefinition, ActiveSeasonInfo, or UserSeasonState — even if they\n don't name the module explicitly.\n---\n\n# Season system (iDosGames TS SDK)\n\nThe Season module is a battle-pass-style meta-progression track: a title\ndefines one or more **season chains**, each chain runs a sequence of\n**seasons** back to back (and cycles again after the last one), and each\nseason has a ladder of **tiers** the player climbs by earning **status\ntokens** (season XP/points). Reaching a tier unlocks that tier's reward, which\nthe player then claims. Everything is **server-authoritative**: the client\nasks the backend to grant tokens or claim a reward, the backend validates and\napplies it, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate season state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `SeasonService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(already claimed, tier not reached, wrong access mode, not logged in) —\nsurface the error, don't try to reproduce the check client-side.\n\n## The three data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n season chains: `SeasonDefinitions.Chains`, keyed by `SeasonChainID`. Each\n chain (`SeasonChainDefinition`) has a `Schedule`, an optional segment\n `Gate`, and an ordered list of `Seasons` (`SeasonDefinition`), each with a\n `DurationSec` and its own `Tiers` (`SeasonTierDefinition[]`). Fetched with\n `getDefinitions()`.\n2. **Active season info** (config + a state slice, per chain) — which season\n in the chain is live _right now_, its computed start/end, seconds\n remaining, and the next tier the player hasn't reached. Fetched per chain\n with `getActiveSeason(seasonChainID)`.\n3. **User season state** (state, per player, per chain) — this player's\n progress in one chain: `CurrentTier`, `ClaimedTierRewards`, which season\n version they're on. Fetched with `getUserState(seasonChainID)`, and also\n embedded in `ActiveSeasonInfo.UserState`.\n\nA season chain is identified by a string `SeasonChainID`; a season inside it\nby `SeasonID`; a tier by its plain `Tier` number, where `1` is the always-on\nbase tier (reached with 0 tokens). There's a single reward track per tier\n(`SeasonTierDefinition.TierReachedReward`) — no separate free/premium track\nsplit in this module.\n\n**Status tokens** are the season's XP/points currency, tracked internally\nthrough the same Core/EventToken ledger every other event-token currency\nuses. Calling `grantStatusTokens` adds an amount and the backend recomputes\n`CurrentTier` from the new cumulative total against the season's `Tiers`\nladder (`RequiredTokens` per tier — highest tier whose threshold is met\nwins). Granting is a distinct step from claiming — advancing a tier does not\nauto-claim its reward; the player (or your UI) calls `claimTierReward`\nseparately for each tier they want to collect.\n\nOnly `SeasonDefinitions` (the root config type) is re-exported from the\npackage root; `SeasonChainDefinition` / `SeasonDefinition` /\n`SeasonTierDefinition` are not directly importable — read them off the\nresolved `SeasonDefinitions` tree instead. See\n[references/data-model.md](references/data-model.md) for the full shape, the\nexact tier-threshold algorithm, season-rollover (\"Wipe\") semantics, and how\nthe season-tier reward overlay used by _other_ modules (Leaderboard, Reward,\nReferral, Quest milestones) relates to (and is separate from) this module's\nown `TierReachedReward`. You do **not** need it to call the methods — only to\ndrive richer UI or understand a cross-module reward scaling feature.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst seasons = client.season; // the SeasonService\n```\n\nEvery season method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing `seasonChainID` or a non-positive amount/tier\nnumber), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside\nthe throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"No active\nseason in this chain.\", \"Tier 3 not reached yet. Current tier: 2.\", \"Tier 3\nreward already claimed.\").\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- |\n| `getDefinitions()` | Load the title's season chain catalog (config). | `SeasonDefinitions` |\n| `getActiveSeason(seasonChainID)` | Load the chain's currently-live season + this player's state in it. | `ActiveSeasonInfo` |\n| `getUserState(seasonChainID)` | Load this player's progress in one chain (state only). | `UserSeasonStateResponse` (= `UserSeasonState`) |\n| `grantStatusTokens(seasonChainID, amount)` | Add status tokens (season XP/points); may bump `CurrentTier`. | `GrantStatusTokensResponse` (`NewTier`, `TierUp`) |\n| `claimTierReward(seasonChainID, tierNumber)` | Claim a reached tier's reward (one-time per tier). | `ClaimTierRewardResponse` (`Resources`) |\n\nThere are no batch methods on this module — each call operates on one season\nchain (and, for claims, one tier) at a time.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `claimTierReward`'s\ngranted resources ride along in `data.Resources` (a shared `ResourceOperation`\n— see `packages/core/src/models/_shared/ResourceModels.ts`) and are already\napplied to the cached currency/item balances, so read updated balances\nstraight from the cache.\n\n**`grantStatusTokens` is access-gated per chain**, not just by auth. Each\nchain's config sets `GrantTokensAccessMode` (`\"ServerOnly\"` | `\"ClientOnly\"` |\n`\"Both\"`, default `\"ServerOnly\"`). If a chain is `\"ServerOnly\"` — the typical\nproduction setup for tokens that should only come from tournament results,\nmatch wins, or quest completion — the client-facing call is rejected outright\nwith `\"GrantStatusTokens cannot be called from client for this chain.\"` before\nit even looks at your amount. A rejection here usually means \"wrong access\nmode for this chain's design,\" not a bug in your integration.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { SeasonDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<SeasonDefinitions>(\"Season\");\nconst chain = defs?.Chains?.[\"battle_pass_main\"];\nchain?.Seasons; // ordered SeasonDefinition[] for this chain\n\n// Per-chain user state (present after getUserState()/getActiveSeason()/a grant/claim):\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\nstate?.CurrentTier; // highest tier reached\nstate?.ClaimedTierRewards; // number[] of tier numbers already claimed\nstate?.CurrentSeasonID; // which season within the chain\n```\n\nThere's no separate cached \"active season\" slot — `ActiveSeasonInfo` (the\nlive season, its `Tiers`, computed dates, `NextTier`) is only available from\nthe `getActiveSeason` call's own return value; only its embedded `UserState`\ngets written into `client.data.user.state.Season`. Keep the last\n`ActiveSeasonInfo` you fetched in your own component/store if you need to\nrender the ladder alongside cached progress.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `season:definitionsLoaded` → `SeasonDefinitions`\n- `season:activeLoaded` → `ActiveSeasonInfo`\n- `season:userStateLoaded` → `UserSeasonStateResponse`\n- `season:statusTokensGranted` → `GrantStatusTokensResponse`\n- `season:tierRewardClaimed` → `ClaimTierRewardResponse`\n\nThe coarse `user:seasonUpdated` (and `user:anyUpdated`) also fire on any\nseason cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"season:statusTokensGranted\", (r) => {\n if (r.TierUp) console.log(`Reached tier ${r.NewTier}!`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load a chain, show the ladder, and claim a reached tier\n\n```ts\nawait client.season.getDefinitions();\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (!active.ok) return showError(active.error); // e.g. \"No active season in this chain.\"\n\nconst { Season, NextTier, SecondsRemaining } = active.data;\nconst currentTier =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]?.CurrentTier ??\n 0;\nconst claimed =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.ClaimedTierRewards ?? [];\n\nfor (const tier of Season?.Tiers ?? []) {\n const reached = (tier.Tier ?? 0) <= currentTier;\n const alreadyClaimed = claimed.includes(tier.Tier ?? -1);\n // reached && !alreadyClaimed -> show a \"Claim\" button for this tier.\n}\n\nif (currentTier >= 1 && !claimed.includes(1)) {\n const res = await client.season.claimTierReward(\"battle_pass_main\", 1);\n if (!res.ok) return showError(res.error); // e.g. \"Tier 1 reward already claimed.\"\n // res.data.Resources already applied to cached balances.\n}\n```\n\n`getActiveSeason` fails with `\"No active season in this chain.\"` both when the\nchain is fully inactive/misconfigured and when the chain is legitimately\n**paused** between two chained seasons (a configured gap) — treat both as\n\"nothing to show right now,\" not as an error worth retrying aggressively.\n\n### Grant status tokens (season XP/points)\n\n```ts\nconst res = await client.season.grantStatusTokens(\"battle_pass_main\", 250);\nif (!res.ok) return showError(res.error);\n\nres.data.NewTier; // tier after this grant\nres.data.TierUp; // true if this grant crossed into a new tier\nres.data.NewStatusTokens; // running cumulative token total for the current season\nres.data.OldTier; // tier before this grant, for a \"leveled up from X to Y\" toast\n```\n\nOnly wire this to a client button if the chain's `GrantTokensAccessMode` is\n`\"ClientOnly\"` or `\"Both\"` — see the Methods section above. For a title that\nawards status tokens purely from server-side triggers (match results,\ntournament placements), this call has nothing to do and should not be\nexposed in the UI at all for that chain.\n\n### Just show progress toward the next tier\n\n```ts\nawait client.season.getUserState(\"battle_pass_main\");\nconst state = client.data.user.state?.Season?.States?.[\"battle_pass_main\"];\n\n// Combine with a previously-fetched ActiveSeasonInfo for the ladder:\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n active.data.NextTier?.RequiredTokens; // tokens needed for the next tier\n active.data.NextTier?.Tier;\n // NextTier is null once the player has reached the season's highest tier.\n}\n```\n\n### Handle claim edge cases\n\n```ts\nconst res = await client.season.claimTierReward(\"battle_pass_main\", 3);\nif (!res.ok) {\n switch (res.reason) {\n case \"server\":\n // e.g. \"Tier 3 not reached yet. Current tier: 2.\" or\n // \"Tier 3 reward already claimed.\" — read res.error and toast it\n showError(res.error);\n break;\n case \"unauthorized\":\n // session expired — re-auth then retry\n break;\n case \"connection\":\n // transient — offer a Retry button\n break;\n default:\n showError(res.error);\n }\n return;\n}\n```\n\n### Handle a season rollover on relaunch\n\n```ts\n// After a client relaunch or a long idle gap, don't trust a stale cached\n// CurrentTier/ClaimedTierRewards — the chain may have advanced to its next\n// season (or a new cycle) since the player last called in, which triggers a\n// server-side reset (see references/data-model.md#season-rollover-wipe).\nconst active = await client.season.getActiveSeason(\"battle_pass_main\");\nif (active.ok) {\n // active.data.UserState now reflects the CURRENT season; the cache under\n // client.data.user.state.Season.States[\"battle_pass_main\"] was refreshed\n // as a side effect of this call.\n const seasonID = active.data.Season?.SeasonID;\n const stateSeasonID =\n client.data.user.state?.Season?.States?.[\"battle_pass_main\"]\n ?.CurrentSeasonID;\n // stateSeasonID === seasonID confirms you're looking at fresh progress.\n}\n```\n\n## Gotchas\n\n- **Granting tokens and claiming a reward are separate steps.**\n `grantStatusTokens` only advances `CurrentTier`; it does not claim anything.\n Your UI must call `claimTierReward` per tier — don't assume a `TierUp: true`\n response means the reward already landed in inventory.\n- **`grantStatusTokens` is access-gated per chain**, independent of whether\n the caller is logged in. `GrantTokensAccessMode: \"ServerOnly\"` (the default)\n rejects every client-initiated call for that chain outright — check which\n mode a given chain uses (via its `SeasonChainDefinition` in `Definitions`)\n before wiring a client button to this call.\n- **A season chain can be gated to a segment.** `SeasonChainDefinition.Gate`\n (Core/Segment `SegmentGate`) can restrict a chain to specific\n segments/levels/countries/premium tiers/experiment variants. A player\n failing the gate gets `\"This season is not available for you.\"` from both\n `getActiveSeason` and `grantStatusTokens` — this is audience targeting, not\n a bug.\n- **Season transitions silently reset progress server-side (\"Wipe\").** When\n the chain has moved on to its next season (or a new cycle) since the player\n last interacted with it, the very next call touching that chain resets\n `CurrentTier` to `1` and clears `ClaimedTierRewards` for the new season —\n this happens lazily on next access, not on a timer, so re-fetch\n (`getActiveSeason`/`getUserState`) rather than trusting a long-cached\n `CurrentTier` across relaunches. See\n [references/data-model.md](references/data-model.md#season-rollover-wipe).\n- **Claims are one-time per tier, tracked client-cache-side too.**\n `ClaimedTierRewards` is a de-duplicated list the SDK cache maintains\n locally (`patchSeasonClaimedTier` only pushes a tier number if it isn't\n already present) as well as the backend enforcing it server-side — expect a\n `reason: \"server\"` rejection (e.g. \"Tier N reward already claimed.\") on a\n repeat call, and use the cached list to gray out the button before the\n player even tries.\n- **`getActiveSeason`'s season/tier ladder isn't cached** — only its embedded\n `UserState` is written to `client.data.user.state.Season`. If you need the\n season's `Tiers`/dates/`NextTier` on a later screen, either refetch\n `getActiveSeason` or hold onto the last response yourself; don't expect it\n in `client.data`.\n- **A tier's reward is not the same thing as the season-tier reward overlay.**\n `SeasonTierDefinition.TierReachedReward` (what `claimTierReward` pays out)\n is a plain, unscaled `ResourceGrant`. The separate `SeasonTierRewardSet`\n overlay (used by Leaderboard/Reward/Quest-milestone rewards to scale _their\n own_ payout by the player's season tier) is not applied here and is not\n something you configure through this module — see\n [references/data-model.md](references/data-model.md#the-season-tier-reward-overlay-used-by-other-modules)\n if you run into it from another module's config.\n- **Only `SeasonDefinitions` is exported at the package root.**\n `SeasonChainDefinition` / `SeasonDefinition` / `SeasonTierDefinition` aren't\n directly importable from `@idosgames/core` — read them structurally off the\n resolved config tree instead of trying to import the type by name.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID` embeds a UUID), so two separate calls are two real\n operations — a double-clicked \"Claim\" can be rejected the second time as\n \"already claimed\" (harmless) but a double-clicked \"Grant\" really does grant\n twice. Disable the control while a call is in flight. Firing the same\n endpoint again within the throttle window (default 600 ms) is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth\n on `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Field names are PascalCase straight off the backend JSON**, and every\n schema keeps `.passthrough()`, so a field the backend adds later still\n round-trips even before the SDK's types are updated for it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the exact tier-threshold algorithm, chain/window resolution and pause\nsemantics, the season-rollover (\"Wipe\") rule, and the season-tier reward\noverlay mechanism other modules build on top of a player's season tier. Read\nit when building config-driven UI (a season selector, a tier ladder with\ncountdown) or when an error message points at a config rule you need to\nunderstand.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Season data model — reference\n\nFull shape of the config (Definitions), player state, tier/window resolution\nrules, and the season-tier reward overlay used by _other_ modules. All of\nthese are **strictly typed in the SDK** — `SeasonDefinitions` and its nested\nblocks are exported from `@idosgames/core`, so `getDefinitions()` and\n`getSection<SeasonDefinitions>(\"Season\")` give you concrete types where\nexported (see the [export gap](#export-gap) note). Every schema keeps\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Export gap](#export-gap) — which season config types are/aren't importable from `@idosgames/core`\n- [Player state](#player-state) — `UserSeasonState` / `UserSeasonsState`\n- [Config: SeasonDefinitions](#config-seasondefinitions)\n- [SeasonChainDefinition](#seasonchaindefinition)\n- [SeasonDefinition (one season in a chain)](#seasondefinition-one-season-in-a-chain)\n- [SeasonTierDefinition](#seasontierdefinition)\n- [Active-season window resolution](#active-season-window-resolution)\n- [Tier computation](#tier-computation)\n- [Season rollover (\"Wipe\")](#season-rollover-wipe)\n- [The season-tier reward overlay (used by OTHER modules)](#the-season-tier-reward-overlay-used-by-other-modules)\n\n---\n\n## Export gap\n\n`@idosgames/core`'s package root (`index.ts`) re-exports only:\n`SeasonDefinitions`, `ActiveSeasonInfo`, `UserSeasonStateResponse`,\n`GrantStatusTokensResponse`, `ClaimTierRewardResponse`, `UserSeasonState`,\n`UserSeasonsState`, `SeasonRequest`, `SeasonAction`.\n\n**Not re-exported at the package root**: `SeasonChainDefinition`,\n`SeasonDefinition`, `SeasonTierDefinition`, `SeasonGrantAccessMode`. They\nexist in `packages/core/src/models/season/SeasonModels.ts` and are reachable\nthrough the nested shape of `SeasonDefinitions` (e.g.\n`defs.Chains![id].Seasons![0].Tiers![0]`), but you cannot\n`import type { SeasonTierDefinition } from \"@idosgames/core\"` today — TypeScript\nwill infer the nested shape structurally instead. Don't write a snippet that\nimports those names directly; destructure/annotate from the resolved\n`SeasonDefinitions` tree instead.\n\n---\n\n## Player state\n\n`UserSeasonState` — one player's progress in one chain. Returned bare by\n`getUserState()`, embedded as `ActiveSeasonInfo.UserState`, and cached at\n`client.data.user.state.Season.States[chainID]`.\n\n```ts\ninterface UserSeasonState {\n SeasonChainID?: string;\n CurrentSeasonID?: string; // which season within the chain this progress belongs to\n SeasonVersion?: number; // monotonic counter, bumped by the server on every season transition\n CurrentTier?: number; // highest tier reached (1-based; 1 = base, no tokens needed)\n ClaimedTierRewards?: number[]; // tier numbers already claimed, dedup'd\n SeasonStartedAtUtc?: string; // ISO timestamp; set when this player's state was (re)initialized for the current season\n}\ninterface UserSeasonsState {\n States?: Record<string, UserSeasonState>; // key = SeasonChainID\n}\n```\n\nBackend source: `IDosGamesSDK/API/Client/v2/Season/Models/UserSeasonState.cs`.\n`SeasonVersion` exists so other subsystems (Collection, CoopEvent) that key\ntheir own per-season state off a chain can detect \"this player rolled into a\nnew season\" and wipe their own copy — that reconciliation happens entirely\nserver-side; you never read or act on `SeasonVersion` from the client.\n\n---\n\n## Config: SeasonDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<SeasonDefinitions>(\"Season\")`. Backend field on\n`TitlePublicConfigurationModel` is `Season` (singular) —\n`IDosGamesSDK/API/Client/v2/Title/Models/TitlePublicConfigurationModel.cs:48`.\n\n```ts\ninterface SeasonDefinitions {\n Chains?: Record<string, SeasonChainDefinition> | null; // key = SeasonChainID\n}\n```\n\n---\n\n## SeasonChainDefinition\n\n```ts\ninterface SeasonChainDefinition {\n SeasonChainID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // Mode = \"Chained\"; see below\n Seasons?: SeasonDefinition[]; // read in Order ASC\n GrantTokensAccessMode?: string; // \"ServerOnly\" | \"ClientOnly\" | \"Both\"; default ServerOnly\n Gate?: SegmentGate; // audience gate; null = available to everyone\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Season/Models/SeasonDefinitions.cs`.\n\n- **`Schedule`** is the shared `ScheduleSpec` (`Core/Scheduling/Models/ScheduleSpec.cs`,\n ported to TS in `_shared/ScheduleModels.ts`) with `Mode: \"Chained\"`. Seasons\n do **not** store explicit start/end dates — they're computed server-side\n from `Schedule.Chain.AnchorUtc` plus the summed `DurationSec` of preceding\n seasons in the chain. After the last season, a new cycle starts from the\n first again (`Schedule.Chain.MaxCycles` caps how many cycles, if set).\n- **`GrantTokensAccessMode`** (`SeasonGrantAccessMode`: `ServerOnly` | `ClientOnly` |\n `Both`, default `ServerOnly`) gates whether `client.season.grantStatusTokens`\n is even reachable for this chain — `Season.cs`'s `GrantStatusTokensClient`\n rejects with `\"GrantStatusTokens cannot be called from client for this chain.\"`\n when the mode is `ServerOnly` (`Season.cs:181`). The internal\n `TryGrantStatusTokensInternal` helper (called by other backend systems, e.g.\n a tournament awarding prize-rank tokens) bypasses this check entirely — it's\n not reachable from the client regardless of mode.\n- **`Gate`** is the standard `SegmentGate` (Core/Segment) — same shape used by\n Store/Reward/TimedBoost/Quest/Experiment. Evaluated in both\n `GetActiveSeason` and `GrantStatusTokens`; a player failing the gate gets\n `\"This season is not available for you.\"` (`Season.cs:125`, `Season.cs:250`)\n and does not receive status tokens even if some other server system tries to\n grant them for that chain.\n\n---\n\n## SeasonDefinition (one season in a chain)\n\n```ts\ninterface SeasonDefinition {\n SeasonID?: string;\n Order?: number; // position in the chain (0, 1, 2, ...)\n DurationSec?: number; // typical default in backend model: 4_320_000 (~50 days)\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n Tiers?: SeasonTierDefinition[]; // sort by RequiredTokens ASC; empty = season meta progression disabled\n LinkedCollectionID?: string; // optional link to a Collection active during this season\n CustomParams?: Record<string, string>;\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Season/Models/SeasonDefinitions.cs`.\n`LinkedCollectionID` and `CustomParams` are cross-system hooks the Season\nmodule itself does not act on — read them only if the title's design wires\nthem into the Collection module or client-side UI.\n\n---\n\n## SeasonTierDefinition\n\n```ts\ninterface SeasonTierDefinition {\n Tier?: number; // 1 = base tier, always reached (no tokens needed)\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredTokens?: number; // minimum cumulative status tokens to reach this tier\n TierReachedReward?: ResourceGrant; // one-time reward on first reaching the tier\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Season/Models/SeasonDefinitions.cs`\n(`SeasonTierDefinition`, lines 222–256).\n\n`TierReachedReward` is a plain `ResourceGrant` — `ClaimTierReward` passes it\nstraight into `ResourceService.ApplyResourceOperationAtomicAsync` with **no**\nseason-tier scaling applied to itself (`Season.cs:461`: `new ResourceOperation\n{ Grant = tierDef.TierReachedReward }`). The season-tier _reward overlay_\ndescribed [below](#the-season-tier-reward-overlay-used-by-other-modules) is a\nseparate mechanism other modules opt into — it does not scale a season tier's\nown reward.\n\n---\n\n## Active-season window resolution\n\n`getActiveSeason(seasonChainID)` computes the live season with the shared\n`ScheduleResolver` (the same resolver TimedEvent/Leaderboard/CoopEvent/\nTimedBoost use for their own chained/cyclic/scheduled windows —\n`Core/Scheduling/Services/ScheduleResolver.cs`). You don't need its internals;\nwhat matters for the client:\n\n- If the chain's `Schedule.IsActive` is false, or the chain has no seasons, or\n no window resolves → `GetActiveSeason` fails with `\"No active season in this\nchain.\"` (`Season.cs:104`, `Season.cs:222`, `Season.cs:393`). Treat this as\n \"nothing to show,\" not an error to retry.\n- A chain can be **in pause** between two chained seasons (gap configured via\n `Schedule.Chain.PauseBetweenPhasesSec` / `PauseBetweenCyclesSec`). While\n paused, no season is active — same \"No active season\" failure. This is\n expected/normal for a chain configured with gaps, not a bug.\n- `ActiveSeasonInfo.ComputedStartUtc` / `ComputedEndUtc` / `SecondsRemaining`\n are all computed fresh on every call — cache them client-side per-screen if\n you need a live countdown; don't expect the SDK cache to tick them for you.\n\n```ts\ninterface ActiveSeasonInfo {\n SeasonChainID: string;\n CycleIndex?: number | null; // 0-based cycle number of the chain\n Season?: SeasonDefinition | null; // the currently-live season's full config\n ComputedStartUtc?: string | null;\n ComputedEndUtc?: string | null;\n SecondsRemaining?: number | null;\n UserState?: UserSeasonState | null; // this player's progress (also patched into the cache)\n NextTier?: SeasonTierDefinition | null; // lowest tier with Tier > CurrentTier; null = max tier reached\n}\n```\n\nSource: `Season.cs` (`ActiveSeasonInfo` class + `BuildActiveSeasonInfo`,\nlines 686–707).\n\n---\n\n## Tier computation\n\n`ComputeTier` (`Season.cs:648-661`) is the exact algorithm the server runs\nevery time status tokens are granted:\n\n```\nresult = 1\nfor each tier in Tiers ordered by RequiredTokens ascending:\n if cumulativeTokens >= tier.RequiredTokens:\n result = tier.Tier\n else:\n break\nreturn result\n```\n\nIn words: the new `CurrentTier` is the **highest tier whose `RequiredTokens`\nthreshold the player's cumulative status-token total has reached**, with tier\n`1` as the always-available floor even at 0 tokens. There's no interpolation,\nno partial credit, no clamping beyond \"stop at the first unmet threshold\" —\ndon't attempt to reproduce this client-side for anything but a progress-bar\npreview; the authoritative `NewTier` always comes back in\n`GrantStatusTokensResponse`.\n\nThe \"cumulative tokens\" figure is the season's status-token balance tracked\nthrough the shared Core/EventToken system under\n`EventTokenType.Season` + `EntityID = SeasonChainID`\n(`Season.cs:275`, `EventTokenAddress`) — i.e. status tokens are backed by the\nsame ledger mechanism as any other event-token currency, just not exposed\nthrough `client.timedEvent`. You read the _result_ (`CurrentTier`,\n`NewStatusTokens`) through Season's own responses/state; you don't need to\ntouch the EventToken module for this.\n\n---\n\n## Season rollover (\"Wipe\")\n\nEach of `GetActiveSeason`, `GrantStatusTokens`, and `ClaimTierReward` calls\n`EnsureSeasonStateActualAsync` (`Season.cs:604-638`) before acting. It compares\nthe player's stored `CurrentSeasonID`/`SeasonChainID` against the freshly\nresolved window:\n\n- **Same season** → state is returned untouched.\n- **Different season** (first time in this chain, or the chain advanced to\n its next season/cycle since the player last touched it) → the server\n performs a **Wipe**: `SeasonVersion` increments, `CurrentTier` resets to\n `1`, `ClaimedTierRewards` resets to `[]`, `SeasonStartedAtUtc` is set to\n now, and the underlying `EventTokenType.Season` balance for that chain is\n reset to a fresh `UserEventTokenProgress()` (`Season.cs:634`) — i.e.\n cumulative status tokens go back to 0 for the new season.\n\nPractical effect for the client: **don't assume `CurrentTier`/\n`ClaimedTierRewards` you cached from a previous session are still valid** once\na season boundary has passed — always re-fetch (`getActiveSeason` or\n`getUserState`) after a client relaunch or a long idle gap before trusting the\ncache for tier-gated UI. The wipe happens lazily on the next call that touches\nthat chain, not on a timer, so two players can be \"wiped\" at different wall-clock\nmoments depending on when each of them next calls in.\n\n---\n\n## The season-tier reward overlay (used by OTHER modules)\n\n`SeasonTierRewardSet` / `SeasonTierRewardResolver` are **not used by the\nSeason module's own `ClaimTierReward`**. They're a separate, generic overlay\nmechanism that _other_ systems (Leaderboard rank/milestone rewards, Reward's\ntiered rewards, Referral, and any Core/Milestone-based reward via\n`MilestoneRewardResolver`) opt into, to scale or replace _their own_ reward\nusing the player's current tier in some season chain as an input. Season only\nsupplies the `CurrentTier` number; it does not consume this overlay itself.\n\n```ts\ninterface SeasonTierRewardMultiplier {\n SeasonChainID?: string;\n MinSeasonTier?: number; // this multiplier applies once player's tier >= this\n Multiplier?: number; // default 1.0 = neutral\n}\ninterface SeasonTierRewardBundle {\n SeasonChainID?: string;\n MinSeasonTier?: number;\n Rewards?: ResourceGrant; // structurally different reward, replaces (not stacks with) the base\n}\ninterface SeasonTierRewardSet {\n Bundles?: SeasonTierRewardBundle[];\n Multipliers?: SeasonTierRewardMultiplier[];\n}\n```\n\nSource: `IDosGamesSDK/API/Client/v2/Season/Models/SeasonTierRewardSet.cs` (TS\nport: `packages/core/src/models/_shared/MilestoneModels.ts`, exported as\n`SeasonTierRewardSet` / `SeasonTierRewardBundle` / `SeasonTierRewardMultiplier`\nfrom `@idosgames/core`). Resolution algorithm\n(`IDosGamesSDK/API/Client/v2/Season/Services/SeasonTierRewardResolver.cs:74-107`):\n\n1. If `tierSet` is null/empty, `seasonChainID` is empty, or `playerTier <= 0`\n → the base reward is returned unchanged.\n2. **Multiplier step**: pick the single best matching `Multipliers` entry\n (same `SeasonChainID`, `MinSeasonTier <= playerTier`, highest\n `MinSeasonTier` wins — not stacked) and scale every `Amount` in the base\n reward's `Standard` bundle and `PremiumTiers[].Resources` by it. Rounding\n is `(long)Math.Round(Amount * Multiplier)`; any entry whose scaled amount\n is `<= 0` is dropped. A multiplier of exactly `1.0` (within `1e-9`) is\n treated as absent (no clone, no rounding pass).\n3. **Bundle step**: pick eligible `Bundles` entries the same way, except the\n caller's `stackLowerTiers` flag decides whether _all_ eligible bundles (by\n ascending `MinSeasonTier`) are concatenated, or only the single best one is\n used. This flag and the `Additive`-vs-`Replace` `RewardMode` both come from\n the _consuming_ module's own tier-reward settings (e.g.\n `RewardDefinitions.TierRewardSettings` — `RewardMode` default `Additive`,\n `RewardStackLowerTiers`), not from anything in the Season config itself.\n4. `Additive` mode concatenates the scaled base's `Standard.Entries` /\n `EventTokens` with the matched bundles' `Standard` payload. `Replace` mode\n discards the scaled base and keeps only the bundles' payload. Bundles only\n ever contribute their `Standard` bundle — no premium sub-structure.\n\nIf you're building a title feature that wants \"better rewards at higher\nseason tiers\" for something _other_ than the Season tier's own\n`TierReachedReward`, that's the shape to configure on the consuming module\n(check that module's own skill/config, e.g. leaderboard-system) — it is not\nsomething you configure or call from `client.season`.\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "social-system",
|
|
3
|
+
"description": "Build a friends / social system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): load the friends list, incoming friend requests, and recommended friends, send/accept/decline friend requests, remove a friend, and read the social activity timeline (attacks, raids, friend-adds). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a friends list screen, friend-request inbox, add-friend flow, recommended friends / player search, or an activity feed, or otherwise touches client.social, SocialService, SocialModels, FriendPublicProfile, or the social timeline — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## The three lists + the feed\n\nPlayer social state (`UserSocialState`) has four independent arrays, all\nstring `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Only grown client-side by `sendFriendRequest`; there\n is no `getOutgoingRequests()` — track it from the cache after you send.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\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 social = client.social; // the SocialService\n```\n\nEvery social 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, e.g. missing target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\nsocial?.Accepted; // string[] of friend UserIDs\nsocial?.IncomingRequests; // string[] awaiting your accept/decline\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved\nsocial?.Timeline; // SocialTimelineEvent[]\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// There's no polling endpoint for the other side's decision — re-check via\n// getFriendsList()/getIncomingRequests() (e.g. on next screen focus) to see\n// if it was accepted (moves to Accepted) or the outgoing entry disappears.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **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- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "store-system",
|
|
3
|
+
"description": "Build a store / shop system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load storefront and offer (SKU) definitions, load the player's purchase counters, and purchase one or many offers (currency/item packs, bundles, cosmetics) with virtual/item cost. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop/store screen, IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition, or offer purchasing — even if they don't name the module explicitly.",
|
|
4
|
+
"content": "---\nname: store-system\ndescription: >-\n Build a store / shop system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load storefront and offer\n (SKU) definitions, load the player's purchase counters, and purchase one or\n many offers (currency/item packs, bundles, cosmetics) with virtual/item\n cost. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a shop/store screen,\n IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise\n touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition,\n or offer purchasing — even if they don't name the module explicitly.\n---\n\n# Store system (iDosGames TS SDK)\n\nThe Store module lets a title sell **offers** (SKUs) — currency packs, item\nbundles, cosmetics, anything priced via `ResourceConsume` — grouped into one or\nmore **storefronts**. Everything is **server-authoritative**: the client asks\nthe backend to purchase, the backend validates cost, rules, and limits, and the\nSDK mirrors the confirmed result (resources + purchase counters) into a local\ncache your UI reads. You never mutate store state yourself — you call a\nmethod, check the result, and render from the cache.\n\nThis skill is for **using** the production `StoreService`, not for porting or\nextending it. If a purchase is rejected, that's the backend enforcing a rule\n(cost, time window, audience gate, purchase cap) — surface the error, don't try\nto reproduce the check client-side.\n\nStore's `Cost`/`Rewards` are virtual (`ResourceConsume`/`ResourceGrant`) —\ncurrency, items, event tokens, premium-tier grants. There is no real-money IAP\nreceipt flow inside this module; that lives entirely in the separate Purchase\nmodule (not covered here).\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n storefronts and offers: which offers live in which store, their `Cost` and\n `Rewards`, and their availability `Rules`. Fetched with `getDefinitions()`.\n2. **User store state** (state, per player) — this player's purchase counters\n per offer (`TotalPurchases`, `DailyPurchases`, reset time). Fetched with\n `getUserState()`.\n\nAn offer is identified by a string `OfferID`; a storefront by `StoreID`. An\noffer can be listed in multiple stores via `StoreIDs`, letting the same SKU\nappear in, say, both the main shop and a limited-time event shop. For the full\nfield-by-field shape of Definitions and state (purchase-limit reset math,\nbatch semantics, special-value rules), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window, default 600 ms), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Offer not found\nin the specified store.\", \"Offer is not yet available.\", \"Offer has expired.\",\n\"Offer is not available for you.\", \"Purchase limit reached for offer\n'<id>'. Max: <n>.\", \"Daily purchase limit reached for offer '<id>'. Max per\nday: <n>.\", or an `ApplyResourceOperationAtomicAsync` failure such as\ninsufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------- | ---------------------------------------------- | -------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's store/offer catalog (config). | `StoreDefinitions` |\n| `getUserState()` | Load this player's purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` (`Resources`) |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` (`BatchItemResult<StorePurchaseResponse>[]`) |\n\n`purchase` clamps `count` server-side to the range **1–100** (values ≤0 sent by\na caller are floored to 1 by the backend, but the SDK itself already rejects\n`count < 1` client-side as `reason: \"client\"`). `purchaseBatch` takes\n`StorePurchaseRef[]`: `{ OfferID, Count }[]` — deduped by `OfferID` (one entry\nper offer per call; use `Count` for multiple units), each `Count` clamped to\n1–100, and the list itself clamped to **50 refs per call** (entries past 50 are\nsilently dropped server-side — chunk larger sets yourself).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. `purchase`/`purchaseBatch`\napply the returned `Resources` (`ResourceOperation`, shared across the SDK —\nsee the currency-system skill for the full `ResourceConsume`/`ResourceGrant`\nreference) to the cached currency/item balances, and bump the purchased\noffer's counters (`TotalPurchases`, `DailyPurchases`, `DailyResetUtc`). Read\nupdated balances and counters straight from the cache.\n\n## Reading state and reacting to changes\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { StoreDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst offer = defs?.StoreOffers?.[\"pack1\"];\noffer?.Cost; // ResourceConsume — what it costs\noffer?.Rewards; // ResourceGrant — what it grants\noffer?.Rules; // time window, Gate (SegmentGate), Limits (LimitSpec)\n\n// Purchase counters (only present after getUserState() or a purchase):\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\npurchases[\"pack1\"]?.TotalPurchases;\npurchases[\"pack1\"]?.DailyPurchases;\npurchases[\"pack1\"]?.DailyResetUtc; // ISO — next UTC-midnight reset\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"store:offerPurchased\", (r) => {\n console.log(`Bought ${r.Count}x ${r.OfferID}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show a storefront with purchase-limit UI\n\n```ts\nawait client.store.getDefinitions();\nawait client.store.getUserState();\n\nconst defs = client.data.config.getSection<StoreDefinitions>(\"Store\");\nconst purchases = client.data.user.state?.Store?.Purchases ?? {};\n\nconst offersInMainStore = Object.values(defs?.StoreOffers ?? {}).filter((o) =>\n o.StoreIDs?.includes(\"main\"),\n);\n\nfor (const offer of offersInMainStore) {\n const counters = purchases[offer.OfferID];\n const limits = offer.Rules?.Limits;\n const totalLeft =\n limits?.TotalCap && limits.TotalCap > 0\n ? Math.max(0, limits.TotalCap - (counters?.TotalPurchases ?? 0))\n : null; // null = no lifetime cap\n const dailyLeft =\n limits?.DailyCap && limits.DailyCap > 0\n ? Math.max(0, limits.DailyCap - (counters?.DailyPurchases ?? 0))\n : null; // null = no daily cap\n // Disable the buy button when totalLeft === 0 or dailyLeft === 0.\n // Don't try to predict the daily reset instant yourself beyond display —\n // read counters.DailyResetUtc fresh after each purchase/getUserState().\n}\n```\n\n`Rules` (time window + `Gate` audience + `Limits` purchase caps) are enforced\nserver-side — use them client-side only to pre-filter/gray out what you\nalready know will be rejected, not as the source of truth.\n\n### Purchase an offer\n\n```ts\nconst res = await client.store.purchase(\"pack1\", 1);\nif (!res.ok) return showError(res.error); // e.g. \"Purchase limit reached...\", can't afford\n// cache now has updated balances + counters. UI re-renders from cache.\nres.data.Resources; // ResourceOperation actually applied (Consume + Grant)\n```\n\n### Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"pack1\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call ran;\neach element's `Success`/`Error` tells you whether that item applied. Offers\nrejected on their own merits (unknown id, outside window, gate failed, limit\nreached) are filtered out _before_ the merged charge is built and simply\nreport their own reason — they never affect other items in the batch. The\nremaining, valid offers are then charged as **one merged, all-or-nothing\ntransaction**: if the combined cost can't be paid, every one of those\nsurvivors comes back `Success: false` with an \"Atomic batch purchase failed\"\nerror, even though each was individually valid.\n\n### Cosmetic/bundle offer with only item rewards\n\nNothing offer-specific to do differently — `Rewards` is a `ResourceGrant` like\nany other, so an offer that only grants items (no currency) works through the\nsame `purchase()` call. Read the granted item instances back from\n`client.data.user.state?.InventoryV2` (see the item-system skill) after the\ncall, or from `res.data.Resources.Grant`.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` call mints a fresh\n idempotency key (`store_buy_{offerID}_{userID}_{uuid}` client-side, further\n wrapped server-side), so two separate calls are two real operations — a\n double-clicked \"Buy\" can charge twice. Disable the control while a call is\n in flight. Firing the same endpoint again within the throttle window\n (default 600 ms) is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.\n- **`Cost` and `Rewards` can be discounted/boosted server-side.** `Cost` is a\n `ResourceConsume` (may carry `PremiumDiscounts`) and `Rewards` is a\n `ResourceGrant` (may carry `PremiumTiers`) — the backend auto-applies the\n player's best subscription tier (see the premium-system skill). Don't assume\n the displayed base price/reward equals what's actually charged/granted; read\n the actual amounts off `res.data.Resources`.\n- **`count` scales cost and rewards linearly, then premium is applied once.**\n Buying `count=3` multiplies every `Cost`/`Rewards` entry (including event\n tokens) by 3 before premium discounts/bonuses are resolved — it is not 3\n independent purchases, so per-purchase minimums/rounding don't compound.\n- **Only one `Resources` apply per batch call, but every item's own data is\n still correct.** `purchaseBatch` applies the first successful item's\n `Resources` to the cache (the batch charge is merged server-side into one\n operation, so attaching it to every item would double-count balances); the\n per-item `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc` are still correct\n for each offer and drive the purchase-counter patch for every successful\n item, not just the first.\n- **`DailyPurchases` resets on UTC midnight, compared as ISO strings.** The SDK\n mirrors the server's reset logic locally when patching after a purchase\n (`state.DailyResetUtc` becomes the next UTC midnight after\n `ServerTimeUtc`) — you don't need to compute it, just read\n `DailyResetUtc`/`DailyPurchases` from the cache after the call.\n- **Purchase-history writes are best-effort and don't affect the result.** The\n backend appends an audit-log row after a successful purchase; if that write\n fails it's swallowed silently and never surfaces to the client — don't\n expect a Store endpoint to expose purchase history.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, the purchase-limit/reset rule matrix, batch all-or-nothing semantics,\nand special-value conventions. Read it when building config-driven UI (cap\npreviews, cooldown countdowns) or when an error message points at a config\nrule you need to understand.\n",
|
|
5
|
+
"references": [
|
|
6
|
+
{
|
|
7
|
+
"path": "data-model.md",
|
|
8
|
+
"content": "# Store data model — reference\n\nFull shape of the config (Definitions) and player state, the purchase-limit\nrule matrix, batch all-or-nothing semantics, and special-value conventions.\nAll of these are **strictly typed in the SDK** — `StoreDefinitions` and every\nnested block (`StoreDefinition`, `StoreRules`, `StoreOfferDefinition`,\n`StoreOfferRules`) are exported from `@idosgames/core`, so `getDefinitions()`\nand `getSection<StoreDefinitions>(\"Store\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — what `getUserState()` returns\n- [Config: StoreDefinitions](#config-storedefinitions) — what `getDefinitions()` returns\n- [StoreDefinition (storefront)](#storedefinition-storefront)\n- [StoreOfferDefinition (SKU)](#storeofferdefinition-sku)\n- [Purchase-limit rule matrix](#purchase-limit-rule-matrix)\n- [Purchase flow, scaling, and idempotency](#purchase-flow-scaling-and-idempotency)\n- [Batch purchase semantics](#batch-purchase-semantics)\n- [Special values](#special-values)\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ Purchases?: Record<string, StorePurchaseState> }`\nand cached at `client.data.user.state?.Store?.Purchases`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/UserStoreState.cs`.\n\n```ts\ninterface StorePurchaseState {\n OfferID: string;\n TotalPurchases: number; // lifetime count, all-time\n DailyPurchases: number; // count since the last DailyResetUtc\n DailyResetUtc: string; // ISO — the next instant DailyPurchases resets to 0\n LastPurchasedAt: string; // ISO — server time of the last successful purchase\n}\n```\n\nThis is a **rate-limit counter store**, not a purchase-history log — it only\nholds what's needed to enforce `TotalCap`/`DailyCap` atomically. A separate\n`StorePurchaseHistoryDocument` audit-log collection exists server-side\n(`UserID`, `TitleID`, `OfferID`, `Count`, `Resources`, `PurchasedAt`) but it is\n**not exposed through any Store endpoint** — there is no \"purchase history\"\nclient call.\n\nA player with no purchase for a given `OfferID` simply has no entry in\n`Purchases` — treat a missing key as `TotalPurchases: 0`, `DailyPurchases: 0`,\nno active daily window.\n\n---\n\n## Config: StoreDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<StoreDefinitions>(\"Store\")`. Backend source:\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreDefinitions {\n Stores?: Record<string, StoreDefinition> | null; // key = StoreID\n StoreOffers?: Record<string, StoreOfferDefinition> | null; // key = OfferID\n}\n```\n\nStorefronts and offers are deliberately separate catalogs: an offer references\nthe storefronts it appears in via `StoreIDs`, so the same SKU (price, rewards,\n`OfferID`, analytics) can be reused across the main shop, an event shop, a VIP\nshop, etc. without duplication.\n\n---\n\n## StoreDefinition (storefront)\n\nA logical shop screen (main / event / VIP). Does not embed offers.\n\n```ts\ninterface StoreDefinition {\n StoreID: string; // stable id; never rename after publication — offers reference it\n Type?: string; // segmentation/grouping tag, free-form\n Name?: string; // display name; optional for internal storefronts\n Description?: string;\n Rules?: StoreRules;\n AssetPaths?: Record<string, string>; // banner/icon/background, key = asset slug\n}\n\ninterface StoreRules {\n StartUtc?: string; // storefront opens at this UTC instant; absent = available from the start\n EndUtc?: string; // storefront closes at this UTC instant; absent = no expiration\n RequiredFlags?: string[]; // ALL must be set on the player for the storefront to show\n}\n```\n\n`StoreRules.RequiredFlags` is **not enforced by the `Store.Purchase` /\n`PurchaseBatch` endpoints** — the backend's purchase path (`Store.cs`) only\nvalidates the _offer's_ own `Rules` (window, `Gate`, `Limits`); it never looks\nup which storefront the purchase came through. Treat `StoreRules` purely as\nclient-side \"should I show this storefront\" filtering data, not as a\nserver-enforced purchase gate — the offer-level `Gate`/window/limits are the\nactual enforcement.\n\n---\n\n## StoreOfferDefinition (SKU)\n\nThe purchasable unit. Backend field-level docs from\n`IDosGamesSDK/API/Client/v2/Store/Models/StoreDefinitions.cs`.\n\n```ts\ninterface StoreOfferDefinition {\n OfferID: string; // stable id; used in analytics/purchase logs; never rename after publication\n StoreIDs?: string[]; // storefronts this offer appears in; empty/null = invisible everywhere\n Name?: string;\n Cost?: ResourceConsume; // debit-only; see currency-system skill for the shared shape\n Rewards?: ResourceGrant; // grant-only; see currency-system skill for the shared shape\n Rules?: StoreOfferRules;\n AssetPaths?: Record<string, string>;\n}\n\ninterface StoreOfferRules {\n StartUtc?: string; // offer becomes purchasable at this UTC instant; absent = from the start\n EndUtc?: string; // offer stops being purchasable at this UTC instant; absent = no expiration\n Gate?: SegmentGate; // \"who can buy this\" — premium tier/ID, segment, level, country, recency, experiment\n Limits?: LimitSpec; // purchase caps — see the matrix below\n}\n```\n\n`Gate` is the shared `SegmentGate` (Core/Segment) — all conditions AND-ed, an\nabsent/empty gate means available to everyone. Resolved server-side by\n`SegmentGateEvaluator.Passes` against the player's document at the moment of\npurchase (`Store.cs` line ~170: `\"Offer is not available for you.\"` on\nfailure).\n\n**Shape validation** (`StoreHelpers.ValidateOfferShape`, always run before a\npurchase is accepted): an offer with an empty `Cost` (no `Standard.Entries` and\nno `Standard.EventTokens`) fails with `\"Offer cost is empty.\"`; an offer with\nno `Rewards` at all (`Standard.Entries`, `Standard.EventTokens`, and\n`PremiumTiers` all empty) fails with `\"Offer rewards are empty.\"`. In other\nwords: **every real offer must both cost something and grant something** —\nthere is no free-claim or cost-only shape for Store offers (use the Reward or\nDealOffer module for pure-claim mechanics).\n\n---\n\n## Purchase-limit rule matrix\n\n`LimitSpec` (shared `Core/Limits` type; full field list in\n`packages/core/src/models/_shared/LimitModels.ts`) is reused across the SDK,\nbut Store's enforcement (`StoreHelpers.CheckPurchaseLimits` and\n`BuildPurchaseCounterPatches`, in\n`IDosGamesSDK/API/Client/v2/Store/Services/StoreHelpers.cs`) only reads two of\nits axes:\n\n| `LimitSpec` field | Meaning for Store | Enforcement |\n| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `TotalCap` | Lifetime purchase cap for the offer, summed over `Count` across all purchases | `TotalPurchases + count > TotalCap` → `\"Purchase limit reached for offer '<id>'. Max: <n>.\"` |\n| `DailyCap` | Per-UTC-day purchase cap | `DailyPurchases + count > DailyCap` (only while `now < DailyResetUtc`; otherwise treated as 0) → `\"Daily purchase limit reached for offer '<id>'. Max per day: <n>.\"` |\n\nOther `LimitSpec` axes (`DailyWeightCap`, `PerActivationCap`,\n`CooldownSeconds`, `MaxPerWindow`, `WindowSeconds`) exist on the shared type\nfor other modules but **Store does not read them** — configuring them on a\nStore offer's `Rules.Limits` has no effect on purchase behavior.\n\n**Daily reset timing.** `DailyResetUtc` is set to `now.Date.AddDays(1)` (the\nUTC midnight _after_ the purchase that (re)started the window) the first time\nan offer is bought, or whenever `now >= DailyResetUtc` on a subsequent\npurchase — i.e. the daily window is lazily rolled forward on the next\npurchase attempt, not on a schedule. If a player buys at 23:59 UTC and again\nat 00:01 UTC, the second purchase sees `now >= DailyResetUtc` from the first,\nresets `DailyPurchases` to the new `count`, and pushes `DailyResetUtc` to the\nfollowing midnight.\n\n**Race protection.** The fail-fast check in `CheckPurchaseLimits` runs before\nthe atomic write, but the real guarantee against concurrent double-spends past\nthe cap is an `extraFilter` attached to the same Mongo update\n(`BuildPurchaseCounterPatches`): the write only commits if\n`TotalPurchases <= TotalCap - count` (and the daily equivalent, tolerant of an\nexpired window) still holds at write time. If two concurrent requests would\nboth push a counter over its cap, only one commits — the loser's whole\n`ApplyResourceOperationAtomicAsync` call fails and the purchase is rejected,\nresources untouched.\n\n---\n\n## Purchase flow, scaling, and idempotency\n\nOrder of checks in `Store.StorePurchase` (`Store.cs`), all before any resource\nmutation:\n\n1. `OfferID` required; `count` clamped to **1–100**.\n2. Offer looked up by `OfferID` (optionally filtered by `storeID`, unused by\n the public `Purchase` action) — `\"Offer not found in the specified store.\"`\n if missing.\n3. Window check (`StartUtc`/`EndUtc`) — `\"Offer is not yet available.\"` /\n `\"Offer has expired.\"`.\n4. Shape check (`Cost` non-empty, `Rewards` non-empty) — see above.\n5. Player document read (single read, id/`InventoryV2`/`EventToken`/`Premium`/`Store` projection only).\n6. `Gate` check — `\"Offer is not available for you.\"`.\n7. Limit check (`CheckPurchaseLimits`) — see the matrix above.\n8. **Scaling**: `Cost` and `Rewards` are each scaled by `count` — every\n `ResourceEntry.Amount` and every `EventTokenOperation.Amount` is multiplied\n by `count` (a fresh object; the config definition itself is never mutated).\n `PremiumDiscounts`/`PremiumTiers` percentages are **not** scaled by count —\n only flat amounts are.\n9. The scaled `Cost`/`Rewards` become one `ResourceOperation { Grant, Consume }`\n applied via `ResourceService.ApplyResourceOperationAtomicAsync`, alongside\n the purchase-counter patches from step 7 and a `FeatureUsage` touch (see\n below), under one Mongo transaction with the `extraFilter` guard.\n10. On success, a best-effort audit-log row is appended\n (`StoreHelpers.AppendPurchaseHistoryAsync`) — failures here are swallowed\n and never affect the client response.\n\n**Idempotency.** The reason key is\n`\"StoreBuy:\" + ResourceService.ResolveRelatedEntityID(relatedEntityID, \"store_buy_{offerID}_{userID}\")`.\nThe SDK's `purchase()` always supplies a fresh, unique `RelatedEntityID`\n(`store_buy_{offerID}_{userID}_{uuid}`) per call — so from the client's\nperspective **every `purchase()` call is a brand-new charge**; the idempotency\nkey only protects against the transport layer's own internal retries within a\nsingle logical call, not against you calling `purchase()` twice.\n\n**`FeatureUsage` touch.** Every successful `Purchase` (regardless of `count`)\nincrements a `FeatureIDs.Store` usage touch exactly once — this is \"the player\nengaged the store,\" unrelated to and not a substitute for the per-offer\n`TotalPurchases`/`DailyPurchases` counters.\n\n---\n\n## Batch purchase semantics\n\n`PurchaseBatch` (`Store.PurchaseBatch` in `Store.cs`) trades N round-trips for\none, but keeps per-offer validation independent from the shared charge:\n\n**1. Normalization** — for each `StorePurchaseRef` in `args.Purchases`:\nblank/whitespace `OfferID` is dropped; `OfferID` is trimmed; duplicates by\n`OfferID` are dropped (first occurrence wins — **one offer per batch call**;\nuse `Count` for multiple units of the same offer, not repeated refs);\n`Count <= 0` is treated as `1`, then clamped to **1–100**; the list stops\ngrowing once it reaches `BatchSupport.MaxBatchSize` = **50** — refs beyond the\n50th are silently dropped and never appear in the result at all. An\nall-empty/invalid request (0 refs survive normalization) fails outright with\n`\"Purchases is required\"`.\n\n**2. One player read** for the whole batch (not per-offer).\n\n**3. Per-offer validation, outside the transaction** — for each surviving\n`(offerID, count)`, in order: offer exists → window → shape → `Gate` → purchase\nlimits (same checks and same error strings as the single-purchase path,\nkeyed per offer). Any failure here produces an immediate `BatchItemResult`\nwith `Success: false` and that specific `Error`, and **excludes the offer from\nthe merged charge** — it does not abort the batch.\n\nIf **zero** offers survive this stage, the call returns `Ok` with only the\nper-offer failure results (no atomic transaction is attempted).\n\n**4. Merge + one atomic charge** — for every surviving offer: `Cost`/`Rewards`\nare scaled by that offer's own `count`, then premium discounts/tiers are\nresolved and flattened per-offer (`ResourceService.FilterByPremium`) _before_\nmerging, so each offer's own premium tier is applied — the merge does not\ncreate a single blended discount. The flattened bundles from every surviving\noffer are summed into one `ResourceGrant`/`ResourceConsume`, together with the\npurchase-counter patches for every surviving offer and one shared\n`FeatureUsage` touch, and applied as a **single**\n`ApplyResourceOperationAtomicAsync` call with a combined `extraFilter`\n(AND of every offer's own race-protection filter).\n\n**All-or-nothing across survivors.** If the merged charge fails (e.g. can't\nafford the combined cost, or any one offer's `extraFilter` no longer holds),\n**every surviving offer** — even ones that were individually valid — comes\nback `Success: false` with `\"Atomic batch purchase failed: <reason>\"`. There is\nno partial application within the merged group; only the pre-filtered\nindividually-invalid offers were ever excluded.\n\n**5. Result shape and `Resources` placement.** The merged `ResourceOperation`\nreturned by the atomic call is attached to `Data.Resources` on **only the\nfirst successful item** in call order; every other successful item gets\n`Data.Resources = new ResourceOperation()` (empty, not null) — so summing\n`Resources` across all successful items double-counts nothing, but reading a\nnon-first item's `Resources` for balance info will show nothing. Read balances\nfrom the cache (which the SDK patches once per successful item's own\n`OfferID`/`Count`, so counters are correct for every item) rather than from\neach item's own `Resources`. `Data.OfferID`/`Data.Count`/`Data.ServerTimeUtc`\nare correct and independent for every successful item regardless of where\n`Resources` landed.\n\n**Reason key.** `BatchSupport.BuildBatchReason(\"StoreBuyBatch\", relatedEntityID, includedOfferIDs)`\n— one idempotency key covering the whole merged transaction, not one per\noffer.\n\n**Audit log.** On a successful merged charge, one best-effort history row is\nappended per surviving offer (same swallow-on-failure semantics as the single\npath).\n\n---\n\n## Special values\n\n- `Rules` absent entirely on a storefront or offer ⇒ no restriction on that\n axis (always visible / always purchasable / no gate / no limits).\n- `LimitSpec.TotalCap` / `DailyCap` `<= 0` (including absent, which the config\n default `LimitSpec` treats as `0`) ⇒ **unlimited** on that axis — the check\n is skipped entirely, not \"zero purchases allowed.\"\n- `StoreOfferDefinition.StoreIDs` empty or `null` ⇒ the offer exists in the\n catalog but is invisible in every storefront (it can still theoretically be\n purchased by `OfferID` directly, since `Purchase`'s `storeID` filter is\n unused by the public action — but there is no supported storefront UI path\n to reach it).\n- A player with no `Purchases[offerID]` entry is equivalent to\n `TotalPurchases: 0, DailyPurchases: 0`, with no active daily window (the\n `DailyExpired` check treats a missing state the same as an expired one).\n"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|