@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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/cli.js +204 -0
  4. package/package.json +46 -0
  5. package/registry/host.json +41 -0
  6. package/registry/index.json +215 -0
  7. package/registry/modules/board-game.json +121 -0
  8. package/registry/modules/currency-hud.json +28 -0
  9. package/registry/modules/idle-rpg.json +89 -0
  10. package/registry/modules/voxelcraft.json +163 -0
  11. package/registry/skills/authentication.json +11 -0
  12. package/registry/skills/blockchain-system.json +11 -0
  13. package/registry/skills/character-system.json +11 -0
  14. package/registry/skills/cloud-code.json +6 -0
  15. package/registry/skills/collection-system.json +11 -0
  16. package/registry/skills/coop-event-system.json +11 -0
  17. package/registry/skills/craft-system.json +11 -0
  18. package/registry/skills/currency-system.json +11 -0
  19. package/registry/skills/deal-offer-system.json +11 -0
  20. package/registry/skills/dev-test-loop.json +6 -0
  21. package/registry/skills/game-loop-system.json +11 -0
  22. package/registry/skills/idosgames-compose-modules.json +6 -0
  23. package/registry/skills/idosgames-getting-started.json +6 -0
  24. package/registry/skills/idosgames-module-contract.json +6 -0
  25. package/registry/skills/item-system.json +11 -0
  26. package/registry/skills/leaderboard-system.json +11 -0
  27. package/registry/skills/lootbox-system.json +11 -0
  28. package/registry/skills/marketplace-system.json +11 -0
  29. package/registry/skills/match-system.json +11 -0
  30. package/registry/skills/multiplayer-realtime.json +11 -0
  31. package/registry/skills/premium-system.json +11 -0
  32. package/registry/skills/quest-system.json +11 -0
  33. package/registry/skills/referral-system.json +11 -0
  34. package/registry/skills/reward-system.json +11 -0
  35. package/registry/skills/season-system.json +11 -0
  36. package/registry/skills/social-system.json +6 -0
  37. package/registry/skills/store-system.json +11 -0
  38. package/registry/skills/timed-boost-system.json +11 -0
  39. package/registry/skills/timed-event-system.json +11 -0
  40. package/registry/skills/title-system.json +6 -0
  41. package/registry/skills/user-custom-data.json +11 -0
  42. package/registry/skills/user-profile.json +11 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "coop-event-system",
3
+ "description": "Build a cooperative / group event system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.coopEvent (CoopEventService): load coop event chain definitions, look up which event in a chain is currently active, load the player's own coop-event state, join or create a matchmade group, spin/contribute toward the group's shared BuildObjects goal, claim a per-member object reward or the group's grand prize, and leave a group. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a co-op event, group event, team event, alliance-style shared-goal feature, spin-to-contribute mechanic, or otherwise touches client.coopEvent, CoopEventService, CoopEventDefinitions, CoopGroupDocument, UserCoopEventState, or CoopSpinResponse — even if they don't name the module explicitly.",
4
+ "content": "---\nname: coop-event-system\ndescription: >-\n Build a cooperative / group event system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.coopEvent (CoopEventService):\n load coop event chain definitions, look up which event in a chain is\n currently active, load the player's own coop-event state, join or create a\n matchmade group, spin/contribute toward the group's shared BuildObjects\n goal, claim a per-member object reward or the group's grand prize, and leave\n a group. Use this whenever the user is working in the iDosGames TS SDK or\n its game templates (board-game, idle-rpg) and wants a co-op event, group\n event, team event, alliance-style shared-goal feature, spin-to-contribute\n mechanic, or otherwise touches client.coopEvent, CoopEventService,\n CoopEventDefinitions, CoopGroupDocument, UserCoopEventState, or\n CoopSpinResponse — even if they don't name the module explicitly.\n---\n\n# Coop event system (iDosGames TS SDK)\n\nThe Coop Event module runs time-limited **cooperative group events**: the\ntitle schedules a chain of events, each event matchmakes players into small\ngroups, and the group works together toward a shared goal (currently the\n`BuildObjects` mechanic — each member owns one \"object\" they fill up by\nspinning, and the whole group shares a grand prize once every object is\ndone). Everything is **server-authoritative**: the client asks to join, spin,\nor claim; the backend validates and updates the shared group document; the\nSDK mirrors the confirmed result into a local cache your UI reads. You never\nmutate group or user state yourself.\n\nThis skill is for **using** the production `CoopEventService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing\na rule (matchmaking window, group capacity, already claimed) — surface the\nerror, don't try to reproduce the check client-side.\n\n## The four data shapes\n\nThis module has more moving parts than a single-player system because of the\ngroup dimension. Keep these four straight:\n\n1. **Definitions** (config, same for every player) — the title's chains of\n coop events: schedule, group size (`PartnerCount`), spin cost, spinner\n odds, per-object completion rewards, grand prize. Fetched with\n `getDefinitions()`.\n2. **Active event info** (config lookup, same for every player watching the\n same chain) — which single event inside a chain is live _right now_, plus\n its computed window. Fetched with `getActiveEvent(coopChainID)`.\n3. **User state** (state, per player) — this player's own coop-event bookkeeping:\n which group they're currently in (if any), which object index in that\n group is _theirs_, and their history of past cycles. Fetched with\n `getUserState()`.\n4. **Group state** (state, per group, shared by every member) — the live\n document every member of a group reads and writes together: the member\n list, each member's contribution counters, the shared `BuildObjects`\n progress for every object, and the group's lifecycle status. Fetched with\n `getGroupState(groupID)`, and also returned by `joinOrCreateGroup`.\n\nA player belongs to **at most one active group per chain** at a time. Their\nown membership pointer (`ActiveGroupID` / `ActiveCoopEventID` /\n`MyObjectIndex`) lives in **user state**; everything about the group itself —\nwho else is in it, whose object is at what progress — lives in **group\nstate**. Render \"my event\" screens from user state + group state together;\nrender \"which event is running\" banners from active-event info.\n\n**Joining** (`joinOrCreateGroup`) either seats the player into an existing\n`Forming`/`Active` group for that chain's current event or spins up a new one\n— the backend's matchmaker decides which; the client just asks to join the\nchain. A group's target size is `1 + PartnerCount` members (`PartnerCount`\ndefaults to 4, i.e. a 5-member group), and if matchmaking doesn't fill it in\ntime the backend seats bots into the empty slots — see Gotchas. **Spinning**\n(`spin`) is the shared contribute action: it costs the event's `SpinCost`,\nrolls a sector on the spinner table, and adds that sector's progress to the\n_player's own_ object in the group's `BuildObjects` tree. **Claiming** has two\ndistinct steps because the mechanic has two distinct payouts:\n`claimObjectReward` pays out **one member's own completed object** (each\nmember claims their own once it's full), while `claimGrandPrize` pays out the\n**group-wide** prize and is only claimable once every object in the group is\ncomplete. Leaving (`leaveGroup`) drops the player's membership; it does not\ndelete the group for the remaining members.\n\nFor the full field-by-field shape of chains, events, the `BuildObjects`\nmechanic, the spinner-weight formula, and the group document, 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 and shared\ngroup state.\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 coopEvents = client.coopEvent; // the CoopEventService\n```\n\nEvery coop-event 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\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args, e.g. missing id), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window),\n`\"connection\"` (transient, offer Retry), `\"validation\"` (response/schema\ndrift), or `\"server\"` (backend rejected it — `error` carries the\nhuman-readable reason straight from the backend, e.g. `\"No active coop event\nin this chain.\"`, `\"Matchmaking failed after retries. Please try again.\"`,\n`\"Your object is already completed. Claim your reward.\"`, `\"Grand Prize\nalready claimed.\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |\n| `getDefinitions()` | Load the title's coop event chains (config). | `CoopEventDefinitions` |\n| `getActiveEvent(coopChainID)` | Look up which event in a chain is currently live. | `ActiveCoopEventInfo` (`CoopChainID`, + untyped extra fields — see below) |\n| `getUserState()` | Load this player's own coop-event bookkeeping (state). | `CoopUserStateResponse` (`UserState`, `ActiveGroup`) |\n| `getGroupState(groupID)` | Load the live shared document for a specific group. | `CoopGroupStateResponse` (`Group`, `SecondsRemaining`) |\n| `joinOrCreateGroup(coopChainID)` | Join an existing group for the chain's active event, or start a new one. | `CoopGroupStateResponse` (`Group`) |\n| `spin(coopChainID, groupID)` | Spend the spin cost, roll the spinner, add progress to your own object. | `CoopSpinResponse` (`Sector`, `NewProgress`, `ObjectCompleted`, `AllObjectsCompleted`) |\n| `claimObjectReward(groupID)` | Claim the completion reward for your own finished object. | `CoopClaimRewardResponse` (`RewardType`, `Resources`) |\n| `claimGrandPrize(groupID)` | Claim the group-wide grand prize once every object is complete. | `CoopClaimRewardResponse` (`RewardType`, `Resources`) |\n| `leaveGroup(groupID?)` | Leave your currently-active group. | `CoopLeaveGroupResponse` (`Success`) |\n\nOn success, each method also **mirrors the confirmed change into the cache\nand emits an event** — you don't apply anything by hand. `spin`,\n`claimObjectReward`, and `claimGrandPrize` all carry a `Resources`\n(`ResourceOperation`) payload that is already applied to the cached\ncurrency/item balances, so read updated balances straight from the cache\nrather than off the response. `joinOrCreateGroup` and `claimGrandPrize` also\npatch the player's `ActiveGroupID`/`ActiveCoopEventID`/`MyObjectIndex`\npointer in user state (join sets it optimistically to the joined group with\nan unresolved object index of `-1`; claiming the grand prize clears it back\nto no active group — the authoritative object index itself always comes from\n`getUserState()`, not from the optimistic patch).\n\n**`getActiveEvent`'s typed model is thinner than the wire response.** The\nzod schema (`zActiveCoopEventInfo`) only strongly types `CoopChainID`; the\nbackend actually returns `CycleIndex`, `EventOrder`, `EventDef` (the full\n`CoopEventDefinition` for the live event — spin cost, spinner table, objects,\ngrand prize), `ComputedStartUtc`, `ComputedEndUtc`, and `SecondsRemaining` too.\nBecause every model in this SDK keeps `.passthrough()`, those fields **are**\npresent on the object at runtime — they're just untyped (`unknown` unless you\ncast). If you need `EventDef` to preview spin cost/odds before joining, read\nit off the response with an explicit cast, or fetch `getDefinitions()` and\nlook the event up yourself by chain + `CoopEventID`.\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// This player's own coop-event bookkeeping (only present after getUserState()\n// or after join/spin/claim/leave patch it):\nconst myCoop = client.data.user.state?.CoopEvent;\nmyCoop?.ActiveGroupID; // group I'm currently in, or null/undefined\nmyCoop?.ActiveCoopEventID; // which event that group belongs to\nmyCoop?.MyObjectIndex; // which BuildObjects object is mine (-1 = unresolved)\nmyCoop?.History; // past cycles: GroupID, FinalStatus, GrandPrizeReceived, ...\n\n// Definitions (cached after getDefinitions()):\nimport type { CoopEventDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CoopEventDefinitions>(\"CoopEvent\");\n```\n\nGroup state (the shared document) is **not** written into\n`client.data.user.state` — it's returned directly from `getGroupState`,\n`joinOrCreateGroup`, and the `coopEvent:groupStateLoaded` /\n`coopEvent:groupJoined` events. Keep the latest `CoopGroupDocument` you\nreceived in your own component/store state and refresh it by calling\n`getGroupState(groupID)` again (e.g. on a poll or after your own spin).\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `coopEvent:definitionsLoaded` → `CoopEventDefinitions`\n- `coopEvent:activeEventLoaded` → `ActiveCoopEventInfo`\n- `coopEvent:userStateLoaded` → `CoopUserStateResponse`\n- `coopEvent:groupStateLoaded` → `CoopGroupStateResponse`\n- `coopEvent:groupJoined` → `CoopGroupStateResponse`\n- `coopEvent:spinCompleted` → `CoopSpinResponse`\n- `coopEvent:objectRewardClaimed` → `CoopClaimRewardResponse`\n- `coopEvent:grandPrizeClaimed` → `CoopClaimRewardResponse`\n- `coopEvent:groupLeft` → `CoopLeaveGroupResponse`\n\nThe coarse `user:coopEventUpdated` (and `user:anyUpdated`) also fire whenever\n`getUserState`, `joinOrCreateGroup`, `claimGrandPrize`, or `leaveGroup`\nwrites to the cached user coop-event state — handy for a \"re-render\neverything\" hook. Note `spin` and `claimObjectReward` do **not** touch user\nstate (they only affect the shared group document and resource balances), so\nthey emit their own `coopEvent:*` event and the resource-balance events, but\nnot `user:coopEventUpdated`.\n\n```ts\nconst off = client.on(\"coopEvent:spinCompleted\", (r) => {\n console.log(`Rolled ${r.Sector?.DisplayName}, +${r.ProgressDelta} progress`);\n if (r.ObjectCompleted) console.log(\"Your object is done!\");\n if (r.AllObjectsCompleted)\n console.log(\"Whole group is done — claim the grand prize!\");\n});\n// later: off();\n```\n\n## Recipes\n\n### Golden path: load, join, contribute, claim\n\n```ts\nawait client.coopEvent.getDefinitions();\nconst active = await client.coopEvent.getActiveEvent(\"spring-coop-chain\");\nif (!active.ok) return showError(active.error);\n\n// Join (or get seated into) a group for this chain.\nconst joined = await client.coopEvent.joinOrCreateGroup(\"spring-coop-chain\");\nif (!joined.ok) return showError(joined.error); // e.g. \"Matchmaking failed after retries. Please try again.\"\nconst groupID = joined.data.Group?.GroupID;\nif (!groupID) return;\n\n// Spin to contribute toward your own object.\nconst spin = await client.coopEvent.spin(\"spring-coop-chain\", groupID);\nif (!spin.ok) return showError(spin.error); // e.g. \"SpinCost not configured.\"\nspin.data.NewProgress; // your object's progress after this spin\nspin.data.MaxProgress;\n\nif (spin.data.ObjectCompleted) {\n const reward = await client.coopEvent.claimObjectReward(groupID);\n if (!reward.ok) return showError(reward.error);\n // reward.data.Resources already applied to cached balances.\n}\n\nif (spin.data.AllObjectsCompleted) {\n const grand = await client.coopEvent.claimGrandPrize(groupID);\n if (!grand.ok) return showError(grand.error); // e.g. \"Grand Prize already claimed.\"\n}\n```\n\n### Checking group progress and other members\n\n```ts\nconst group = await client.coopEvent.getGroupState(groupID);\nif (!group.ok) return showError(group.error);\n\ngroup.data.SecondsRemaining; // time left before the group's window expires\n\nfor (const member of group.data.Group?.Members ?? []) {\n member.PublicData?.Username;\n member.SpinsCount;\n member.TokensSpent;\n member.MemberStatus; // \"Active\" | \"Left\" | \"Replaced\"\n member.BuildObjectsProgress?.ObjectIndex; // which object is theirs\n member.BuildObjectsProgress?.ObjectCompletionRewardClaimed;\n}\n\nfor (const obj of group.data.Group?.BuildObjectsState?.Objects ?? []) {\n obj.OwnerUserID;\n obj.CurrentProgress;\n obj.MaxProgress;\n obj.IsCompleted;\n}\n```\n\nPoll `getGroupState` (or re-fetch after your own actions) to keep a \"my\ngroup\" screen showing teammates' progress — there is no group-wide push\nevent, so other members' spins only show up once you re-fetch. Note that\n`getGroupState` (and `joinOrCreateGroup`) also lazily advance bot members'\nprogress server-side on each call — see Gotchas.\n\n### Claiming an object reward vs. the grand prize\n\n```ts\n// Your own object reward — claimable per member, independently of teammates.\nconst objectReward = await client.coopEvent.claimObjectReward(groupID);\nif (!objectReward.ok) return showError(objectReward.error); // e.g. \"Your object is not completed yet.\"\nobjectReward.data.RewardType; // \"ObjectCompletion\"\n\n// Grand prize — one claim per player per group, requires every object done.\nconst grandPrize = await client.coopEvent.claimGrandPrize(groupID);\nif (!grandPrize.ok) return showError(grandPrize.error); // e.g. \"Grand Prize is not available (group status: Active).\"\ngrandPrize.data.RewardType; // \"GrandPrize\"\n```\n\nDon't gate the \"claim object reward\" button on the whole group finishing —\nit only depends on _your_ object. Gate the grand-prize button on\n`AllObjectsCompleted` (from the last `spin`/`getGroupState` response) or on\nwalking `BuildObjectsState.Objects` and checking every `IsCompleted` — note\nthe backend additionally requires the group's own `Status` to have already\nflipped to `\"Completed\"` before `claimGrandPrize` will accept the call, which\nnormally happens automatically the instant the last object finishes.\n\n### Group lifecycle: join, then leave\n\n```ts\nconst joined = await client.coopEvent.joinOrCreateGroup(\"spring-coop-chain\");\nif (!joined.ok) return showError(joined.error);\n\n// ... play the event ...\n\nconst left = await client.coopEvent.leaveGroup(joined.data.Group?.GroupID);\nif (!left.ok) return showError(left.error);\n// client.data.user.state?.CoopEvent now has ActiveGroupID/ActiveCoopEventID\n// cleared (null) and MyObjectIndex reset to -1.\n```\n\n`leaveGroup`'s `groupID` argument is optional — omit it to leave whatever\ngroup the backend has on record as the player's active one. Leaving does not\nun-claim anything already claimed and does not affect other members' groups;\nre-calling `joinOrCreateGroup` afterward may seat the player into a fresh\ngroup (their old object progress belongs to the group they left, not to\nthem).\n\n## Gotchas\n\n- **Guard against double-submit.** Every authenticated request gets a fresh\n `RelatedEntityID` idempotency key, so two separate calls are two real\n operations — a double-clicked \"Spin\" can charge twice. 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, but don't rely on that for correctness.)\n- **User state and group state are separate caches.** Only `getUserState`,\n `joinOrCreateGroup`, `claimGrandPrize`, and `leaveGroup` touch\n `client.data.user.state?.CoopEvent`. `getGroupState`, `spin`, and\n `claimObjectReward` never write there — they only return data and (for\n `spin`/`claimObjectReward`) apply resource balances. Don't expect\n `user:coopEventUpdated` to fire after a spin.\n- **`MyObjectIndex` from `joinOrCreateGroup` is a placeholder, not the\n truth.** The service optimistically patches it to `-1` on join because the\n real assigned object index isn't known client-side yet — call\n `getUserState()` to get the authoritative value before relying on it.\n- **`getActiveEvent`'s typed shape omits the useful fields.** Only\n `CoopChainID` is strongly typed; `EventDef`, `CycleIndex`, `EventOrder`,\n `ComputedStartUtc`/`ComputedEndUtc`, and `SecondsRemaining` ride along\n untyped via passthrough. Cast explicitly if you need them, or resolve the\n live event from `getDefinitions()` config instead.\n- **Group state has no push updates.** There's no live event for \"a\n teammate just spun\" — `getGroupState` (or the response of your own\n `spin`/`joinOrCreateGroup`) is a snapshot. Poll it if you want a\n progress bar for other members to move.\n- **Object reward and grand prize are claimed independently, and each\n guards against re-claiming.** `CoopBuildObjectsMemberState\n.ObjectCompletionRewardClaimed` and `CoopGroupMember.GrandPrizeClaimed` are\n the server's own once-only guards — a repeat call to `claimObjectReward`\n fails with `\"Object completion reward already claimed.\"` (or, on a raw\n race, `\"Claim failed: already claimed or object not completed.\"`), and a\n repeat `claimGrandPrize` fails with `\"Grand Prize already claimed.\"` (or\n `\"Grand Prize claim failed: already claimed or group not completed.\"`).\n- **Groups have a lifecycle beyond \"you're in it\".** `CoopGroupDocument\n.Status` is one of `Forming | Active | Completed | Failed | Expired`\n (`CoopGroupStatus`) and carries `ExpiresAtUtc` / `CreatedAtUtc`. A group\n flips to `Failed` the moment its timer expires with objects unfinished — no\n Grand Prize is granted for a `Failed` group. Surface `Status` and\n `SecondsRemaining` in the UI rather than assuming a joined group stays\n playable indefinitely.\n- **Unfilled groups get backfilled with bots, not left waiting forever.**\n Each event configures `MatchmakingTimeoutMinutes` (how long a `Forming`\n group waits for real players) and `MemberGracePeriodMinutes` (how long a\n vacated slot stays reserved after a member leaves). Once the matchmaking\n timeout passes, the next `getGroupState`/`getUserState`/`joinOrCreateGroup`\n call lazily fills every remaining slot with a bot and flips the group to\n `Active`. Bots (`CoopGroupMember.IsBot === true`) don't really spin — the\n backend deterministically simulates their progress (roughly 60–90% final\n efficiency, linearly interpolated against event time elapsed) on every\n read, so their progress bars advance on their own between your calls.\n- **Members can leave or be replaced without the group disappearing.**\n `CoopGroupMember.MemberStatus` is `Active | Left | Replaced`\n (`CoopMemberStatus`) — a member who leaves keeps their row in `Members`\n with `LeftAtUtc` set rather than being removed, so don't assume\n `Members.length` equals the current headcount; filter on\n `MemberStatus === \"Active\"`.\n- **`spin` only works for `BuildObjects` events.** The config supports a\n second `EventType`, `BossAttack`, reserved for a future mechanic; calling\n `spin` against a chain whose live event isn't `BuildObjects` fails with\n `\"Spin is only available for BuildObjects events.\"` — check `EventType`\n before showing a spin button.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the cache + emits an event; the failure path gives you\n `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, the `BuildObjects` mechanic and spinner-table shape, the group\ndocument tree, the spinner-weight and bot-simulation formulas, and how the\nshared `ResourceConsume`/`ResourceGrant`/`ResourceOperation` types apply\nhere. Read it when building config-driven UI (spin cost previews, spinner\nodds, object progress bars) or when you need the exact shape of the group\ndocument.\n",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
8
+ "content": "# Coop event data model — reference\n\nFull shape of the config (Definitions), the active-event lookup, player state,\nand the shared group document, plus the spinner-weight, progress-roll, and\nbot-simulation formulas the backend actually runs. All of these are **strictly\ntyped in the SDK** where noted below — `CoopEventDefinitions` and its nested\nblocks are exported from `@idosgames/core` with `.passthrough()` schemas, so a\nfield the backend adds later still round-trips even though it may not appear\nin the TS type. Field names are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CoopEventDefinitions](#config-coopeventdefinitions) — what `getDefinitions()` returns\n- [CoopEventChainDefinition](#coopeventchaindefinition)\n- [CoopEventDefinition](#coopeventdefinition-one-event-in-a-chain)\n- [CoopBuildObjectsDefinition + spinner formula](#coopbuildobjectsdefinition--spinner-formula)\n- [ActiveCoopEventInfo (getActiveEvent)](#activecoopeventinfo-getactiveevent)\n- [Player state: UserCoopEventState](#player-state-usercoopeventstate)\n- [Group state: CoopEventGroupDocument](#group-state-coopeventgroupdocument)\n- [Group lifecycle & bot backfill](#group-lifecycle--bot-backfill)\n- [Spin flow, idempotency, and rollback](#spin-flow-idempotency-and-rollback)\n- [Resource shapes](#resource-shapes)\n\n---\n\n## Config: CoopEventDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<CoopEventDefinitions>(\"CoopEvent\")`.\n\n```ts\ninterface CoopEventDefinitions {\n Chains?: Record<string, CoopEventChainDefinition>; // key = CoopChainID\n}\n```\n\n---\n\n## CoopEventChainDefinition\n\nA chain schedules an ordered sequence of events (`Events`), one live at a\ntime, using the SDK-wide `ScheduleSpec` (the same container Leaderboard,\nTimedEvent, Season, and TimedBoost use). Coop chains always run in\n`Schedule.Mode === \"Chained\"`.\n\n```ts\ninterface CoopEventChainDefinition {\n CoopChainID?: string;\n DisplayName?: string;\n Schedule?: ScheduleSpec; // Mode = \"Chained\"; Chain.AnchorUtc/MaxCycles/PauseBetweenPhasesSec/PauseBetweenCyclesSec\n Events?: CoopEventDefinition[]; // sort by Order ASC — this is the phase list\n Gate?: SegmentGate; // audience gate; null = available to everyone\n}\n```\n\n`Gate` (Core/Segment `SegmentGate`) is checked by the backend on\n`GetActiveEvent` and `JoinOrCreateGroup` — a player failing the gate gets\n`\"This coop event is not available for you.\"` on both calls; the chain simply\ndoesn't resolve to an active event for them. It does **not** block\n`getDefinitions()`, `getGroupState()`, `spin()`, or claim calls — those operate\non a `GroupID`/`CoopChainID` the player already has, not on chain-level\ndiscovery.\n\n`Schedule.IsActive === false`, or a chain with no `Events`, means\n`ComputeCoopEventWindow` returns nothing and every chain-scoped call\n(`GetActiveEvent`, `JoinOrCreateGroup`, `Spin`) fails with `\"No active coop\nevent in this chain.\"` (`CoopEvent.cs` lines 122, 261, 458). Pauses between\nphases/cycles (`PauseBetweenPhasesSec` / `PauseBetweenCyclesSec`) resolve to\nthe same window shape with `IsInPause = true`, which the backend treats\nidentically to \"no active event.\"\n\n---\n\n## CoopEventDefinition (one event in a chain)\n\n```ts\ninterface CoopEventDefinition {\n CoopEventID?: string; // e.g. \"coop_fairytale_partners_1\"\n Order?: number; // position in the chain (0, 1, 2, ...)\n DurationSec?: number; // default 518_400 (6 days); typical range 432,000–604,800 (5–7 days)\n DisplayName?: string;\n Description?: string;\n AssetPaths?: Record<string, string>;\n EventType?: \"BuildObjects\" | \"BossAttack\"; // default \"BuildObjects\"; BossAttack is reserved/unimplemented\n PartnerCount?: number; // default 4 — partners excluding the initiator\n MatchmakingTimeoutMinutes?: number; // default 5\n MemberGracePeriodMinutes?: number; // default 30\n BuildObjects?: CoopBuildObjectsDefinition; // populated only when EventType = \"BuildObjects\"\n GrandPrize?: ResourceGrant; // granted to every member once all objects complete\n}\n```\n\n**Group size is `1 + PartnerCount`.** With the default `PartnerCount = 4` that\nis 5 members total (1 initiator + 4 partners), matching `Objects` 0..4 if\n`BuildObjects.Objects` has 5 entries. `EventType` currently only really\nsupports `\"BuildObjects\"` — `spin` rejects any other type with `\"Spin is only\navailable for BuildObjects events.\"` (`CoopEvent.cs:463`); `BossAttack` exists\nin the enum (`CoopEventType.cs`, `CoopEventDefinitions.cs:41`) but has no\nimplemented mechanic yet (\"реализация позже\" / \"implementation later\" per the\nsource comment).\n\n---\n\n## CoopBuildObjectsDefinition + spinner formula\n\n```ts\ninterface CoopBuildObjectsDefinition {\n Objects?: CoopObjectDefinition[]; // one per partner slot; index = 0..(1+PartnerCount-1)\n SpinCost?: ResourceConsume; // charged per spin() call\n SpinnerTable?: CoopSpinnerSector[]; // weighted probability table\n}\n\ninterface CoopObjectDefinition {\n Index?: number; // 0..N-1, matches CoopPartnerObjectState.Index / CoopBuildObjectsMemberState.ObjectIndex\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n MaxProgress?: number; // default 100 — object completes at CurrentProgress >= MaxProgress\n CompletionReward?: ResourceGrant; // paid to the object's owner via claimObjectReward\n}\n\ninterface CoopSpinnerSector {\n DisplayName?: string; // e.g. \"Small\", \"Medium\", \"Big\", \"Jackpot\"\n AssetPaths?: Record<string, string>;\n Weight?: number; // arbitrary scale, does not need to sum to 100\n MinProgress?: number;\n MaxProgress?: number;\n}\n```\n\n**Sector selection formula** (`CoopEventHelpers.PickSpinnerSector`,\n`CoopEventHelpers.cs:200-213`): `P(sector) = sector.Weight / sum(all\nsector.Weight in SpinnerTable)`. Example: weights `[40, 35, 20, 5]` → 40%,\n35%, 20%, 5%. Selection uses `SecureRandom` over the cumulative-weight range,\nso it's a genuine server-side roll — never simulate the roll client-side for\nanything but a cosmetic pre-spin animation.\n\n**Progress-per-spin formula**: once a sector is chosen, the actual progress\nawarded is `SecureRandom.Next(sector.MinProgress, sector.MaxProgress + 1)` —\na uniform-random integer in the inclusive range `[MinProgress, MaxProgress]`\n(`CoopEvent.cs:526`). If the tentative new progress (`CurrentProgress +\ndelta`) would reach or exceed `MaxProgress`, the object clamps to exactly\n`MaxProgress` (no overshoot) and is marked `IsCompleted` (`CoopEvent.cs:564-566`).\n\n**Empty-`Objects` fallback**: if a `CoopEventDefinition.BuildObjects.Objects`\nlist is empty when a new group is created, the backend synthesizes\n`1 + PartnerCount` objects with `MaxProgress = 100` and no completion reward\n(`CoopEvent.cs:1255-1268`) rather than failing — so a misconfigured title still\nproduces a playable (if reward-less) event. Don't rely on this — always\nconfigure `Objects` explicitly if you want completion rewards.\n\n**Spin cost accounting**: `SpinCost` is a `ResourceConsume`; the backend sums\n`Standard.EventTokens[].Amount` + `Standard.Entries[].Amount` into a single\n`spinTokenAmount` purely for the group document's `TokensSpent` counter\n(`CoopEvent.cs:528-532`) — the actual debit against the player's balance goes\nthrough `ResourceService.ApplyResourceOperationAtomicAsync` using the full\n`ResourceConsume` (including any `PremiumDiscounts`), so the amount reflected\nin `Resources.Consume` on the response can be lower than the raw config sum if\nthe player has a discount-granting premium tier. `spin()` rejects with\n`\"SpinCost not configured.\"` if `SpinCost.Standard` is null, and with\n`\"SpinnerTable not configured.\"` if the table is empty.\n\n---\n\n## ActiveCoopEventInfo (getActiveEvent)\n\nThe **typed** SDK model (`zActiveCoopEventInfo`) is intentionally thin:\n\n```ts\ninterface ActiveCoopEventInfo {\n CoopChainID: string;\n // + untyped passthrough fields, see below\n}\n```\n\nThe **actual backend response** (`ActiveCoopEventInfo` C# class,\n`CoopEvent.cs:1364-1373`) carries more:\n\n```ts\n// present at runtime, NOT in the TS type — cast explicitly if you need them\ninterface ActiveCoopEventInfoWire extends ActiveCoopEventInfo {\n CycleIndex: number; // which cycle of the chain this is\n EventOrder: number; // Order of the live CoopEventDefinition\n EventDef: CoopEventDefinition; // the full live event config (spin cost, spinner, objects, grand prize)\n ComputedStartUtc: string; // ISO — when this event's window started\n ComputedEndUtc: string; // ISO — when this event's window ends\n SecondsRemaining: number; // computed server-side at response time\n}\n```\n\nSince every schema in this SDK keeps `.passthrough()`, these fields survive\nparsing and are readable off the object — just not type-checked. Prefer\ncasting the response (`active.data as ActiveCoopEventInfoWire`) over widening\nthe whole module's types yourself.\n\n---\n\n## Player state: UserCoopEventState\n\nReturned inside `CoopUserStateResponse.UserState` by `getUserState()`, and\ncached at `client.data.user.state?.CoopEvent`.\n\n```ts\ninterface UserCoopEventState {\n ActiveGroupID?: string | null; // group the player is currently in, or null\n ActiveCoopEventID?: string | null; // which CoopEventID that group belongs to\n MyObjectIndex?: number; // which BuildObjects object is theirs; -1 = unresolved/none\n LastSpinAtUtc?: string; // ISO — server bookkeeping, not itself a rate limit you should read\n History?: CoopEventHistoryEntry[]; // most recent 5 finished cycles (FIFO)\n}\n\ninterface CoopEventHistoryEntry {\n GroupID?: string;\n CoopEventID?: string;\n FinalStatus?: string; // \"Completed\" | \"Failed\" (CoopGroupStatus at finish time)\n GrandPrizeReceived?: boolean;\n FinishedAtUtc?: string; // ISO\n}\n```\n\n`getUserState()` self-heals stale pointers: if the player's `ActiveGroupID`\npoints at a group that has become terminal (`Failed`, `Expired`, or past its\n`ExpiresAtUtc` without completing — see\n[Group lifecycle](#group-lifecycle--bot-backfill)), the backend clears\n`ActiveGroupID`/`ActiveCoopEventID` to `null` and `MyObjectIndex` to `-1`\nserver-side before returning, in the same call (`CoopEvent.cs:171-177`) — you\ndon't need to detect and clear this yourself.\n\n---\n\n## Group state: CoopEventGroupDocument\n\nReturned as `Group` by `getGroupState()`, `joinOrCreateGroup()`, and inside\n`CoopUserStateResponse.ActiveGroup`. This document is **shared** — every\nmember's calls read and write the same record (collection `CoopGroups`,\nkeyed by `GroupID`, with MongoDB optimistic locking on `Version`).\n\n```ts\ninterface CoopGroupDocument {\n GroupID?: string;\n TitleID?: string;\n CoopChainID?: string;\n CoopEventID?: string;\n CycleIndex?: number; // which chain cycle this group belongs to\n Members?: CoopGroupMember[];\n BuildObjectsState?: { Objects?: CoopPartnerObjectState[] };\n Status?: \"Forming\" | \"Active\" | \"Completed\" | \"Failed\" | \"Expired\";\n CreatedAtUtc?: string;\n ExpiresAtUtc?: string; // group fails automatically once now >= this\n Version?: number; // optimistic-lock counter; increments on every mutation\n}\n\ninterface CoopGroupMember {\n UserID?: string;\n PublicData?: UserPublicDataModel; // snapshot taken at join time\n BuildObjectsProgress?: {\n ObjectIndex?: number;\n ObjectCompletionRewardClaimed?: boolean;\n };\n IsBot?: boolean;\n SpinsCount?: number;\n TokensSpent?: number; // sum of SpinCost token amounts, config-side (see spin cost accounting above)\n MemberStatus?: \"Active\" | \"Left\" | \"Replaced\";\n GrandPrizeClaimed?: boolean;\n JoinedAtUtc?: string;\n LeftAtUtc?: string; // set when MemberStatus becomes \"Left\"; row is never removed\n}\n\ninterface CoopPartnerObjectState {\n Index?: number; // matches CoopObjectDefinition.Index / member's ObjectIndex\n OwnerUserID?: string; // set once the owning member is known (join-time or bot-fill time)\n CurrentProgress?: number;\n MaxProgress?: number; // copied from config at group-creation time\n IsCompleted?: boolean;\n}\n```\n\n---\n\n## Group lifecycle & bot backfill\n\n`CoopGroupStatus` transitions (all server-driven, never set by the client):\n\n- **Forming** → initial state when a group is created; still recruiting.\n- **Forming → Active**: either matchmaking fills every slot\n (`1 + PartnerCount` members), or `MatchmakingTimeoutMinutes` elapses and the\n backend lazily backfills remaining slots with bots on the next read\n (`TryFillWithBotsIfNeeded`, `CoopEvent.cs:1029-1100`) — triggered from\n `getGroupState`, `joinOrCreateGroup`, and (indirectly) `getUserState`. If a\n group is `Forming` and the last active human member leaves, it flips\n straight to `Failed` instead (`CoopEvent.cs:990-994`).\n- **Active → Completed**: the moment every object's `IsCompleted` becomes\n true (checked right after each spin commits, and after each lazy\n bot-progress simulation). Grand Prize is claimable once `Status ===\n\"Completed\"`.\n- **Active/Forming → Failed**: `now >= ExpiresAtUtc` before all objects\n finish. Checked lazily on `getGroupState` and `spin`. No Grand Prize is ever\n granted for a `Failed` group.\n- **→ Expired**: a later archival state (grace period after\n Completed/Failed) — `IsGroupTerminal` treats `Failed`, `Expired`, and \"past\n `ExpiresAtUtc` while not `Completed`\" as equivalent terminal states for the\n self-heal check in `GetUserState`.\n\n**Bot simulation** (`SimulateBotProgressAsync`, `CoopEvent.cs:1111-1186`) runs\non every `getGroupState` / `joinOrCreateGroup` call while the group is\n`Active` and has bot members. It's a deterministic linear-interpolation model,\nnot a real spin loop:\n\n- `progress = clamp(elapsedSec / totalSec, 0, 1)` where `totalSec =\nExpiresAtUtc - CreatedAtUtc` and `elapsedSec = now - CreatedAtUtc`.\n- Each bot gets a fixed per-bot `efficiency = 0.60 + rng.NextDouble() * 0.30`\n (uniformly 60%–90%), seeded deterministically from `HashSeed(GroupID +\nBotUserID)` so every player's read of the same group sees the same bot\n trajectory.\n- `targetProgress = floor(MaxProgress * efficiency * progress)`; the bot's\n `CurrentProgress` is advanced toward (never past) that target and clamped to\n `MaxProgress`, marking `IsCompleted` on reaching it.\n\nThis means a bot's bar can visibly jump when you reopen the group screen\nafter time has passed — that's expected, not a bug to work around.\n\n---\n\n## Spin flow, idempotency, and rollback\n\n`spin()` runs as two phases against different stores, in this order:\n\n1. **Group document** (`coop_groups`): an optimistic-lock patch adds the\n rolled progress to the caller's object, bumps `Version`, and increments\n `Members[i].SpinsCount`/`TokensSpent`. Retried up to 3 times\n (`SpinMaxRetries`) on a concurrent-write conflict before failing with\n `\"Spin commit failed (concurrent update). Please retry.\"`.\n2. **Resource ledger**: only after phase 1 commits, the token/currency cost is\n charged via `ResourceService.ApplyResourceOperationAtomicAsync` under a\n `reason` of `CoopSpin:<idempotencyKey>` (the idempotency key is derived\n from the request's `RelatedEntityID`, salted with the chain+group). If the\n charge fails, the backend best-effort **rolls back** the group progress it\n just committed in phase 1 (decrements `CurrentProgress`/`SpinsCount`/\n `TokensSpent`) and returns `\"Spin failed: <reason>\"`.\n\nBecause the idempotency key is keyed off `RelatedEntityID`, and the SDK's\n`buildAuthedBaseRequest()` mints a fresh UUID for every call, retrying the\nexact same client-side call object (same `RelatedEntityID`) is safe — a\nduplicate charge is suppressed as an `IdempotentReplay` — but issuing a\n**new** `spin()` call (fresh `RelatedEntityID`, e.g. from a second button\nclick) is a genuinely new operation and will charge again. This is the same\n\"idempotency protects retries, not double-submits\" rule as every other module\nin the SDK.\n\n`claimObjectReward` and `claimGrandPrize` follow the same two-phase,\nrollback-on-failure pattern against their own flags\n(`ObjectCompletionRewardClaimed`, `GrandPrizeClaimed`) instead of a progress\ndelta.\n\n---\n\n## Resource shapes\n\n`SpinCost` (`ResourceConsume`), `CompletionReward` and `GrandPrize`\n(`ResourceGrant`), and every response's `Resources` (`ResourceOperation`) use\nthe SDK-wide shared shapes from `_shared/ResourceModels` — the same types\n`Store`, `Character`, `Craft`, and every other resource-touching module use:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[]; // currencies/items: { Type, CurrencyID?, Amount?, CatalogID?, ItemID? }\n EventTokens?: EventTokenOperation[]; // { Address: { Type, EntityID }, Amount, Source? }\n}\ninterface ResourceGrant {\n Standard?: ResourceBundle;\n PremiumBonuses?: unknown[];\n PremiumTiers?: PremiumTierBundle[]; // extra bundles unlocked by MinPremiumTier/RequiredPremiumID\n}\ninterface ResourceConsume {\n Standard?: ResourceBundle;\n PremiumDiscounts?: unknown[];\n PremiumTiers?: PremiumTierBundle[];\n}\ninterface ResourceOperation {\n Grant?: ResourceGrant;\n Consume?: ResourceConsume;\n}\n```\n\nDon't reimplement premium-discount or tier-bundle resolution client-side —\nthe backend resolves the player's best applicable tier/discount and returns\nthe _actually applied_ amounts in the response's `Resources`, which the\nservice already applies to the cache for you.\n"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "craft-system",
3
+ "description": "Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.craft (CraftService): load craft recipe definitions and execute a craft that burns input item instances (trade-up by rarity or trade-up by collection) to produce a rolled output item. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage UIs, or otherwise touches client.craft, CraftService, CraftDefinitions, CraftDefinition, or CraftResponse — even if they don't name the module explicitly.",
4
+ "content": "---\nname: craft-system\ndescription: >-\n Build a crafting / trade-up system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.craft (CraftService): load craft recipe\n definitions and execute a craft that burns input item instances (trade-up\n by rarity or trade-up by collection) to produce a rolled output item. Use\n this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants item-fusion / trade-up / salvage\n UIs, or otherwise touches client.craft, CraftService, CraftDefinitions,\n CraftDefinition, or CraftResponse — even if they don't name the module\n explicitly.\n---\n\n# Craft system (iDosGames TS SDK)\n\nThe Craft module lets a title define recipes that burn a fixed number of the\nplayer's owned item instances and produce one rolled output item — either\n**trade up by rarity** (N items of `InputRarityID` → one item of\n`OutputRarityID`, any collection) or **trade up by collection** (N items of\n`CollectionID` at `InputRarityID` → one item of the same `CollectionID` at\n`OutputRarityID`). It's **server-authoritative**: the client sends the recipe\nid and the specific item instances to burn, the backend validates\nownership/rarity/collection/cost and rolls the output with a\ncryptographically-secure RNG, and the SDK mirrors the resulting resource\nchanges into the local cache. You never resolve a craft yourself — you call\n`craft()`, check the result, and render from the response + cache.\n\nThis skill is for **using** the production `CraftService`, not for porting or\nextending it. If a craft is rejected, that's the backend enforcing a rule\n(wrong item count, item not in the allowed rarity/collection, insufficient\nprice), or a \"no valid input/output items configured\" state — surface the\nerror, don't try to reproduce the check client-side.\n\n## Key data entities\n\nOnly one config shape and no dedicated player-state shape:\n\n1. **`CraftDefinitions`** (config, same for every player) — the title's\n recipe catalog, keyed by `CraftID`. Fetched with `getDefinitions()`,\n cached under the `\"Craft\"` config section. Each `CraftDefinition` carries\n `Type` (`\"TradeUpRarity\"` | `\"TradeUpCollection\"`), the optional source\n `CatalogID`, `InputRarityID`/`OutputRarityID` (+ `CollectionID` for\n collection trade-ups), `RequiredItemCount`, and `PriceOptions`.\n2. **No player-state slot.** Unlike most other modules, Craft has **no**\n `client.data.user.state?.Craft` entry and **no** dedicated \"player craft\n state\" endpoint — a craft's outcome lives only in the `CraftResponse` and\n in the standard inventory/currency/event-token cache that\n `data.Resources` feeds into. There's nothing to \"load\" besides the recipe\n catalog.\n\nThe input items you burn are **item instances already in the player's\ninventory** — the recipe config only says how many and which\nrarity/collection they must belong to; it never lists specific instance ids.\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 craft = client.craft; // the CraftService\n```\n\nEvery craft method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args, e.g. missing\n`CraftID`), `\"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. wrong input count, item not\nallowed, no outputs configured, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------- |\n| `getDefinitions()` | Load the title's craft recipe catalog (config). | `CraftDefinitionsResponse` |\n| `craft(craftID, inputItemIDs, count?, selectedOptionID?)` | Burn `inputItemIDs`, execute the recipe, apply the rolled output(s). | `CraftResponse` |\n\nNon-obvious parameter notes:\n\n- **`inputItemIDs` is a template, not a flat list.** Pass **exactly**\n `RequiredItemCount` item-instance ids — one craft's worth — regardless of\n `count`. The server repeats that same template `count` times internally and\n consumes `RequiredItemCount * count` total instances; sending\n `RequiredItemCount * count` ids yourself is rejected (\"InputItemIDs must\n contain exactly `{RequiredItemCount}` items\"). This means every iteration\n in a batched craft burns instances with the _same ids_ you passed — the\n server does not let you target `count` independent sets of instances in one\n call.\n- **`inputItemIDs` are item _instance_ ids, not catalog/definition ids.** The\n server checks each instance's underlying `ItemID` against the recipe's\n allowed-input set (by `InputRarityID`, and by `CollectionID` too for\n `TradeUpCollection`) and that the player actually holds enough of that item\n in total (equipped instances don't count — see Gotchas).\n- **`count`** (default `1`) is clamped server-side to **1–20** per call\n (`Math.Clamp(args.Count, 1, 20)`); passing 0, negative, or above 20 is\n silently clamped into range, not rejected.\n- **`selectedOptionID`** picks one entry of the recipe's `PriceOptions` map.\n Omit it to get the first option in the map (`PriceOptions.First()` —\n insertion order, not necessarily a \"default\" one you'd expect) when the\n recipe has more than one, or the sole option when it has just one. If\n `PriceOptions` is empty/absent, the craft is **free** (only the input items\n are burned). Passing an id that doesn't exist in the map fails with\n `\"Price option '{id}' not found.\"`.\n\nOn success, `craft()` applies `data.Resources` (consumed inputs + price,\ngranted output) to the cached currency/item/event-token balances via the\nshared resource-operation pipeline — read updated balances from the cache as\nusual.\n\n## Reading state and reacting to changes\n\nThere is no `Craft` cache slot to read — drive recipe-card UI off the config\nsection, and drive result UI directly off each `craft()` response plus the\nstandard inventory/currency cache:\n\n```ts\n// Config (cached after getDefinitions()):\nimport type { CraftDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\n// After craft(): read burned/rolled output straight off the response —\n// there's no \"last craft\" anywhere in client.data.user.state.\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `craft:definitionsLoaded` → `CraftDefinitions`\n- `craft:completed` → `CraftResponse`\n\nThere is **no** `user:craftUpdated` coarse event for this module (every other\nmodule with a state slot has one; Craft doesn't, because it has no state\nslot). `craft()` still triggers the generic resource-side events as a side\neffect of applying `data.Resources`: `user:inventoryUpdated` (items burned\nand/or granted), `user:virtualCurrencyUpdated` (if a `PriceOptions` entry\ncharges VC), `user:eventTokenUpdated` (if it charges event tokens), and the\numbrella `user:anyUpdated` — each only fires if that bucket actually changed.\n\n```ts\nconst off = client.on(\"craft:completed\", (r) => {\n console.log(`Crafted ${r.CraftID} x${r.CraftedCount}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and render a recipe card\n\n```ts\nawait client.craft.getDefinitions();\nconst defs = client.data.config.getSection<CraftDefinitions>(\"Craft\");\n\nfor (const [craftID, def] of Object.entries(defs?.Definitions ?? {})) {\n // def.Type: \"TradeUpRarity\" | \"TradeUpCollection\"\n // def.InputRarityID / def.OutputRarityID — always present for both types\n // def.CollectionID — only meaningful for \"TradeUpCollection\"\n // def.RequiredItemCount — how many input instances one craft consumes\n // def.PriceOptions: Record<optionID, { OptionID, RequiredResources }>\n}\n```\n\n### Trade up by rarity (single craft)\n\n```ts\nconst res = await client.craft.craft(\"rarity-common-to-rare\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n]);\nif (!res.ok) return showError(res.error); // e.g. wrong count, wrong rarity\nres.data.Results?.[0]?.Output; // the rolled output entry ({ Type: \"Item\", ItemID, CatalogID, Amount: 1 })\nres.data.Results?.[0]?.BurnedItemIDs; // the instance ids actually consumed for this iteration\n```\n\n`RequiredItemCount` on the definition is the number of input items **per\ncraft** — pass exactly that many `inputItemIDs`, no matter what `count` you\nplan to pass.\n\n### Trade up by collection\n\n```ts\nconst res = await client.craft.craft(\"collection-set-a\", [\n \"inst-1\",\n \"inst-2\",\n \"inst-3\",\n \"inst-4\",\n \"inst-5\",\n]);\nif (!res.ok) return showError(res.error);\nres.data.Results?.[0]?.RolledCollectionID; // == the recipe's CollectionID\nres.data.Results?.[0]?.UsedCollections; // { [collectionID]: RequiredItemCount }\nres.data.Results?.[0]?.Output; // rolled output item, same collection, OutputRarityID\n```\n\n`TradeUpCollection` requires every input instance's `CollectionID` **and**\n`RarityID` to match the recipe's `CollectionID`/`InputRarityID`; the rolled\noutput is drawn only from items of that same `CollectionID` at\n`OutputRarityID` — cross-collection trade-ups use `TradeUpRarity` instead\n(which ignores `CollectionID` entirely, on both the input and the candidate\noutput pool).\n\n### Craft multiple times in one call\n\n```ts\nconst res = await client.craft.craft(\n \"collection-set-a\",\n templateInputInstanceIDs, // exactly RequiredItemCount ids — NOT multiplied by count\n 3, // count\n \"gems\", // selectedOptionID, if the recipe has more than one price option\n);\nif (!res.ok) return showError(res.error);\nres.data.CraftedCount; // how many iterations ran (clamped to 1-20, so may be < your request)\nres.data.Results; // one CraftSingleResult per iteration, each independently rolled\n```\n\nThis is atomic — either all `CraftedCount` iterations are charged and\napplied, or none are. Each iteration rolls its own output independently\n(same input template, `craftCount` separate weighted rolls); results are\nreported per-iteration in `res.data.Results`, indexed `0..CraftedCount-1`.\n\n### Preview cost before crafting\n\n```ts\nconst def = defs?.Definitions?.[\"rarity-common-to-rare\"];\nconst option =\n def?.PriceOptions?.[\"gems\"] ?? Object.values(def?.PriceOptions ?? {})[0];\n// option.RequiredResources.Standard.Entries — cost of ONE craft; multiply by\n// your intended `count` yourself for a display estimate. The server does the\n// same multiplication and may apply PremiumDiscounts you can't predict\n// client-side, so treat any client-side total as an estimate, not a quote.\n```\n\n## Gotchas\n\n- **No cache slot, no coarse event.** Craft doesn't write a\n `client.data.user.state?.Craft` entry or emit a `user:craftUpdated` event —\n only `craft:definitionsLoaded`, `craft:completed`, and the resource-side\n events (`user:inventoryUpdated`, etc.) fire. There is no server-side \"craft\n history\" endpoint either; if you need a history UI, keep it client-side off\n `craft:completed`.\n- **`inputItemIDs` is a per-craft template, always length `RequiredItemCount`\n — never `RequiredItemCount * count`.** Sending more ids than\n `RequiredItemCount` fails with `\"InputItemIDs must contain exactly\n{RequiredItemCount} items (RequiredItemCount).\"` regardless of `count`.\n- **Equipped instances cannot be consumed.** The preflight check counts total\n owned quantity of each required `ItemID`; if it's short, the error\n explicitly says _\"Not enough '{itemID}' to craft. Need {n}, have {m}. Note:\n equipped instances cannot be consumed.\"_ — tell the player to unequip\n first, don't silently swap instances for them.\n- **A recipe can have zero valid outputs and still exist.** If the title's\n item catalog has no item at `OutputRarityID` (and, for collection\n trade-ups, `CollectionID`) with `Weight > 0`, every craft attempt on that\n recipe fails with `\"Trade-up impossible: ...\"` even though `GetDefinitions`\n happily returned the recipe. Don't assume a listed recipe is always\n craftable — surface the server error as-is.\n- **`count` is silently clamped to 1–20**, not validated/rejected — if you\n let players type an arbitrary batch size, clamp and reflect it in your own\n UI so the displayed cost/output count matches what the server will actually\n do (`res.data.CraftedCount` is the ground truth).\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 \"Craft\" burns items twice. 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- **The RNG is server-side and cryptographically secure.** Never predict or\n precompute the rolled output client-side from `Weight`s in the config —\n it's for building an odds-preview UI only, not for guessing the result\n before the response arrives.\n- **Render from the response for this module.** Since there's no dedicated\n state cache, drive craft-result UI (burned items, rolled output, rolled\n collection) directly off `CraftResponse`, then let the standard\n inventory/currency/event-token cache update the rest of the screen.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full `CraftDefinition`\n/ `CraftPriceOption` field shapes, the exact server-side input/output matching\nrules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order (with verbatim error strings).\n",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
8
+ "content": "# Craft data model — reference\n\nFull shape of the config (`CraftDefinitions`), the server-side input/output\nmatching rules per `CraftType`, the weighted-roll algorithm, and the preflight\nvalidation order with verbatim error strings. All of these are **strictly\ntyped in the SDK** — `CraftDefinitions`, `CraftDefinition`, `CraftPriceOption`,\n`CraftResponse`, `CraftSingleResult` are exported from `@idosgames/core`, so\n`getDefinitions()` and `getSection<CraftDefinitions>(\"Craft\")` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Config: CraftDefinitions](#config-craftdefinitions) — what `getDefinitions()` returns\n- [CraftDefinition](#craftdefinition)\n- [CraftPriceOption](#craftpriceoption)\n- [CraftType matching rules](#crafttype-matching-rules) — exactly what makes an input/output \"allowed\"\n- [The craft flow, in order](#the-craft-flow-in-order) — validation → preflight → roll → apply\n- [Weighted roll algorithm](#weighted-roll-algorithm)\n- [Response shapes](#response-shapes)\n\n---\n\n## Config: CraftDefinitions\n\nReturned by `getDefinitions()` as `CraftDefinitionsResponse`; cached via\n`client.data.config.getSection<CraftDefinitions>(\"Craft\")`.\n\n```ts\ninterface CraftDefinitions {\n Definitions?: Record<string, CraftDefinition> | null; // key = CraftID\n}\n```\n\n---\n\n## CraftDefinition\n\nOne recipe. Source: `IDosGamesSDK/API/Client/v2/Craft/Models/CraftDefinitions.cs`.\n\n```ts\ninterface CraftDefinition {\n CraftID?: string;\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\"; // default on the backend is TradeUpRarity\n\n // Source item catalog (V2: ItemDefinitions.Catalogs[CatalogID]).\n // Empty/absent -> ItemDefinition lookup runs across ALL of the title's catalogs.\n CatalogID?: string;\n\n // Used only when Type === \"TradeUpCollection\". Both input AND output items'\n // Metadata.CollectionID must equal this. Ignored entirely for TradeUpRarity.\n CollectionID?: string;\n\n InputRarityID?: string; // required for both CraftTypes\n OutputRarityID?: string; // required for both CraftTypes\n\n RequiredItemCount?: number; // default 10 on the backend (\"usually 10, CS trade-up\")\n\n PriceOptions?: Record<string, CraftPriceOption>; // key = OptionID; empty/absent = free craft\n}\n```\n\nNote the backend default of `RequiredItemCount = 10` and `Type =\nTradeUpRarity` only apply when a title's config omits the field entirely —\nalways read the value the server actually returned rather than assuming 10.\n\n---\n\n## CraftPriceOption\n\nOne payment option for a recipe.\n\n```ts\ninterface CraftPriceOption {\n OptionID?: string;\n RequiredResources?: ResourceConsume; // cost of ONE craft; server multiplies by `count`\n}\n```\n\n`RequiredResources` is the shared `ResourceConsume` shape (`Standard.Entries`\nfor VC/item costs, `Standard.EventTokens` for event-token costs,\n`PremiumDiscounts` for subscription-tier discounts). See the currency-system\nskill / `ResourceModels.ts` for the full shape — Craft doesn't add anything\ncraft-specific to it.\n\nSelection logic (`SelectPriceOption` in `Craft.cs`):\n\n- `PriceOptions` empty or absent → the craft is **free**: a virtual option\n with `RequiredResources = new ResourceConsume()` (no cost) is used, no\n input other than the burned items.\n- `selectedOptionID` omitted, but `PriceOptions` non-empty → the **first**\n entry of the map is used (`craftConfig.PriceOptions.First()` — .NET\n dictionary enumeration order, effectively insertion order; don't rely on\n this being a semantically \"default\" or \"cheapest\" option).\n- `selectedOptionID` provided but not found in the map → fails with\n `\"Price option '{selectedOptionID}' not found.\"`.\n\n---\n\n## CraftType matching rules\n\nBoth `CraftType`s run the same shape of validation; the difference is which\n`ItemDefinition.Metadata` fields the input/output item pools are filtered by.\nSource: `CraftTradeUpCollection` / `CraftTradeUpRarity` in `Craft.cs`.\n\n### TradeUpRarity\n\n| Pool | Filter |\n| ----------------- | ----------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.RarityID == InputRarityID` (any `CollectionID`, cross-collection allowed) |\n| Candidate outputs | `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\nEvery item scanned comes from `CatalogID` if set, else every catalog on the\ntitle (`EnumerateCatalogItems`).\n\n- No items match the input filter → `\"No INPUT items found for\nRarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: no outputs for\nrarity '{OutputRarityID}' with Weight > 0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected Rarity='{InputRarityID}'.\"`\n\n### TradeUpCollection\n\n| Pool | Filter |\n| ----------------- | ---------------------------------------------------------------------------------------------------------- |\n| Allowed inputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == InputRarityID` |\n| Candidate outputs | `Metadata.CollectionID == CollectionID` **and** `Metadata.RarityID == OutputRarityID` **and** `Weight > 0` |\n\n- `CollectionID` missing on the recipe config → `\"Craft config: CollectionID\nis required.\"`\n- No items match the input filter → `\"No INPUT items found for\nCollectionID='{CollectionID}' and Rarity='{InputRarityID}'.\"`\n- No items match the output filter → `\"Trade-up impossible: CollectionID='\n{CollectionID}' has no outputs for rarity '{OutputRarityID}' with Weight >\n0.\"`\n- A submitted input instance's `ItemID` isn't in the allowed-input set →\n `\"Item {itemID} is not allowed. Expected CollectionID='{CollectionID}',\nRarity='{InputRarityID}'.\"`\n\nIn both types, `Metadata` is the `ItemDefinition.Metadata` block\n(`RarityID`, `CollectionID`, `AuthorID`) — an item with no `Metadata` at all\nnever matches either pool.\n\n---\n\n## The craft flow, in order\n\n`Craft()` in `Craft.cs` runs these steps; the SDK's `craft()` is a thin pass\nthrough, so every one of these can surface as a `reason: \"server\"` error:\n\n1. **`CraftID` required** → `\"CraftID is required.\"`\n2. **Recipe must exist** in `titleConfig.Craft.Definitions` → `\"Craft config\nnot found.\"`\n3. **Title must have item definitions configured** → `\"Item definitions are\nnot configured for this title.\"`\n4. **`count` clamp** — `craftCount = Math.Clamp(args.Count, 1, 20)`. Values\n outside `[1, 20]` are silently clamped, never rejected.\n5. **Price option selection** (see above).\n6. **`RequiredItemCount` template check** —\n `requiredPerCraft = Math.Max(1, craftConfig.RequiredItemCount)`;\n `InputItemIDs.Count` must equal `requiredPerCraft` exactly, regardless of\n `craftCount` → `\"InputItemIDs must contain exactly {requiredPerCraft}\nitems (RequiredItemCount).\"` The server then builds the real burn list by\n repeating your template `craftCount` times\n (`Enumerable.Repeat(args.InputItemIDs, craftCount).SelectMany(x => x)`).\n7. **Build allowed-input / candidate-output pools** from the item catalog per\n `CraftType` (see above), fail fast if either is empty.\n8. **Validate every (repeated) input instance's `ItemID`** is in the\n allowed-input set (see per-type error strings above).\n9. **Preflight balance check** (`ValidatePreflightBalances`) — read-only,\n before any RNG roll, so a doomed craft never wastes a roll:\n - Input items: total owned quantity (`ItemTotals.TotalAmount`, i.e.\n **includes equipped instances in the count but excludes them from what's\n consumable** — see the Gotchas note in the main skill) must be `>=`\n the required quantity per `ItemID` → `\"Not enough '{itemID}' to craft.\nNeed {n}, have {m}. Note: equipped instances cannot be consumed.\"`\n - Price `Item` entries: combined with any input-item need for the same\n `ItemID` → `\"Not enough '{itemID}' (input + price). Need {combined}\n(input={a}, price={b}), have {have}.\"`\n - Price `VirtualCurrency` entries → `\"Not enough '{currencyID}'. Need\n{n}, have {m}.\"`\n - Price `EventTokens` entries → `\"Not enough event tokens. Need {n}, have\n{m}.\"`\n - Price entries of type `CryptoCurrency` skip this preflight (checked\n later, decimal-precise, inside the atomic apply).\n - Price entries of type `UsdCent` are rejected outright → `\"Invalid price\noption: UsdCent is not supported as in-game price.\"`\n - **This preflight is intentionally conservative**: it checks the full\n undiscounted price. `PremiumDiscounts` are applied later, only inside\n `ResourceService`'s atomic apply — so a player with a discount may see\n the preflight \"pass\" at a higher number than what's actually charged,\n never the reverse.\n10. **Roll one output per iteration** (`craftCount` independent weighted\n rolls — see below) only after preflight passes, so RNG is never spent on\n a craft that was going to fail anyway.\n11. **Build the `ResourceOperation`** — `Consume.Standard.Entries` = grouped\n input items (by `ItemID`, summed count) + price `Item`/`VirtualCurrency`\n entries (each `Amount * craftCount`); `Consume.Standard.EventTokens` =\n price event-token entries (`Amount * craftCount`);\n `Consume.PremiumDiscounts` passed through from the price option;\n `Grant.Standard.Entries` = the rolled outputs (one `Item` entry per\n iteration, `Amount: 1` each).\n12. **Atomic apply** via `ResourceService.ApplyResourceOperationAtomicAsync`\n — OCC-guarded against `InventoryV2.Version` with retries, idempotent by\n `reason: \"Craft:{RelatedEntityID}\"` (the TS SDK always sends a fresh\n UUID-suffixed `RelatedEntityID`, so in practice every SDK-initiated call\n is a distinct operation — see the \"guard against double-submit\" gotcha in\n the main skill). Failure → `\"Craft failed: {error}\"`.\n\nAll of steps 6–12 run per-`CraftType` but are otherwise identical between\n`TradeUpCollection` and `TradeUpRarity`.\n\n---\n\n## Weighted roll algorithm\n\n`RollWeightedDef` in `Craft.cs`: a linear cumulative-weight scan over the\ncandidate-output pool (`(ItemDefinition, Weight)` pairs, `Weight` taken from\neach `ItemDefinition.Weight`), driven by `NextInt64`, a rejection-sampled\ndraw from `RandomNumberGenerator` (cryptographic RNG, not `System.Random`)\nthat removes modulo bias. One craft with `count = N` performs **N\nindependent rolls** against the same pool — there is no shared pity/duplicate\nprotection across iterations of one call, and no cross-call pity system\nanywhere in Craft.\n\nBecause the pool is rebuilt once per call (not once per iteration) from the\nsame `titleConfig` snapshot, all `N` iterations in one `craft()` call roll\nagainst an identical odds table.\n\n---\n\n## Response shapes\n\n```ts\ninterface CraftResponse {\n ServerTimeUtc: string; // ISO datetime\n Type?: \"TradeUpRarity\" | \"TradeUpCollection\";\n CraftID: string;\n CraftedCount?: number; // == the clamped craftCount that actually ran\n SelectedOptionID?: string; // the option actually charged (resolved default if you omitted it)\n InputRarity?: string; // echoes craftConfig.InputRarityID\n OutputRarity?: string; // echoes craftConfig.OutputRarityID\n Resources?: ResourceOperation; // Consume = burned inputs + price; Grant = rolled outputs\n Results?: CraftSingleResult[]; // one entry per iteration, index 0..CraftedCount-1\n}\n\ninterface CraftSingleResult {\n Index?: number;\n BurnedItemIDs?: string[]; // the instance ids consumed in this specific iteration\n RolledCollectionID?: string; // TradeUpCollection only — == the recipe's CollectionID\n UsedCollections?: Record<string, number>; // TradeUpCollection only — { [CollectionID]: RequiredItemCount }\n Output?: ResourceEntry; // the rolled item: { Type: \"Item\", ItemID, CatalogID, Amount: 1 }\n}\n```\n\n`RolledCollectionID` / `UsedCollections` are populated only when\n`collectionID` is non-empty when building the result (i.e. only for\n`TradeUpCollection` — `TradeUpRarity` always leaves both `undefined`, per the\n`BuildSingleResults` helper's `collectionID: null` argument on the rarity\npath).\n"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "currency-system",
3
+ "description": "Convert between currencies in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.currency (CurrencyService): virtual-currency to virtual-currency (VC↔VC) conversion, and crypto-source conversion (crypto→VC or crypto→crypto). This is also the canonical home for the SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry cost-and-reward types used by every other module (Store, Character, Craft, Lootbox, Blockchain, …). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a currency-exchange screen, gold-to-gems conversion, crypto conversion, or otherwise touches client.currency, CurrencyService, ConvertResponse, CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or ResourceEntry — even if they don't name the module explicitly.",
4
+ "content": "---\nname: currency-system\ndescription: >-\n Convert between currencies in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.currency (CurrencyService): virtual-currency to\n virtual-currency (VC↔VC) conversion, and crypto-source conversion\n (crypto→VC or crypto→crypto). This is also the canonical home for the\n SDK-wide shared ResourceConsume/ResourceGrant/ResourceOperation/ResourceEntry\n cost-and-reward types used by every other module (Store, Character, Craft,\n Lootbox, Blockchain, …). 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 currency-exchange screen, gold-to-gems conversion, crypto conversion, or\n otherwise touches client.currency, CurrencyService, ConvertResponse,\n CryptoConvertResponse, ResourceConsume, ResourceGrant, ResourceOperation, or\n ResourceEntry — even if they don't name the module explicitly.\n---\n\n# Currency system (iDosGames TS SDK)\n\nThe Currency module is small — two methods — but it's the module every other\nsystem rides on: it's the reference implementation for converting one balance\ninto another, and it's the canonical home for the **shared cost/reward\nprimitives** (`ResourceConsume`, `ResourceGrant`, `ResourceOperation`,\n`ResourceEntry`) that Store, Character, Craft, Lootbox, Blockchain, and others\nall use to describe \"this action costs X and grants Y.\" Read this skill once\nand the resource shapes in every other module's docs make sense by reference.\n\nEverything is **server-authoritative**: the client asks the backend to\nconvert, the backend validates status/rate/fee/limits and debits/credits, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate balances yourself, and you never precompute the rate, fee, or\nrounding client-side — the backend owns all of it.\n\nThis skill is for **using** the production `CurrencyService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(disabled conversion, unlisted target, currency under maintenance, daily\nlimit, insufficient funds) — surface the error, don't try to reproduce the\ncheck client-side.\n\n## Two currency kinds\n\n- **Virtual currency (VC)** — title-defined, integer balances (gold, gems,\n energy). Config: `VirtualCurrencyDefinition`.\n- **Crypto currency** — on-chain-backed, decimal balances (ETH, USDT, …).\n Config: `CryptoCurrencyDefinition`. Crypto balances/deposits/withdrawals are\n otherwise the Blockchain module's territory — see the blockchain-system\n skill; Currency only covers converting a crypto balance you already hold.\n\nBoth currency kinds share a `CurrencyType` tag (`\"Virtual\"` | `\"Crypto\"`) used\nthroughout requests/responses to disambiguate `CurrencyID`s that could\notherwise collide.\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 currency = client.currency; // the CurrencyService\n```\n\nEvery currency 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>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the 600ms throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. \"Conversion from\n'X' is disabled\", \"is under maintenance\", \"is deprecated and cannot receive\nnew credits\", \"Amount below pair minimum\", \"Per-pair daily limit exceeded\",\ninsufficient balance).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------- |\n| `convert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | VC↔VC conversion only. Integer amounts. | `ConvertResponse` |\n| `cryptoConvert(sourceType, sourceID, targetType, targetID, sourceAmount, transactionID?)` | Crypto-**source** conversion (crypto→VC or crypto→crypto). Decimal amounts. | `CryptoConvertResponse` |\n\n`sourceAmount` for `convert` is a `number` (truncated to an integer via\n`Math.trunc` before sending — VC balances are integer). For `cryptoConvert` it\nis `Decimal | string | number` (a `decimal.js` `Decimal`, a numeric string, or\na number) — pass a string or `Decimal` for anything beyond safe-integer/float\nprecision; the SDK depends on `decimal.js`. Each method validates locally\nbefore any network call and rejects mismatched pairs:\n\n- `convert` rejects if either side is `CurrencyType.Crypto` — \"Convert\n supports VC↔VC only. Use cryptoConvert for crypto.\"\n- `cryptoConvert` rejects if **both** sides are `CurrencyType.Virtual` —\n \"cryptoConvert requires at least one crypto side. Use convert for VC↔VC.\"\n- Both reject source === target, empty IDs, non-positive amounts.\n- `cryptoConvert` additionally rejects a non-integer amount when the source\n side is `Virtual` (VC amounts must be whole) — \"Virtual currency source\n amount must be integer.\"\n\nOne server-side constraint the SDK preflight does **not** catch: the backend's\n`CryptoConvert` endpoint requires the **source** to be `Crypto`, and\n`Virtual→Crypto` is not supported by any endpoint (`Convert` explicitly fails\nVirtual→Crypto with \"Virtual→Crypto conversion is not supported.\"). So the\nonly valid pairings are `convert` for VC→VC and `cryptoConvert` for crypto→VC\n/ crypto→crypto; a Virtual-source `cryptoConvert` passes the local check but\ncomes back `reason: \"server\"` (\"CryptoConvert requires source to be Crypto.\nUse Convert for VC↔VC.\").\n\n`transactionID` is optional; omit it and the SDK generates a unique one per\ncall (`convert_<sourceType>_<sourceID>_to_<targetType>_<targetID>_<uuid>` /\n`crypto_convert_...`). The backend folds `TransactionID` into its idempotency\nkey (`CurrencyConvert:<key>` / `CurrencyCryptoConvert:<key>`, where `<key>` is\n`TransactionID` verbatim when you supply one), stored per `(userID, reason)`\nfor 7 days. **Retrying with the same `transactionID` is safe** — the server\ndetects the replay and returns the stored result instead of charging again.\nTwo calls with different (e.g. auto-generated) IDs are two real conversions.\n\nOn success, both methods **mirror the confirmed debit/credit into the cache\nand emit an event** — you don't apply anything by hand. Read updated balances\nstraight from the cache.\n\n### Non-obvious server behavior worth knowing before you build UI\n\n- **Rate resolution**: `Automatic` mode divides the source's `ValueInUSD` by\n the target's `ValueInUSD` (`rate = src.ValueInUSD / tgt.ValueInUSD`);\n `Manual` mode uses the fixed `Rate` pinned on that specific\n `ConversionTarget`. Which mode applies is a property of the **source**\n currency (`Conversion.RateMode`), not the pair.\n- **Fee comes off first, then the rate, then rounding**: `fee = sourceAmount *\nFeePercent / 100`; `net = sourceAmount - fee`; `output = net * rate`. Order\n matters for previews.\n- **Rounding is always truncation toward zero, never nearest/ceiling.** VC↔VC\n truncates the final `output` to a `long`. Crypto-source conversions keep\n full decimal precision throughout _except_ when the target is Virtual, where\n the decimal `output` is floored (`Math.Floor`) to a `long`. A conversion\n whose result rounds to 0 (dust) is rejected rather than silently granting\n nothing.\n- **Currency status gates asymmetrically**: `Maintenance` blocks the currency\n on either side; `Deprecated` blocks it only as a **target** (can't receive\n new credits) — a deprecated currency can still be spent down as a _source_.\n- **Two independent limit layers can reject the same call**: the per-pair\n `ConversionTarget.MinAmount` / `MaxAmount` / `DailyLimit` (scoped to this\n exact source→target pair), and the source currency's own\n `Economy.MinBalance` / `MaxBalance` / `DailyEarnLimit` / `DailySpendLimit`\n (scoped to the whole currency, across every way it can change). Either can\n fail independently — don't assume passing one means the other passed.\n\n## Reading state and reacting to changes\n\n```ts\n// Virtual currency balance (integer):\nclient.data.user.getVirtualCurrencyAmount(\"coins\"); // number\n\n// Crypto currency balance (decimal-as-string):\nclient.data.user.getCryptoCurrencyAmount(\"eth\"); // string, e.g. \"0.05\"\n\n// Currency catalog (config). Populated by client.title.getCurrencyDefinitions()\n// (emits \"title:currencyDefinitionsReceived\"), with a fallback to the `Currency`\n// section of the full title public configuration if that's been loaded:\nconst defs = client.data.config.currencyDefinitions; // CurrencyDefinitions | undefined\ndefs?.VirtualCurrencies?.[\"coins\"]?.Conversion?.Targets;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `currency:converted` → `ConvertResponse`\n- `currency:cryptoConverted` → `CryptoConvertResponse`\n\nNeither `convert` nor `cryptoConvert` has its own coarse `user:currencyUpdated`\nevent — the balance change instead rides through the same resource pipeline\nevery other module uses. `convert` (VC↔VC) always applies through the integer\nresource pipeline, so it fires `user:virtualCurrencyUpdated` +\n`user:inventoryUpdated` (plus the umbrella `user:anyUpdated`) for both sides.\n`cryptoConvert` splits by side: a `Virtual` leg goes through the same\nresource pipeline (same events as above for that leg only); a `Crypto` leg\ngoes through a separate decimal patch that fires only\n`user:inventoryUpdated` + `user:anyUpdated` (no\n`user:virtualCurrencyUpdated`, since no VC balance changed). Listen at\nwhichever granularity suits your UI: the specific `currency:*` event for a\ntoast/confirmation, the coarse `user:*` event for a \"re-render balances\" hook.\n\n```ts\nconst off = client.on(\"currency:converted\", (r) => {\n console.log(\n `Spent ${r.SourceSpent} ${r.SourceID} -> got ${r.TargetCredited} ${r.TargetID}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Convert gold to gems (VC↔VC)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // 100 (integer, includes the fee)\nres.data.FeeAmount; // e.g. 10 (integer, source-currency units — 10% fee here)\nres.data.TargetCredited; // e.g. 45 (integer: (100 - 10) * 0.5, truncated)\nres.data.RateApplied; // decimal-as-string, e.g. \"0.5\"\n// Balances are already updated in the cache:\nclient.data.user.getVirtualCurrencyAmount(\"coins\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Convert a crypto balance to virtual currency\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\nimport Decimal from \"decimal.js\";\n\nconst res = await client.currency.cryptoConvert(\n CurrencyType.Crypto,\n \"usdt\",\n CurrencyType.Virtual,\n \"gems\",\n new Decimal(\"2.50\"), // decimal source amount\n);\nif (!res.ok) return showError(res.error);\n\nres.data.SourceSpent; // \"2.50\" (decimal string)\nres.data.TargetCredited; // \"500\" (decimal string; VC side is still integer-valued and floored)\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\nclient.data.user.getVirtualCurrencyAmount(\"gems\");\n```\n\n### Read a cost/reward breakdown from another module's response\n\nCurrency doesn't return `ResourceOperation` itself (its responses are flat\n`ConvertResponse`/`CryptoConvertResponse`), but almost every other module's\nresponse embeds one under a `Resources` field — e.g. a Store purchase, a\nCharacter upgrade, a Blockchain deposit. Once you've read this skill you can\nread any of them the same way:\n\n```ts\nimport type { ResourceOperation } from \"@idosgames/core\";\n\nfunction summarize(op: ResourceOperation | null | undefined) {\n const spent = op?.Consume?.Standard?.Entries ?? [];\n const gained = op?.Grant?.Standard?.Entries ?? [];\n for (const e of spent)\n console.log(`-${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n for (const e of gained)\n console.log(`+${e.Amount} ${e.CurrencyID ?? e.ItemID}`);\n}\n```\n\n`Standard` is already the server-resolved final amount (premium\ndiscounts/bonuses folded in) — don't re-derive it from `PremiumDiscounts` /\n`PremiumTiers`. See [references/data-model.md](references/data-model.md) for\nthe full shape and every field.\n\n### Edge case: rejected pairing (client-side, no network call)\n\n```ts\nimport { CurrencyType } from \"@idosgames/core\";\n\nconst res = await client.currency.convert(\n CurrencyType.Crypto, // wrong method for a crypto side\n \"eth\",\n CurrencyType.Virtual,\n \"coins\",\n 1,\n);\n// res.ok === false, res.reason === \"client\" — rejected locally, no round-trip.\n// Use cryptoConvert instead.\n```\n\n### Edge case: not logged in\n\n```ts\nconst res = await client.currency.convert(\n CurrencyType.Virtual,\n \"coins\",\n CurrencyType.Virtual,\n \"gems\",\n 100,\n);\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res.ok === false, res.reason === \"unauthorized\"\n```\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n client-side (the auto-generated `TransactionID`), so two separate calls are\n two real operations — a double-clicked \"Convert\" can charge twice. Disable\n the control while a call is in flight. (Firing the same endpoint again\n 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- **`convert` and `cryptoConvert` are not interchangeable.** `convert` is\n VC↔VC only (integer amounts); `cryptoConvert` requires at least one crypto\n side (decimal amounts) and rejects a VC↔VC pair. Both reject mismatched\n calls with `reason: \"client\"` before any network round-trip.\n- **Amount precision matters.** VC amounts are always integers — `convert`\n truncates via `Math.trunc`. Crypto amounts are decimal; pass a `Decimal` or\n numeric string for `cryptoConvert` rather than a JS `number` once you're\n near float precision limits (the SDK's own crypto math uses `decimal.js`\n throughout).\n- **Rounding always favors the house, never the player.** Both the VC↔VC and\n the crypto→VC paths truncate/floor the credited amount down — there is no\n \"round to nearest.\" A tiny source amount can legitimately convert to 0\n target units, which the backend rejects outright rather than granting a\n free-rounding credit.\n- **`RateApplied`/`FeeAmount` are informational, not something to\n precompute.** The backend enforces the title's configured\n `CurrencyConversion` rules (`Enabled`, `RateMode`, `FeePercent`, whitelisted\n `Targets` with their own `MinAmount`/`MaxAmount`/`DailyLimit`) — read the\n actual applied numbers off the response rather than estimating client-side.\n- **A conversion can fail on the currency's global limits even if the pair\n looks fine.** `Economy.MinBalance`/`MaxBalance`/`DailyEarnLimit`/\n `DailySpendLimit` apply on top of (and independently of) the pair-specific\n `MinAmount`/`MaxAmount`/`DailyLimit` — show whichever `error` string comes\n back rather than trying to pre-validate both layers yourself.\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) — the full\n`CurrencyDefinitions` config shape (virtual + crypto), the backend conversion\nformulas transcribed from source, and the complete, canonical documentation of\nthe shared `ResourceConsume` / `ResourceGrant` / `ResourceOperation` /\n`ResourceEntry` types used across the whole SDK. Read it before building\ncost/reward UI in any other module (Store offers, Character upgrades, Craft\nrecipes, Lootbox prices, Blockchain deposits/withdrawals all describe their\ncosts and payouts with these same shapes).\n",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
8
+ "content": "# Currency data model — reference\n\nFull shape of the `CurrencyDefinitions` config, and — the canonical\ndocumentation for the whole SDK — the shared `ResourceConsume` /\n`ResourceGrant` / `ResourceOperation` / `ResourceEntry` cost-and-reward\nprimitives. All types are **strictly typed** and exported from\n`@idosgames/core`; every object schema keeps `.passthrough()`, so a field the\nbackend adds later still round-trips instead of being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CurrencyDefinitions](#config-currencydefinitions)\n- [VirtualCurrencyDefinition](#virtualcurrencydefinition)\n- [CryptoCurrencyDefinition](#cryptocurrencydefinition)\n- [Shared conversion config](#shared-conversion-config)\n- [Backend conversion formulas](#backend-conversion-formulas) — transcribed from `ConversionService.cs`\n- [The shared resource primitives](#the-shared-resource-primitives) — canonical home\n - [ResourceEntry](#resourceentry)\n - [ResourceBundle](#resourcebundle)\n - [PremiumTierBundle](#premiumtierbundle)\n - [ResourceGrant](#resourcegrant)\n - [ResourceConsume](#resourceconsume)\n - [ResourceOperation](#resourceoperation)\n - [EventTokenOperation / EventTokenAddress](#eventtokenoperation--eventtokenaddress)\n - [ResourceDualPartyResult / ResourceTransferResult](#resourcedualpartyresult--resourcetransferresult)\n- [How the SDK applies a ResourceOperation](#how-the-sdk-applies-a-resourceoperation)\n\n---\n\n## Config: CurrencyDefinitions\n\n```ts\ninterface CurrencyDefinitions {\n VirtualCurrencies?: Record<string, VirtualCurrencyDefinition> | null;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition> | null;\n}\n```\n\nKey in both maps is the `CurrencyID`. A currency is \"known\" iff it has an\nentry in one of these maps under its `CurrencyType` (`Virtual` or `Crypto`).\n\n---\n\n## VirtualCurrencyDefinition\n\n```ts\ninterface VirtualCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>; // \"icon\", ...\n Economy?: {\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string; // ISO timestamp\n InitialDeposit?: number; // starting balance for new players\n MinBalance?: number;\n MaxBalance?: number;\n DailyEarnLimit?: number;\n DailySpendLimit?: number;\n };\n Recharge?: {\n // energy-style auto-regen\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n MinDeposit?: string;\n MinWithdraw?: string;\n WithdrawFee?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nDeposit/withdrawal flows (and `Networks`/`Limits` enforcement for those flows)\nbelong to the Blockchain module — see the blockchain-system skill.\n`Permissions.ConvertibleToVirtual` is the flag most relevant here: it's what\nlets a crypto balance participate in `cryptoConvert`.\n\n---\n\n## Shared conversion config\n\nBoth currency kinds reuse the same `CurrencyConversion` shape for their\n`Conversion` field:\n\n```ts\ninterface CurrencyConversion {\n Enabled?: boolean;\n RateMode?: \"Automatic\" | \"Manual\";\n FeePercent?: string; // decimal string\n Targets?: ConversionTarget[]; // whitelist of valid conversion targets\n}\n\ninterface ConversionTarget {\n TargetCurrencyType?: \"Virtual\" | \"Crypto\";\n TargetCurrencyID: string;\n Rate?: string; // decimal string; used when RateMode is \"Manual\"\n MinAmount?: number;\n MaxAmount?: number;\n DailyLimit?: number;\n}\n```\n\nA conversion is only accepted if the source currency's `Conversion.Enabled`\nis true and the target appears in `Targets` (by type + id). `\"Automatic\"`\nrate mode means the backend derives the rate from each side's `ValueInUSD`;\n`\"Manual\"` uses the `Rate` pinned on the `ConversionTarget`. Either way, treat\n`RateApplied` on the response as the source of truth — don't recompute it.\n\n---\n\n## Backend conversion formulas\n\nTranscribed from `ConversionService.ConvertAsync` /\n`ConversionService.ConvertCryptoAsync` in the backend\n(`IDosGamesSDK/API/Client/v2/Currency/Services/ConversionService.cs`). These\nare enforced server-side; the SDK never recomputes them — use this section\nonly for building accurate cost/reward **previews**, not for validating a\nconversion before sending it.\n\n**Order of checks** (any failure short-circuits, no partial debit):\n\n1. Basic shape: non-empty `SourceID`/`TargetID`, positive amount, source ≠\n target.\n2. `Convert` (VC↔VC) rejects a `Crypto` source outright (\"Convert supports\n VC↔VC only\") and a `Crypto` target outright (\"Virtual→Crypto conversion is\n not supported\"). `CryptoConvert` rejects a non-`Crypto` source outright\n (\"CryptoConvert requires source to be Crypto\").\n3. **Status**: source or target `Maintenance` → rejected on that side (\"is\n under maintenance\"). Target (only) `Deprecated` → rejected (\"is deprecated\n and cannot receive new credits\"). A `Deprecated` **source** is allowed —\n deprecating a currency only stops new inflow, it doesn't trap the player's\n remaining balance.\n4. Source's `Conversion` must be non-null and `Enabled`, and must have a\n `Targets` entry matching `(TargetCurrencyType, TargetCurrencyID)` exactly —\n otherwise \"Conversion from 'X' to 'Y' is not allowed.\"\n5. Crypto-source → Virtual-target additionally requires\n `CryptoCurrencyPermissions.ConvertibleToVirtual` — false rejects even a\n listed target.\n6. Per-pair `MinAmount`/`MaxAmount` on the matched `ConversionTarget`, checked\n against the raw source amount before fee.\n7. **Rate resolution**:\n - `RateMode = Manual` → `rate = ConversionTarget.Rate`; a pair configured\n Manual with no `Rate` set is rejected (\"Manual conversion rate is not set\n for this pair\"), not treated as 0 or 1.\n - `RateMode = Automatic` → `rate = source.ValueInUSD / target.ValueInUSD`.\n Either side missing/zero `ValueInUSD` rejects the conversion (\"Automatic\n rate cannot be computed\").\n8. **Fee**: `FeePercent` is clamped to `[0, 100]` defensively, then\n `feeAmount = sourceAmount * FeePercent / 100`; `netSource = sourceAmount -\nfeeAmount`. `netSource <= 0` is rejected.\n9. **Output**: `output = netSource * rate`.\n - VC↔VC (`ConvertAsync`): `output` is cast straight to `long`, i.e.\n **truncated toward zero**. `output <= 0` after truncation is rejected\n (\"Resulting target amount is zero\").\n - Crypto-source (`ConvertCryptoAsync`): `output` stays a full-precision\n `decimal` if the target is `Crypto`. If the target is `Virtual`, it is\n **floored** (`Math.Floor`) to a `long` before the zero-check.\n10. Per-pair `DailyLimit` on the matched `ConversionTarget`: today's\n already-converted amount for this exact `(SourceType:SourceID ->\nTargetType:TargetID)` pair (tracked server-side per UTC day; not exposed\n to the client) plus this operation's raw source amount must not exceed\n it.\n11. The debit/credit itself runs through `ResourceService\n.ApplyResourceOperationAtomicAsync`, which additionally enforces the\n source currency's own `Economy.MinBalance`/`MaxBalance` (for VC) and\n `Economy.DailyEarnLimit`/`DailySpendLimit` — a **second, independent**\n limit layer scoped to the whole currency rather than this one pair. A\n crypto source's live balance is checked directly against\n `InventoryV2.CryptoCurrencies[id].Amount` before the debit.\n\n**Practical takeaway**: two conversions that look identical (same pair, same\namount) can differ in outcome depending on how much of the _daily_ pair\nallowance or the _daily_ currency-wide allowance is already used — always\nrender the server's `error` rather than trying to precompute eligibility.\n\n---\n\n## The shared resource primitives\n\nThis is the **canonical documentation** for these types — every other module\nskill (Store, Character, Craft, Lootbox, Blockchain, …) links here instead of\nredefining them. They describe \"spend this, receive that\" in one uniform\nshape used for offer costs, upgrade costs, craft inputs/outputs, lootbox\nprices/rewards, and blockchain deposit/withdrawal resource deltas.\n\n### ResourceEntry\n\nThe atomic unit: one currency or item quantity.\n\n```ts\ninterface ResourceEntry {\n Type?: \"Item\" | \"VirtualCurrency\" | \"CryptoCurrency\" | \"UsdCent\"; // ResourceEntryType — exactly these 4\n CurrencyID?: string; // set when Type is a currency kind\n Amount?: number; // integer amount; C# `long` on the wire, parsed via zVcAmount (exact up to 2^53-1)\n CatalogID?: string; // set when Type is \"Item\": which catalog\n ItemID?: string; // set when Type is \"Item\": which item definition\n}\n```\n\nOnly the fields relevant to `Type` are populated — e.g. a `VirtualCurrency`\nentry sets `CurrencyID` + `Amount` and leaves `CatalogID`/`ItemID` unset; an\n`Item` entry sets `CatalogID`/`ItemID` (+ `Amount` for stackable quantity) and\nleaves `CurrencyID` unset.\n\n### ResourceBundle\n\nA flat list of entries, plus optional event-token deltas:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[] | null;\n EventTokens?: EventTokenOperation[] | null;\n}\n```\n\n### PremiumTierBundle\n\nAn alternate bundle that only applies if the player holds a qualifying\npremium tier — used inside `ResourceGrant`/`ResourceConsume` to express\n\"VIPs get a better grant / a cheaper cost.\"\n\n```ts\ninterface PremiumTierBundle {\n MinPremiumTier?: number;\n RequiredPremiumID?: string;\n Resources?: ResourceBundle | null;\n}\n```\n\n### ResourceGrant\n\nWhat a player receives.\n\n```ts\ninterface ResourceGrant {\n Standard?: ResourceBundle | null; // baseline grant, always applies\n PremiumBonuses?: unknown[] | null; // reserved/opaque bonus list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated additional/alternate grants\n}\n```\n\n### ResourceConsume\n\nWhat a player is charged. Mirror-shaped to `ResourceGrant`, but the\ntier-based array is a **discount** mechanism rather than a bonus one — see\nGotchas below.\n\n```ts\ninterface ResourceConsume {\n Standard?: ResourceBundle | null; // baseline cost\n PremiumDiscounts?: unknown[] | null; // reserved/opaque discount list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated reduced/alternate cost\n}\n```\n\n### ResourceOperation\n\nThe full manifest for one action: what's granted and what's consumed. This is\nthe shape every \"did an action succeed\" response embeds under a `Resources`\nfield (e.g. `DepositNFTResponse.Resources`, `NFTWithdrawalResponse.Resources`\nin Blockchain).\n\n```ts\ninterface ResourceOperation {\n Grant?: ResourceGrant | null;\n Consume?: ResourceConsume | null;\n}\n```\n\nEither side can be `null`/absent — a pure grant (no cost) sets only `Grant`;\na pure charge (no payout) sets only `Consume`.\n\n### EventTokenOperation / EventTokenAddress\n\nEvent tokens are a lighter-weight counter mechanic (e.g. season/event\ncurrency) addressed by an entity rather than a flat `CurrencyID`:\n\n```ts\ninterface EventTokenAddress {\n Type?: string; // EventTokenType — which kind of entity owns the token bucket\n EntityID: string;\n}\n\ninterface EventTokenOperation {\n Address?: EventTokenAddress | null;\n Amount?: number;\n Source?: string; // free-form provenance tag\n}\n```\n\n### ResourceDualPartyResult / ResourceTransferResult\n\nUsed by PvP/transfer-style features where two accounts are affected by one\naction:\n\n```ts\n// Each side gets its own independent grant/consume manifest.\ninterface ResourceDualPartyResult {\n FromUserID?: string;\n ToUserID?: string;\n FromResult?: ResourceOperation | null;\n ToResult?: ResourceOperation | null;\n}\n\n// A straight transfer: one bundle moves from one account to another.\ninterface ResourceTransferResult {\n FromUserID?: string;\n ToUserID?: string;\n Transferred?: ResourceBundle | null;\n}\n```\n\n---\n\n## How the SDK applies a ResourceOperation\n\nEvery module that returns a `ResourceOperation` (directly, or via a\n`Resources` field) has already had it **applied server-side**; the SDK's job\nis only to mirror it into the local cache so balances/inventory read\ncorrectly without a re-fetch. Internally this goes through\n`UserData.applyResourceOperation(op, itemDefs)`, which:\n\n- walks `Consume.Standard.Entries` and `Grant.Standard.Entries` (the\n `PremiumDiscounts`/`PremiumTiers`/`PremiumBonuses` arrays describe _why_ the\n standard amount is what it is — the server has already resolved them into\n `Standard` before sending the response; the client does not re-apply tiers),\n- for `VirtualCurrency` entries, adjusts the integer balance and emits\n `user:virtualCurrencyUpdated`,\n- for `Item` entries, adjusts stackable counts / creates unstackable instances\n and emits `user:inventoryUpdated`,\n- for `EventTokens`, adjusts the addressed token bucket and emits\n `user:eventTokenUpdated`,\n- always emits the umbrella `user:anyUpdated` when anything changed.\n\n`CryptoCurrency` amounts do **not** flow through this integer pipeline —\nthey're decimal and go through a separate patch\n(`UserData.patchCryptoCurrencyDelta(currencyID, delta, serverTimeUtc)`), which\nis what `CurrencyService.cryptoConvert` and the Blockchain deposit/withdrawal\nmethods use directly instead of embedding crypto deltas in a\n`ResourceOperation`.\n\n**Practical takeaway when building UI in any module:** don't hand-roll cost\npreviews from `PremiumDiscounts`/`PremiumTiers` internals unless you're\nexplicitly building a \"your VIP tier saves you N%\" comparison — for \"what will\nthis cost me right now,\" prefer the value the server already resolved\n(`Standard`, or the flat response fields like `ConvertResponse.SourceSpent`).\nTreat `Amount` on VC entries as a signed integer conceptually (consume vs.\ngrant is which container it's in, not a negative number) and crypto strings as\nopaque decimal values to hand to `decimal.js`, not to parse with `Number()`\nonce you're near precision limits.\n"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "deal-offer-system",
3
+ "description": "Build a personalized / targeted deal-offer system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.dealOffer (DealOfferService): load slot and offer definitions, load the player's deal-offer state, fetch the currently active deals per slot, dismiss a deal, execute a node in an offer's graph (purchase / free claim / rewarded-video / info step), record an impression (show event), and claim a milestone reward (single or batch). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants limited-time offer popups, IAP funnels, \"special offer\" slots, node/graph-based offer chains, rewarded-video offer steps, offer milestone/progress bars, or otherwise touches client.dealOffer, DealOfferService, DealOfferDefinitions, DealNodeDefinition, UserDealOffersState, ActiveDealSlotInfo, or ExecuteNodeResponse — even if they don't name the module explicitly.",
4
+ "content": "---\nname: deal-offer-system\ndescription: >-\n Build a personalized / targeted deal-offer system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.dealOffer (DealOfferService):\n load slot and offer definitions, load the player's deal-offer state, fetch\n the currently active deals per slot, dismiss a deal, execute a node in an\n offer's graph (purchase / free claim / rewarded-video / info step), record\n an impression (show event), and claim a milestone reward (single or batch).\n Use this whenever the user is working in the iDosGames TS SDK or its game\n templates (board-game, idle-rpg) and wants limited-time offer popups, IAP\n funnels, \"special offer\" slots, node/graph-based offer chains, rewarded-video\n offer steps, offer milestone/progress bars, or otherwise touches\n client.dealOffer, DealOfferService, DealOfferDefinitions, DealNodeDefinition,\n UserDealOffersState, ActiveDealSlotInfo, or ExecuteNodeResponse — even if\n they don't name the module explicitly.\n---\n\n# Deal offer system (iDosGames TS SDK)\n\nThe Deal Offer module runs targeted, time-boxed offer popups (\"special offer\",\n\"starter pack\", \"welcome bundle chain\") shown in fixed **slots** on screen.\nEverything is **server-authoritative**: the client asks the backend to\nexecute a step, dismiss, or claim, the backend validates and charges/grants,\nand the SDK mirrors the confirmed result into a local cache your UI reads. You\nnever mutate deal state yourself — you call a method, check the result, and\nrender from the cache.\n\nThis skill is for **using** the production `DealOfferService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (cost, gate, lock, cooldown) — surface the error, don't try to reproduce\nthe check client-side.\n\n## The mental model: slots, offers, and the node graph\n\nThree nested concepts. Keep them straight — every method operates on one of\nthem.\n\n1. **Slot** (`DealSlotDefinition`) — a fixed UI position (e.g. `\"Slot1\"`) that\n cycles through a **queue** of offers over time, on a `Schedule`, gated by a\n `SegmentGate`. At any moment a slot has at most one **active offer\n activation**.\n2. **Offer** (`DealOfferDefinition`) — one specific deal (e.g. a 3-step\n starter pack). An offer is not a flat \"buy this for that\" — it's a **graph\n of nodes** the player works through during one activation.\n3. **Node** (`DealNodeDefinition`) — one atomic player action inside the\n offer's graph: a `Purchase`, a `FreeClaim`, a `RewardedVideo` view, or an\n `Info` step. Executing a node is the unit of progress.\n\n### What the node graph actually is\n\nAn offer's `Nodes` array is a small directed graph, not a list:\n\n- `RootNodeIDs` names the node(s) available the instant a fresh activation\n starts — no server round trip needed to \"unlock\" them.\n- Each node's `NextNodeIDs` names the node(s) that become reachable once\n **this** node is completed — that's the graph's real edge list. A node can\n have zero (terminal), one (linear chain), or several (branching)\n next-nodes.\n- Each node also carries `UnlockRules` (`RequiredCompletedNodeIDs` /\n `RequiredAnyCompletedNodeIDs` / `RequiredTracks`), but **`NextNodeIDs` is\n the thing that actually unlocks a node server-side** — completing a node\n writes `Available` state to every ID in its `NextNodeIDs` unconditionally.\n A non-root node with no runtime state yet is always rejected (\"Node is\n locked\"), even if its own `UnlockRules` look satisfiable on paper — the\n server never bootstraps a node's state from `UnlockRules` alone. The one\n place `RequiredTracks` really does unlock nodes on its own is tracks (see\n below). Treat `UnlockRules` mainly as UI-hint metadata (what a locked node\n is \"waiting on\") rather than a client-computable gate — see\n [references/data-model.md](references/data-model.md) for the exact\n algorithm.\n- `GraphMode` (`Single | Chain | BranchingChain | Choice | MeteredChain`)\n describes the _shape_ the title author intended — it's descriptive metadata\n on the offer, not something the client interprets differently. The actual\n traversal is always just `NextNodeIDs` + `UnlockRules` + (for `Choice`)\n `ChoiceGroupID`.\n- **Executing a node** (`executeNode(slotID, nodeID)`) is \"the player\n performed this node's action right now\": pay its `Action.Purchase` cost (if\n `UseExternalRewards` is false and a `DirectCost` is set), or register a\n `RewardedVideo` view, or acknowledge a `FreeClaim`/`Info` node — then the\n backend applies `Grants` (skipped for `Purchase` nodes with\n `UseExternalRewards: true`, and for `RewardedVideo` mid-sequence views\n unless `GrantRewardsPerView` is true), applies `TrackChanges`, adds\n `MilestonePoints` to the offer's milestone bar, and marks the node\n `Completed` once its execution count reaches the required amount (1 by\n default; `Limits.PerActivationCap` for ordinary nodes,\n `Action.RewardedVideo.ViewsRequiredToComplete` for ad nodes). The response\n tells you `NodeCompleted` (this call finished the node) and\n `OfferExhausted` (this completion ended the whole activation, e.g. via\n `ExhaustOfferOnComplete` or all terminal nodes now being complete).\n- **Tracks** (`DealTrackDefinition`) are small offer-local counters (e.g.\n \"shells collected this activation\") that nodes write via `TrackChanges` and\n that can independently unlock nodes whose `UnlockRules.RequiredTracks`\n threshold is crossed — a lightweight in-offer state machine layered on top\n of node completion, reset to `StartValue` every fresh activation.\n- **Milestones** are a separate reward ladder over `MilestonePoints` earned\n from nodes in this same activation — see `claimMilestone` below.\n\nIn short: a **slot** shows one **offer** at a time; an **offer** is a graph of\n**nodes**; executing a node pays/claims that node and can unlock further\nnodes via `NextNodeIDs` (or via a track threshold); enough node completions\ncan exhaust the offer and/or clear milestone thresholds. Always read the\nreturned `DealNodeRuntimeStatus` per node (`Locked | Available | InProgress |\nCompleted | Hidden`) as ground truth rather than computing it yourself.\n\nFor the full field-by-field shape of slots, offers, nodes, tracks,\nmilestones, runtime state, the exact unlock algorithm, the milestone\naddressing/reward math, and slot-resolution/schedule-mode 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 (progress bars,\nlocked/available badges, choice-group rendering).\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 deals = client.dealOffer; // the DealOfferService\n```\n\nEvery deal-offer 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\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. `\"Node 'X' is\nlocked\"`, `\"Node 'X' is already completed\"`, `\"Deal in slot 'Y' has\nexpired\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |\n| `getDefinition()` | Load the title's slot + offer catalog (config). | `DealOffersDefinitionResponse` (`DealOfferDefinitions`) |\n| `getUserState()` | Load this player's raw deal-offer state (all slots + history). | `UserDealOffersStateResponse` (`DealOffers`) |\n| `getActiveDeals()` | Load the resolved, ready-to-render active offer per slot. | `GetActiveDealsResponse` (`Slots: ActiveDealSlotInfo[]`) |\n| `dismissDeal(slotID)` | Dismiss the active offer in a slot before finishing it. | `DismissDealResponse` |\n| `executeNode(slotID, nodeID, externalRefID?)` | Perform one node's action (purchase / claim / ad view / ack info). | `ExecuteNodeResponse` (`NodeCompleted`, `OfferExhausted`, `Idempotent`) |\n| `recordShow(slotID)` | Record an impression (the offer popup was shown to the player). | `RecordShowResponse` |\n| `claimMilestone(slotID, milestoneID)` | Claim one reached-and-unclaimed milestone reward. | `ClaimDealMilestoneResponse` |\n| `claimMilestonesBatch(slotID, milestoneIDs)` | Claim many milestones for one slot's active offer in one call. | `ClaimDealMilestonesBatchResponse` (`ClaimedIDs`, `Rejected`) |\n\n`getActiveDeals()` is the one to render a deal popup/carousel from directly —\neach `ActiveDealSlotInfo` bundles the slot's offer definition (`OfferDef`),\nthe player's runtime progress on it (`ActivationState`), whether this is a\nfreshly-started activation (`IsNewActivation`), a computed expiry\n(`ComputedExpiresAtUtc`), an aggregated cost preview (`Cost`), and milestone\nprogress (`Milestone`) — you don't have to manually join `getDefinition()` +\n`getUserState()` yourself, though both remain available for lower-level reads\n(e.g. offer history, or definitions for slots with no active offer). Slots\nthat fail their audience `Gate`, have no live/next offer, or are paused\nbetween cycles are simply omitted from `Slots` — there's no \"locked slot\"\nplaceholder.\n\nOn success, each method **emits an event**; only `dismissDeal`, `executeNode`,\n`recordShow`, `claimMilestone`, and `claimMilestonesBatch` also carry a\n`Resources: ResourceOperation` that's mirrored into the inventory/currency\ncache (grants and/or consumes already applied — read updated balances from\n`client.data.user.state?.<Currency/Item>` as usual). `dismissDeal` and\n`recordShow` always carry an empty `Resources` (they never move\ncurrency/items — the field exists for response-shape consistency).\n`executeNode` skips re-applying resources when `data.Idempotent` is true (a\nretried/duplicate call replaying a result already applied server-side without\nopening a new transaction). Note that unlike some other modules, the service\ndoes **not** yet optimistically patch the per-slot/per-node runtime state in\nthe cache on these five calls — refresh with `getUserState()` /\n`getActiveDeals()` (or rely on the event payload) to see updated node\nstatuses.\n\n## Reading state and reacting to changes\n\n```ts\n// Raw per-slot runtime state (present after getUserState() or getActiveDeals()):\nconst dealState = client.data.user.state?.DealOffer;\nconst slot = dealState?.Slots?.[\"Slot1\"];\nslot?.ActiveOfferID;\nslot?.ActiveOffer?.Nodes?.[\"node_1\"]?.Status; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\nslot?.ActiveOffer?.Tracks?.[\"shells\"]?.CurrentValue;\n\n// Definitions (cached after getDefinition()):\nimport type { DealOfferDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<DealOfferDefinitions>(\"DealOffer\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `dealOffer:definitionLoaded` → `DealOfferDefinitions`\n- `dealOffer:userStateLoaded` → `UserDealOffersState`\n- `dealOffer:activeDealsLoaded` → `GetActiveDealsResponse`\n- `dealOffer:dealDismissed` → `DismissDealResponse`\n- `dealOffer:nodeExecuted` → `ExecuteNodeResponse`\n- `dealOffer:showRecorded` → `RecordShowResponse`\n- `dealOffer:milestoneClaimed` → `ClaimDealMilestoneResponse`\n- `dealOffer:milestonesBatchClaimed` → `ClaimDealMilestonesBatchResponse`\n\nThe coarse `user:dealOfferUpdated` (and `user:anyUpdated`) fire only from\n`getUserState()` (which replaces the whole cached `DealOffer` state via\n`applyDealOffer`) — not from the other six calls, since those don't yet patch\n`client.data.user.state?.DealOffer` themselves. Treat the specific\n`dealOffer:*` event payload above as the source of truth for what just\nhappened, and call `getActiveDeals()` / `getUserState()` afterward if you need\nthe refreshed per-slot cache.\n\n```ts\nconst off = client.on(\"dealOffer:nodeExecuted\", (r) => {\n console.log(\n `node ${r.NodeID} completed=${r.NodeCompleted} exhausted=${r.OfferExhausted}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the active deal in a slot\n\n```ts\nawait client.dealOffer.getDefinition();\nconst res = await client.dealOffer.getActiveDeals();\nif (!res.ok) return showError(res.error);\n\nfor (const slot of res.data.Slots ?? []) {\n // slot.OfferDef.Nodes describes the graph; slot.ActivationState.Nodes has\n // per-node runtime Status/ExecutionCount for THIS activation.\n const rootNodes = (slot.OfferDef?.RootNodeIDs ?? []).map((id) =>\n slot.OfferDef?.Nodes?.find((n) => n.NodeID === id),\n );\n // render rootNodes first; reveal further nodes as their Status flips to\n // \"Available\"/\"Completed\" after each executeNode call.\n}\n```\n\n### Record an impression, then execute a node (golden path)\n\n```ts\nawait client.dealOffer.recordShow(\"Slot1\"); // fire once when the popup opens\n\nconst res = await client.dealOffer.executeNode(\"Slot1\", \"node_purchase_1\");\nif (!res.ok) return showError(res.error); // e.g. \"Node 'node_purchase_1' is locked\", insufficient funds\nif (res.data.NodeCompleted) unlockNextNodesInUI();\nif (res.data.OfferExhausted) closeDealPopup(); // no more nodes to work through\n```\n\n### Rewarded-video node needing multiple views\n\nA `RewardedVideo` node's `Action.RewardedVideo.ViewsRequiredToComplete` can be\ngreater than 1 — call `executeNode` again after each ad view; the node only\nflips to completed (and grants `Grants`, unless `GrantRewardsPerView` is set)\nonce enough views have been recorded.\n\n```ts\nasync function watchAdForNode(slotID: string, nodeID: string) {\n await showRewardedAd(); // your ad SDK\n const res = await client.dealOffer.executeNode(slotID, nodeID);\n if (!res.ok) return showError(res.error);\n if (!res.data.NodeCompleted) {\n // still needs more views — show \"1 of N watched\" from ActivationState.Nodes[nodeID].ExecutionCount\n }\n}\n```\n\nPer-activation view cap is derived from the ad config\n(`max(MaxViewsPerActivation, ViewsRequiredToComplete)`, or unlimited if\n`MaxViewsPerActivation <= 0`) — it's never accidentally lower than what's\nneeded to finish the node. `CooldownSecondsBetweenViews` (if set) rejects an\nearly retry with `\"Node 'X' is on cooldown until <time>\"`.\n\n### Claim milestones (single, then batch)\n\n```ts\nconst one = await client.dealOffer.claimMilestone(\"Slot1\", \"milestone_1\");\nif (!one.ok) return showError(one.error); // e.g. \"Not enough earned...\", \"Milestone already claimed.\"\n\nconst batch = await client.dealOffer.claimMilestonesBatch(\"Slot1\", [\n \"milestone_2\",\n \"milestone_3\",\n \"milestone_2\", // duplicates are deduped client-side before the call\n]);\nif (!batch.ok) return showError(batch.error);\nbatch.data.ClaimedIDs; // milestone IDs that were actually claimed\nbatch.data.Rejected; // Record<milestoneID, reasonString> for ones that weren't\n```\n\nThe milestone bar's progress address is derived from the offer's _current_\nslot position (`\"{offerID}:{slotID}:c{cycleIndex}:q{queueIndex}\"`), so it\nresets to zero automatically whenever the slot advances to a new queue entry\nor cycle — there's no explicit \"reset the bar\" call. A `MilestoneClaimMode`\nof `AfterEventEnd` (or `FeaturedAfterEnd`, for `IsFeatured` milestones only)\nrejects the claim with `\"Milestone can only be claimed after the offer\nends.\"` until the activation is no longer live — see\n[references/data-model.md](references/data-model.md) for the exact\n\"activation ended\" check.\n\n### Dismiss a deal early\n\n```ts\nconst res = await client.dealOffer.dismissDeal(\"Slot1\");\nif (!res.ok) return showError(res.error);\n// slot's activation is marked Dismissed server-side; the slot's queue can\n// advance to the next offer per its Schedule/AllowDismissSkip config.\n```\n\n## Gotchas\n\n- **The node graph's real edge list is `NextNodeIDs`, not `UnlockRules`.**\n Completing a node writes `Available` state to every ID in its `NextNodeIDs`\n unconditionally; a non-root node with no runtime state yet is always\n rejected regardless of whether its own `UnlockRules` look satisfied. The\n one exception is `UnlockRules.RequiredTracks`, which genuinely does unlock\n a node the moment the relevant track crosses its threshold. Don't compute\n node availability client-side — read `ActivationState.Nodes[nodeID].Status`\n (refreshed via `getActiveDeals()` / `getUserState()`, or the latest\n `dealOffer:nodeExecuted` event).\n- **Cache isn't optimistically patched for five of the eight calls.**\n `dismissDeal`, `executeNode`, `recordShow`, `claimMilestone`, and\n `claimMilestonesBatch` mirror resource grants/consumes into the\n currency/item cache, but they do **not** patch\n `client.data.user.state?.DealOffer` themselves (per the \"optimistic slot\n patches deferred\" note in the source) — only `getUserState()` does, via\n `applyDealOffer`, which replaces the whole `DealOffer` state wholesale.\n Re-fetch `getActiveDeals()`/`getUserState()` after a mutating call if your\n UI needs the updated per-node/per-slot status rather than relying on stale\n cache reads.\n- **`executeNode`'s `Idempotent` flag matters.** When `true`, the resources in\n the response were already applied by an earlier call with the same\n `RelatedEntityID` (matched against that node's last stored execution ref) —\n the service intentionally skips both re-applying them and opening a new\n transaction. Pass your own `externalRefID` if you need a stable idempotency\n key across retries (e.g. after a network drop); otherwise the SDK mints a\n fresh `deal_exec_<slot>_<node>_<uuid>` each call — still disable the\n control while a call is in flight to guard against double-submits on the UI\n side.\n- **Milestones are a separate reward ladder from node `Grants`, and go\n through the platform-wide progression-multiplier resolver.** A node's\n `Grants` pay out immediately on that node's completion; `MilestonePoints`\n from completed nodes accumulate toward the offer's `Milestones` thresholds,\n which need their own explicit `claimMilestone`/`claimMilestonesBatch` call\n — reaching a threshold does not auto-grant its reward. The actual payout is\n the milestone's base `Rewards` scaled by the title's\n `Reward.MilestoneRewardMultiplier` progression curve if one is configured\n (same resolver Quest/CommunityChest/Referral use) — don't assume the\n claimed amount equals the milestone's raw `Rewards` field.\n- **Batch milestone claims are partial-aware, not all-or-nothing on\n rejection, and uncapped in size.** `ClaimedIDs` / `Rejected` tell you\n per-milestone outcome inside one call; an entry can be rejected on its own\n merits (not yet reached, already claimed, wrong claim-mode timing)\n independent of the others. There's no batch-size cap like Character's\n 50-item limit — send however many milestone IDs you have for one slot. The\n combined resource grant across all claimed IDs in a batch is applied as one\n atomic operation (one Mongo write, one merged `Resources.Grant` summing\n every claimed milestone's reward) — but which IDs land in `Claimed` vs\n `Rejected` is decided before that atomicity boundary.\n- **Tracks reset per activation.** `Tracks` on `ActivationState` belong to the\n current offer activation in that slot — when the slot cycles to the next\n queued offer (or the same offer re-activates later), track values start\n fresh per the offer's `DealTrackDefinition.StartValue`, they don't carry\n over. Lifetime counting instead lives in `OfferHistory`/`NodeCounts`.\n- **`AllowDismissSkip` changes what dismiss actually does.** With it `false`\n (the default), `dismissDeal` only marks the activation `Dismissed` and the\n slot's existing timer keeps ticking — the same (now-inert) offer stays\n \"current\" until it naturally expires. With it `true` (meant for\n one-time-offer slots), dismissing lets the slot advance to the next queued\n offer after `DismissSkipDelaySec` seconds.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: slot queues/schedule modes, the full node/graph shape and its\nexact unlock/exhaustion algorithm, tracks, node execution limits, the\nmilestone-bar addressing and reward-multiplier math, cost-preview\naggregation, and the idempotency/OCC mechanics behind node execution.\n",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
8
+ "content": "# Deal Offer data model — reference\n\nFull shape of the config (Definitions) and player state, the node-graph\ntraversal rules, the milestone-bar addressing/math, and slot/offer resolution\nrules. All of these are **strictly typed in the SDK** — `DealOfferDefinitions`\nand every nested block are exported from `@idosgames/core`, so\n`getDefinition()` / `getSection<DealOfferDefinitions>(\"DealOffer\")` give you\nconcrete types, not `unknown`. Every schema keeps `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON). Every claim below traces to\n`IDosGamesSDK/API/Client/v2/DealOffer/{DealOffer.cs, Models/DealOfferDefinitions.cs,\nModels/UserDealOffersState.cs, Services/DealOfferHelpers.cs}` in the backend\nrepo, plus the shared `EventTokenService.cs` (Core/Event) for milestone claim\nmath.\n\n## Contents\n\n- [Config: DealOfferDefinitions](#config-dealofferdefinitions)\n- [DealSlotDefinition + queue/schedule](#dealslotdefinition--queueschedule)\n- [DealOfferDefinition](#dealofferdefinition)\n- [DealNodeDefinition + action params](#dealnodedefinition--action-params)\n- [Node unlock rules and the graph traversal algorithm](#node-unlock-rules-and-the-graph-traversal-algorithm)\n- [Tracks](#tracks)\n- [Node execution limits](#node-execution-limits)\n- [Milestones — addressing and reward math](#milestones--addressing-and-reward-math)\n- [Player state](#player-state)\n- [Slot resolution rules (GetActiveDeals)](#slot-resolution-rules-getactivedeals)\n- [Cost preview aggregation](#cost-preview-aggregation)\n- [Idempotency and OCC](#idempotency-and-occ)\n\n---\n\n## Config: DealOfferDefinitions\n\nReturned by `getDefinition()`; cached via\n`client.data.config.getSection<DealOfferDefinitions>(\"DealOffer\")`.\n\n```ts\ninterface DealOfferDefinitions {\n Slots?: Record<string, DealSlotDefinition>; // key = SlotID\n Offers?: Record<string, DealOfferDefinition>; // key = OfferID\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 37-53.\n\n---\n\n## DealSlotDefinition + queue/schedule\n\n```ts\ninterface DealSlotDefinition {\n SlotID?: string;\n Enabled?: boolean; // default true\n SortOrder?: number; // default 0\n Queue?: DealSlotQueueEntry[]; // shown in ascending Order; loops after the last\n Schedule?: ScheduleSpec; // see modes below; default Mode: \"Chained\"\n Gate?: SegmentGate; // audience gate; null = everyone\n AllowDismissSkip?: boolean; // default false — see Dismiss semantics below\n DismissSkipDelaySec?: number; // default 0\n}\n\ninterface DealSlotQueueEntry {\n Order?: number; // lower = shown earlier\n OfferID?: string; // key into DealOfferDefinitions.Offers\n DurationSec?: number; // 0 = no timer, active until fully exhausted\n DelayBeforeActivationSec?: number; // pause before this entry activates\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 62-153.\n\n### Schedule modes (`DealSlotDefinition.Schedule.Mode`)\n\nThe same `ScheduleSpec` container used by every other module (TimedEvent,\nLeaderboard, TimedBoost, ...), but Deal Offer gives each mode a distinct\nmeaning for **how the slot shows offer(s)** (`DealOfferHelpers.ResolveSlotState`,\nlines 632-649; doc comment on `Schedule` field, `DealOfferDefinitions.cs`\nlines 84-96):\n\n| Mode | Behavior |\n| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Chained` (default) | Native per-player queue: `Queue` advances for **this player** on expiry/exhaustion/dismiss of the active offer. This is the classic \"starter pack chain\" behavior. |\n| `Scheduled` / `Cyclic` / `AlwaysOn` | A **single offer** — the first `Queue` entry — active by wall clock, identically for every player (resolved via the shared `ScheduleResolver.ResolveActive`). |\n| `Triggered` | The offer becomes visible only when a source in `Schedule.ActivationTriggers` fires (e.g. a comeback offer after a board-game loss); visible for `DurationSec` of the first queue entry. |\n\nFor `Chained`, the resolver (`ResolveSlotState`, lines 632-731) walks:\n\n1. No `slotState` yet -> show queue's first entry (`IsNewActivation: true`).\n2. Active offer still alive (not timer-expired, not `Exhausted`/`Expired`, not\n a dismissed-with-`AllowDismissSkip` slot) -> keep showing it as-is.\n3. Otherwise advance: `QueueIndex + 1`; if past the end, wrap to `0` and bump\n `CycleIndex` (respecting `Schedule.Chain.MaxCycles`, `0` = unlimited, and\n `Schedule.Chain.PauseBetweenCyclesSec`, which pauses the whole slot, not just\n between offers).\n4. If the next entry has `DelayBeforeActivationSec > 0`, the wait is measured\n from whichever moment ended the previous offer (`LastDismissedAtUtc` if\n dismissed, `ExhaustedAtUtc` if exhausted, else the previous\n `ActiveOfferExpiresAtUtc`).\n\n`Triggered` slots never resolve anything until some other module's trigger hook\n(`BuildTriggeredOfferActivations`, lines 793-847) stamps an `ActiveOfferID` +\n`ActiveOfferExpiresAtUtc` on the slot state — the client cannot cause a\nTriggered offer to appear by calling deal-offer methods itself.\n\n### Dismiss semantics driven by `AllowDismissSkip`\n\n- `AllowDismissSkip: false` (default) — `dismissDeal` only marks the\n activation `Dismissed` and records history; the slot's timer (if any) keeps\n ticking and the same offer stays \"current\" (just inert) until it naturally\n expires/exhausts.\n- `AllowDismissSkip: true` — once dismissed, the resolver treats the slot as\n ready to advance (same branch as expiry/exhaustion), after\n `DismissSkipDelaySec` seconds have passed since `LastDismissedAtUtc` (`0` =\n immediately). This is meant for One-Time-Offer slots.\n\n---\n\n## DealOfferDefinition\n\n```ts\ninterface DealOfferDefinition {\n OfferID?: string;\n Version?: number; // default 1; bumped on config change\n Enabled?: boolean; // default true; false = no NEW activations (existing ones keep running)\n GraphMode?: DealOfferGraphMode; // descriptive metadata only — see below\n Name?: string;\n Description?: string;\n RootNodeIDs?: string[]; // available immediately on a fresh activation\n Nodes?: DealNodeDefinition[];\n Tracks?: DealTrackDefinition[];\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID; empty = no bar\n MilestoneClaimMode?: MilestoneClaimMode; // default \"Instant\"\n MilestoneToken?: EventTokenDefinition; // caps for milestone points; set iff Milestones non-empty\n ExhaustedWhenAllTerminalNodesCompleted?: boolean; // default true\n AssetPaths?: Record<string, string>;\n Metadata?: Record<string, string>;\n}\n\ntype DealOfferGraphMode =\n | \"Single\" // one node (Dapper Deal, Wild Sticker)\n | \"Chain\" // vertical/linear chain (Deal Aquarium)\n | \"BranchingChain\" // grid with arrows, multiple branches (Bargain Burrows)\n | \"Choice\" // pick one of N (Pick One Kennel)\n | \"MeteredChain\"; // chain gated by a progress track\n```\n\nSource: `DealOfferDefinitions.cs` lines 161-278.\n\n`GraphMode` is **purely descriptive** — it tells the title's UI/admin panel\nwhat shape the author intended, but the actual traversal is always just\n`RootNodeIDs` + `NextNodeIDs` + `UnlockRules` (+ `ChoiceGroupID` for Choice).\nDo not special-case client logic per `GraphMode`.\n\n`Enabled: false` on an offer blocks it only from being **newly activated**\n(`ComputeNodeExecutionCreate` checks `offerDef.Enabled` via the shared\n`offerDef == null || !offerDef.Enabled` guard in `ComputeNodeExecution`, line\n88, and `ResolveSlotState`'s `FindOfferDefinition`+`Enabled` checks, e.g. line\n656, 726, 749, 778) — it does not retroactively kill an activation already in\nprogress.\n\n---\n\n## DealNodeDefinition + action params\n\n```ts\ninterface DealNodeDefinition {\n NodeID?: string;\n SortOrder?: number; // default 0, UI draw order\n Type?: DealNodeType; // default \"Purchase\"\n Action?: DealNodeActionDefinition;\n Grants?: ResourceGrant; // paid out on node completion (see shouldGrant rules below)\n TrackChanges?: DealTrackChange[]; // applied on node completion\n MilestonePoints?: number; // default 0; points into the offer's milestone bar per execution\n UnlockRules?: DealNodeUnlockRules; // null = unlocked immediately (root)\n NextNodeIDs?: string[]; // nodes unlocked once THIS node completes\n ChoiceGroupID?: string; // for Choice-shaped graphs\n Limits?: LimitSpec; // null = default \"once per activation\"\n HideWhenCompleted?: boolean; // default false\n ExhaustOfferOnComplete?: boolean; // default false\n AssetPaths?: Record<string, string>;\n Metadata?: Record<string, string>;\n}\n\ntype DealNodeType = \"Purchase\" | \"FreeClaim\" | \"RewardedVideo\" | \"Info\";\n\ninterface DealNodeActionDefinition {\n Purchase?: DealPurchaseActionDefinition; // populated iff Type === \"Purchase\"\n RewardedVideo?: DealRewardedVideoActionDefinition; // populated iff Type === \"RewardedVideo\"\n}\n\ninterface DealPurchaseActionDefinition {\n StoreOfferID?: string; // if purchase routes through the Store subsystem\n BillingProductID?: string; // real-money IAP product id\n DirectCost?: ResourceConsume; // used only if no StoreOfferID/BillingProductID\n UseExternalRewards?: boolean; // default true — see grant rules below\n}\n\ninterface DealRewardedVideoActionDefinition {\n AdPlacementID?: string;\n ViewsRequiredToComplete?: number; // default 1\n MaxViewsPerActivation?: number; // default 1; 0 = unlimited\n CooldownSecondsBetweenViews?: number; // default 0\n RequireServerVerification?: boolean; // default true\n GrantRewardsPerView?: boolean; // default false — see grant rules below\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 286-454.\n\n### What actually gets charged/granted per node execution\n\n`DealOfferHelpers.BuildNodeOperation` (lines 352-402), run identically on both\nthe create (bootstrap) and update paths:\n\n**Cost** — only for `Type: \"Purchase\"` nodes where `Action.Purchase.UseExternalRewards`\nis `false` **and** `Action.Purchase.DirectCost.Standard` has at least one\nentry or event-token op. Otherwise no cost is charged by this call at all\n(e.g. `UseExternalRewards: true` means the real-money/Store purchase flow\nhandles the charge elsewhere; this node is just an acknowledgement +\nbonus-grant step). `DirectCost.PremiumDiscounts` ride along as-is — the\nbackend's `ResourceService.FilterByPremium` auto-picks the best matching tier\nand reduces `Consume.Standard` accordingly at charge time; the aggregated\n`Cost` preview on `ActiveDealSlotInfo` shows the **pre-discount** standard\nprice.\n\n**Grants** — `shouldGrant` is true unless the node is a `Purchase` node with\n`UseExternalRewards: true` (that combination means the _external_ system, not\nthis node, pays out the primary reward — `Grants` on such a node would be a\nbonus you'd instead need to design as `UseExternalRewards: false`, so in\npractice `UseExternalRewards: true` nodes rely on the store-purchase grant\npath). There is one more override: a `RewardedVideo` node with\n`GrantRewardsPerView: false` (the default) only grants `Grants` on the call\nthat **completes** the node (i.e. the final required view), not on every\nintermediate view — otherwise a multi-view node would overpay.\n\n**Milestone points** — independent of the above; if the offer has\n`Milestones` and the executed node's `MilestonePoints > 0`, an\n`EventTokenOperation` for `EventTokenType.DealOffer` is appended into the\n_same_ `Grant.Standard.EventTokens` list, in the same atomic transaction\n(`AppendMilestonePointsGrant`, lines 141-191).\n\n---\n\n## Node unlock rules and the graph traversal algorithm\n\n```ts\ninterface DealNodeUnlockRules {\n RequiredCompletedNodeIDs?: string[]; // ALL must be Completed\n RequiredAnyCompletedNodeIDs?: string[]; // AT LEAST ONE must be Completed\n RequiredTracks?: DealTrackRequirement[]; // offer-local track thresholds\n VisibleWhileLocked?: boolean; // default true\n}\n\ninterface DealTrackRequirement {\n TrackID?: string;\n Operator?: \"Eq\" | \"Gte\" | \"Lte\"; // default \"Gte\"\n Value?: number; // default 0\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 462-546.\n\nDespite `UnlockRules` existing independently of `NextNodeIDs` on paper, the\n**actual server algorithm resolves reachability from execution history, not\nfrom re-evaluating `UnlockRules` against arbitrary node IDs at read time.**\nConcretely (`DealOfferHelpers`, `ValidateNodeAccess` lines 1252-1286 +\n`BuildExecutionPatches`/`BuildFreshActivation` unlock blocks):\n\n- A **root node** (`RootNodeIDs`) is unlocked the instant the offer activates\n — no runtime `UserDealNodeState` entry needed; `ValidateNodeAccess` special-cases\n \"no state yet + `isRoot`\" as allowed.\n- A **non-root node with no runtime state yet** is rejected outright — \"Node\n is locked\" — **even if you construct a hypothetical case where its\n `UnlockRules` would already be satisfied.** The only way a non-root node's\n state ever gets created and set to `Available` is:\n - it appears in some other node's `NextNodeIDs` **and that other node just\n completed** (`BuildExecutionPatches` lines 896-905 / `BuildFreshActivation`\n lines 1069-1078: on completion, the engine writes `Available` state for\n every ID in `NextNodeIDs`, unconditionally — it does **not** re-check that\n node's own `UnlockRules` at that point), or\n - it has `RequiredTracks` and a `TrackChange` on some node execution just\n pushed the relevant track(s) over the threshold\n (`GetNodesUnlockedByTrack`, lines 1416-1458 — evaluated for **every**\n node in the offer whenever a track changes, regardless of `NextNodeIDs`\n membership, and only for nodes whose current status is `Locked` or absent).\n- Once a runtime state exists and is `Available`/`InProgress`, `UnlockRules`\n fields (`RequiredCompletedNodeIDs` / `RequiredAnyCompletedNodeIDs`) are\n **never consulted again** — they only produce the generic \"is locked\n (prerequisite nodes not completed)\" message on the **no-state, non-root**\n branch, purely to give a nicer error string; they do not gate anything once\n a node has been reached via `NextNodeIDs` or a track threshold.\n\n**Practical takeaway for the client:** treat `NextNodeIDs` as the _only_ real\nedge list, and `RequiredTracks`/`RequiredCompletedNodeIDs` as: (a) descriptive\nUI hints for what a locked node is waiting on, and (b) the mechanism for\ntrack-gated unlocks specifically (which do work as documented, via\n`GetNodesUnlockedByTrack`). Don't write client logic that unlocks a node\nbecause you've locally determined its `UnlockRules` are satisfied — always\nread `ActivationState.Nodes[nodeID].Status` from the server.\n\n### Choice groups\n\nOn completion of a node with `ChoiceGroupID` set, every **other** node sharing\nthat `ChoiceGroupID` is force-set to `Locked` and\n`ActivationState.SelectedChoiceNodeID` is stamped with the winner\n(`BuildExecutionPatches` lines 907-921, `BuildFreshActivation` lines\n1080-1092). This happens even if a sibling was already `Available`.\n\n### Offer exhaustion\n\nAn offer's activation flips to `Exhausted` the moment either is true\n(checked identically on create and update paths, e.g. lines 248-252 and\n318-322):\n\n- the just-completed node has `ExhaustOfferOnComplete: true`, or\n- the offer has `ExhaustedWhenAllTerminalNodesCompleted: true` (the default)\n **and** every node with an empty/absent `NextNodeIDs` (a \"terminal\" node) is\n now `Completed` or `Hidden` (`AreAllTerminalNodesCompleted`, lines\n 1374-1405).\n\n### Multi-execution nodes (`Limits.PerActivationCap` and RewardedVideo)\n\nA node is not necessarily a single-execution action:\n\n- For non-`RewardedVideo` nodes, `IsNodeCompleted` (lines 1350-1362) compares\n the new execution count against `Limits?.PerActivationCap` (default `1` if\n `Limits` is null) — so a node with e.g. `PerActivationCap: 3` requires 3\n `executeNode` calls before it flips to `Completed`, going through\n `InProgress` in between (`ComputeNodeStatus`, lines 1364-1372).\n- For `RewardedVideo` nodes, completion instead compares against\n `Action.RewardedVideo.ViewsRequiredToComplete` (default `1`), **not**\n `Limits.PerActivationCap` — see `ResolveEffectiveLimits` below.\n\n---\n\n## Tracks\n\n```ts\ninterface DealTrackDefinition {\n TrackID?: string;\n DisplayName?: string;\n StartValue?: number; // default 0\n MinValue?: number; // default 0\n MaxValue?: number; // default 0 = no maximum\n ClampToMin?: boolean; // default true\n ClampToMax?: boolean; // default true\n HiddenFromUI?: boolean; // default false\n}\n\ninterface DealTrackChange {\n TrackID?: string;\n Amount?: number; // positive = add, negative = subtract\n RespectBounds?: boolean; // default true\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 490-546; math in `ApplyTrackChange`\n(`DealOfferHelpers.cs` lines 1460-1471): `newValue = currentValue + Amount`,\nthen if `RespectBounds` and a matching `DealTrackDefinition` exists, clamp to\n`MinValue` (if `ClampToMin`) and to `MaxValue` (if `ClampToMax` **and**\n`MaxValue > 0`).\n\nTracks are seeded to `StartValue` when a fresh activation is bootstrapped\n(`BuildFreshActivation` lines 1024-1033) — they do **not** persist or carry\nover between activations of the same offer; a new activation (queue advance,\ncycle wrap, or re-trigger) always starts every track at its configured\n`StartValue`.\n\n---\n\n## Node execution limits\n\n`DealNodeDefinition.Limits` is the shared `LimitSpec` (`PerActivationCap` /\n`TotalCap` / `DailyCap`), but Deal Offer resolves an **effective** limit\nbefore checking it (`ResolveEffectiveLimits`, `DealOfferHelpers.cs` lines\n1331-1348):\n\n- **Non-RewardedVideo nodes**: `nodeDef.Limits ?? { PerActivationCap: 1 }` —\n i.e. no `Limits` block at all means \"once per activation,\" matching the\n doc comment on the config field.\n- **RewardedVideo nodes**: the per-activation cap is derived from the ad\n config, not from a generic default — `Math.max(MaxViewsPerActivation,\nViewsRequiredToComplete)` when `MaxViewsPerActivation > 0`, or `0`\n (unlimited) when `MaxViewsPerActivation <= 0`. This guarantees a multi-view\n ad node (`ViewsRequiredToComplete > 1`) can never be capped out before it's\n able to reach completion. `TotalCap`/`DailyCap` pass through from\n `nodeDef.Limits` unchanged (`0` = no cap) regardless of node type.\n\n`ValidateNodeLimits` (lines 1288-1311) checks, in order: `PerActivationCap`\nagainst the node's `ExecutionCount` this activation, `TotalCap` against\nlifetime `NodeCounts[nodeID].TotalExecutions`, and `DailyCap` against\n`NodeCounts[nodeID].DailyExecutions` (reset to `0` once `now >=\nDailyResetUtc`, which is set to the next UTC midnight after the first\nexecution of the day). Rejections surface as, respectively: `\"Execution\nlimit reached for this deal (X/Y)\"`, `\"Total execution limit reached for\nthis node\"`, `\"Daily execution limit reached for this node\"`.\n\nA `RewardedVideo` node with `CooldownSecondsBetweenViews > 0` additionally\nstamps `NextAvailableAtUtc` after each view; a call before that time returns\n`\"Node 'X' is on cooldown until <ISO time>\"`.\n\n---\n\n## Milestones — addressing and reward math\n\nMilestones use the shared `MilestoneDefinition` / `MilestoneClaimMode` (Core/Milestone\n— the same primitive as TimedEvent/Leaderboard/Quest/CommunityChest), but Deal\nOffer's progress source and instance addressing are module-specific.\n\n### Addressing (why the bar resets per activation)\n\n```\nInstanceKey = \"{slotID}:c{cycleIndex}:q{queueIndex}\"\nEventToken address = { Type: \"DealOffer\", EntityID: \"{offerID}:{InstanceKey}\" }\n```\n\nSource: `BuildDealInstanceKey` / `BuildMilestoneAddress`,\n`DealOfferHelpers.cs` lines 119-131. This address is recomputed fresh from\nthe _currently resolved_ slot position every time (`GetActiveDeals`,\n`ClaimMilestone`, `ClaimMilestonesBatch`, and node execution's\n`AppendMilestonePointsGrant` all call `BuildMilestoneAddress` with the live\n`CycleIndex`/`QueueIndex`) — so as soon as the slot advances to a new queue\nposition or cycle, the milestone bar's `EntityID` changes and the player\nstarts a **fresh** `EventTokenType.DealOffer` bucket at `TotalEarned: 0`.\nThere is no explicit \"reset\" step; it's a natural consequence of the address\nbeing derived from position, not from a monotonic counter.\n\n### Progress and reward computation\n\n- Points are earned via `DealNodeDefinition.MilestonePoints` on node\n execution, added to `EventTokenType.DealOffer`'s `Balance.TotalEarned` for\n that address (capped, if configured, by `DealOfferDefinition.MilestoneToken`'s\n `DailyEarnCap`/`MaxBalance`/`MaxPerGrant` — passed as an\n `EventTokenGrantContext`, `BuildMilestonePointsContext` lines 181-191; `null`\n `MilestoneToken` means points accrue with no global caps).\n- A milestone is \"reached\" when `TotalEarned >= milestoneDef.RequiredProgress`\n (`ComputeMilestoneClaim`, `EventTokenService.cs` line 352-353, and the\n `ReachedUnclaimedIDs` computation in `DealOffer.cs` lines 162-165).\n- The reward is **not** the milestone's flat `Rewards` — it's resolved through\n the shared `MilestoneRewardResolver.Resolve(milestoneDef, context)`\n (`DealOffer.cs` lines 652-657, 792-797), which applies the title's\n `Reward.MilestoneRewardMultiplier` progression overlay (a `RewardProgressionMultiplierSpec`\n keyed off things like board stage/rank/character level/season tier — see\n `references/_shared` `MilestoneModels.ts`) on top of the base `Rewards`. Deal\n Offer does not set a bonus-window or season-tier overlay itself, so in\n practice you get \"base reward, optionally scaled by the title-wide\n progression multiplier if one is configured on that milestone.\"\n- Batch claims combine every claimed milestone's resolved grant via\n `BonusWindowHelpers.MergeRewards` (concatenation of entries) into a single\n `ResourceOperation.Grant` before charging — so `claimMilestonesBatch`'s\n `Resources.Grant` in the response is the **sum of all claimed milestones**\n in that call, not itemized per milestone ID.\n\n### Claim mode gate (`MilestoneClaimMode`)\n\n`CheckMilestoneClaimMode` (`DealOffer.cs` lines 866-873):\n\n| Mode | Rule |\n| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| `Instant` (default) | Claimable as soon as reached — no additional gate. |\n| `AfterEventEnd` | Rejected with `\"Milestone can only be claimed after the offer ends.\"` unless the activation has ended. |\n| `FeaturedAfterEnd` | Same rejection, but only for milestones with `IsFeatured: true`; non-featured milestones under this mode claim instantly. |\n\n\"Activation ended\" (`IsActivationEnded`, lines 856-863) means: not a fresh\n`IsNewActivation`, **and** either `ActivationState.Status !== \"Active\"` or\n`now > ComputedExpiresAtUtc`. Deal Offer has **no grace window** after the\nslot advances — once the position moves on, the old bar's milestones under\n`AfterEventEnd`/`FeaturedAfterEnd` are governed by whatever `IsActivationEnded`\nevaluates to for the _newly resolved_ position, which for a genuinely-past\nactivation will read as ended.\n\n### Batch claim mechanics\n\n`ClaimMilestonesBatch` has no server-side cap on the number of milestone IDs\nper call (unlike Character's 50-item batch limit) — it just processes\nwhatever you send after de-duplication. Each requested ID is independently\nscreened for \"exists in `offerDef.Milestones`\" and the `ClaimMilestoneMode`\ngate _before_ being handed to `EventTokenService.ComputeMilestoneClaimBatch`,\nwhich re-checks `TotalEarned >= RequiredProgress` and \"not already claimed\"\nper ID (`EventTokenService.cs` lines 375-408). All rejections — not-found,\nclaim-mode-gated, not-reached, already-claimed — land in the same `Rejected`\ndictionary. The whole batch's DB write is one `PushEach` into `ClaimedIDs`\nguarded by one `Nin` filter, and the combined resource grant rides in the\nsame atomic `ResourceService.ApplyResourceOperationAtomicAsync` call — so\nclaimed milestones in one batch call either all persist or none do, but which\nIDs count as \"claimed\" vs \"rejected\" is decided before that atomicity\nboundary.\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ DealOffers: UserDealOffersState }` and\nmirrored into `client.data.user.state?.DealOffer` (note: response field is\n`DealOffers`, the cached property is `DealOffer` — singular).\n\n```ts\ninterface UserDealOffersState {\n Slots?: Record<string, UserDealSlotState>;\n OfferHistory?: Record<string, UserDealOfferHistory>; // lifetime stats, key = OfferID\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealSlotState {\n SlotID?: string;\n QueueIndex?: number;\n CycleIndex?: number;\n ActiveOfferID?: string; // denormalized for convenience\n ActiveOfferStartedAtUtc?: string;\n ActiveOfferExpiresAtUtc?: string | null; // null = no timer\n ActiveOffer?: UserDealOfferActivationState;\n NextCycleStartsAtUtc?: string | null; // set while paused between Chain cycles\n LastUpdatedUtc?: string;\n DismissSkipAvailableAtUtc?: string | null;\n LastDismissedAtUtc?: string | null;\n}\n\ninterface UserDealOfferActivationState {\n OfferID?: string;\n InstanceKey?: string; // \"{slotID}:c{cycleIndex}:q{queueIndex}\" — see Milestones\n SourceOfferVersion?: number; // DealOfferDefinition.Version at activation time\n Status?: DealOfferActivationStatus; // \"Active\" | \"Exhausted\" | \"Expired\" | \"Dismissed\"\n ActivatedAtUtc?: string;\n ExhaustedAtUtc?: string | null;\n ExpiredAtUtc?: string | null;\n DismissedAtUtc?: string | null;\n ShowCount?: number; // recordShow calls this activation\n LastShownAtUtc?: string | null;\n Nodes?: Record<string, UserDealNodeState>; // key = NodeID\n Tracks?: Record<string, UserDealTrackState>; // key = TrackID\n SelectedChoiceNodeID?: string | null;\n}\n\ninterface UserDealNodeState {\n NodeID?: string;\n Status?: DealNodeRuntimeStatus; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\n ExecutionCount?: number; // executions this activation\n UnlockedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n LastExecutedAtUtc?: string | null;\n NextAvailableAtUtc?: string | null; // RewardedVideo cooldown\n LastExecutionRefID?: string | null; // last RelatedEntityID — idempotency key\n}\n\ninterface UserDealTrackState {\n TrackID?: string;\n CurrentValue?: number;\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealOfferHistory {\n TotalActivations?: number;\n TotalExhausted?: number;\n TotalExpired?: number;\n TotalDismissed?: number;\n TotalShows?: number; // across ALL activations, including pre-activation show-only calls\n LastShownAtUtc?: string | null;\n LastActivatedAtUtc?: string | null;\n LastExhaustedAtUtc?: string | null;\n NodeCounts?: Record<string, UserDealNodeLifetimeCounts>; // key = NodeID\n}\n\ninterface UserDealNodeLifetimeCounts {\n TotalExecutions?: number;\n DailyExecutions?: number;\n DailyResetUtc?: string;\n}\n```\n\nSource: `UserDealOffersState.cs` in full.\n\n`OfferHistory` is cross-activation (lifetime), keyed by `OfferID` — separate\nfrom the per-activation `Nodes`/`Tracks` under `Slots[x].ActiveOffer`, which\nget wholesale replaced every time a fresh activation bootstraps\n(`BuildBootstrapPatches` does one `Set(activeOfferBase, activation)`, wiping\nthe previous activation's node/track state).\n\n---\n\n## Slot resolution rules (GetActiveDeals)\n\n`GetActiveDeals` (`DealOffer.cs` lines 95-178) walks every **enabled** slot\nwith a non-empty `Queue`, applies the slot's `Gate` (`SegmentGate`, read\nlazily — only fetches the gate-relevant player projection once, and only if\nat least one slot actually has a `Gate` configured), then calls\n`DealOfferHelpers.ResolveSlotState` per slot (the same pure resolver\n`ExecuteNode` uses internally, so reads and writes agree on \"what's active\nright now\"). Slots that fail the gate, have no enabled current/next offer, or\nare mid-pause are simply omitted from the response — there's no \"locked slot\"\nplaceholder entry.\n\nEach returned `ActiveDealSlotInfo.Milestone` is populated only if the\nresolved offer has `Milestones` — its `TotalEarned`/`ClaimedIDs` come from a\nlive `EventTokenService.ReadProgress` read against the _current_ milestone\naddress, and `ReachedUnclaimedIDs` is computed inline (defined milestones\nwhose `RequiredProgress` is met and not yet in `ClaimedIDs`).\n\n**Note on the earning-vs-spending gate split**: `ExecuteNode` re-checks the\nslot's `SegmentGate` itself, but **only** for `FreeClaim` and `RewardedVideo`\nnode types (`DealOffer.cs` lines 343-372) — `Purchase` and `Info` node\nexecutions are never blocked by the slot gate directly (a gated-out player\nsimply never sees the slot via `GetActiveDeals`, but a direct `executeNode`\ncall against a `Purchase`/`Info` node id they somehow know about isn't\nre-gated here). This mirrors the platform-wide principle: gate visibility and\nearning, not spending or claiming.\n\n---\n\n## Cost preview aggregation\n\n`ActiveDealSlotInfo.Cost` (`GetNodesCost`, `DealOfferHelpers.cs` lines\n1483-1542) is a single aggregated `ResourceConsume` built by scanning every\n`Purchase` node in the offer that has `UseExternalRewards: false` and a\nnon-null `DirectCost`:\n\n- `Standard` — concatenation of every such node's `DirectCost.Standard.Entries`\n and `.EventTokens` (i.e. the **sum total** if a player bought every\n purchasable node in the offer, not any single node's price).\n- `PremiumDiscounts` — unioned across nodes; when two nodes declare a discount\n for the same `(MinPremiumTier, RequiredPremiumID)` key, the **larger**\n `DiscountPercent` wins (most favorable to the player is shown).\n- `PremiumTiers` — unioned across nodes; on a duplicate `(MinPremiumTier,\nRequiredPremiumID)` key, the **first** one encountered wins (structural\n tier prices aren't summed/merged).\n\nThis is a **preview only** — the actual charge for a specific node is\ncomputed fresh, per-node, at `executeNode` time via `BuildNodeOperation`, with\nthe real discount resolution happening inside\n`ResourceService.ApplyResourceOperationAtomicAsync` → `FilterByPremium`.\n\n---\n\n## Idempotency and OCC\n\nEvery mutating call resolves its Mongo write with `ResourceService.ResolveRelatedEntityID(requestID,\nprefix)`: if the client supplied a `RelatedEntityID` (the SDK's\n`externalRefID` param on `executeNode`, or the auto-minted UUID-suffixed\ndefault), that's the key; otherwise a fallback keyed by slot/offer/node plus\nUnix-seconds guards against sub-second retries. On top of that generic\nmechanism, `ExecuteNode` layers a **domain-level idempotent replay**: if the\nsame `RelatedEntityID` matches `UserDealNodeState.LastExecutionRefID` for\nthat exact node in the current activation, the handler returns success with\n`Idempotent: true` and an empty `ResourceOperation` **without opening a Mongo\ntransaction at all** (`ComputeNodeExecution`, lines 70-79 for the\nalready-known-node fast path, and lines 227-228 in the update path) — this is\nwhy the SDK skips re-applying `Resources` into the cache when\n`data.Idempotent` is true.\n\nNode execution races (two `executeNode` calls hitting the CREATE/bootstrap\npath at once) are guarded by an optimistic-concurrency filter\n(`BuildBootstrapFilter`, lines 1189-1202) that fails the write if a live\n`Active` activation of the same offer at the same queue/cycle position\nalready exists; on that specific race the HTTP handler retries **once** by\nre-reading fresh state and resolving again (`DealOffer.cs` lines 382-472) —\nthis is transparent to the client, no special handling needed on your side\nbeyond normal retry-on-`\"connection\"`/`\"server\"` policy.\n"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "dev-test-loop",
3
+ "description": "Run the боево (real) create→configure→generate→test→fix loop when building or improving a game feature on the iDosGames TypeScript SDK (@idosgames/core). Provision the DEV title ({TitleID}-DEV), implement the feature with the SDK and the per-module skills (character-system, store-system, quest-system, …), apply the title config for every module used, generate content (image/audio/3D) if needed, then run `npm run verify` (@idosgames/harness) against the DEV title and fix until it goes green. Use this whenever the user wants to build, add, or improve a game feature end-to-end, test a feature against the backend, close the loop, or otherwise wants the work to end in a green verify run against a real sandbox rather than just written code — even if they don't name the loop.",
4
+ "content": "---\nname: dev-test-loop\ndescription: >-\n Run the боево (real) create→configure→generate→test→fix loop when building or\n improving a game feature on the iDosGames TypeScript SDK (@idosgames/core).\n Provision the DEV title ({TitleID}-DEV), implement the feature with the SDK and\n the per-module skills (character-system, store-system, quest-system, …),\n apply the title config for every module used, generate content (image/audio/3D)\n if needed, then run `npm run verify` (@idosgames/harness) against the DEV title\n and fix until it goes green. Use this whenever the user wants to build, add, or\n improve a game feature end-to-end, test a feature against the backend, close the\n loop, or otherwise wants the work to end in a green verify run against a real\n sandbox rather than just written code — even if they don't name the loop.\n---\n\n# Dev test loop (iDosGames)\n\nYour unit of work is not \"write the code\" — it's **\"take the feature to a green\nбоевой (real) verify run against a DEV title.\"** You don't finish until the\nproject builds and the feature actually works against the backend sandbox.\n\nTesting is done **here, by you** (locally, via `npm run verify`) — there is no\nseparate test service. The same runner also powers AI Coder in the browser\n(Sandpack), so scenarios you write are reusable.\n\n## The loop\n\n1. **Get the DEV title.** Every live title has a paired DEV sandbox `{TitleID}-DEV`,\n isolated from prod. Provision/reuse it once, then work entirely in DEV:\n\n ```bash\n curl -s -X POST \"$IGE_API_URL/v2/ai-coder/EnsureDevTitle\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\\\"AuthToken\\\":\\\"$IGE_AUTH_TOKEN\\\",\\\"TitleID\\\":\\\"$IGE_TITLE_ID\\\"}\"\n # → { \"Data\": { \"parentTitleId\": \"...\", \"devTitleId\": \"ABCD1234-DEV\", \"created\": true } }\n ```\n\n Use `devTitleId` for everything below. Never test against the prod title.\n\n2. **Understand → load skills.** Identify which SDK services and title modules the\n feature touches, and read those skills first (`character-system`, `store-system`,\n `currency-system`, `quest-system`, `lootbox-system`, `leaderboard-system`,\n `season-system`, …). Don't guess SDK signatures — the skills are canonical.\n\n3. **Implement.** Write the game code on `@idosgames/core` following the loaded\n skills and the template's conventions. Edit minimally and in place.\n\n4. **Configure the title.** For every module the feature uses, apply settings to\n the **DEV** title (currencies, items, characters, store SKUs, seasons, …). Code\n and config must agree — ids/keys referenced in code must exist in the config.\n\n5. **Generate content (if needed).** Create missing assets (icons, audio, 3D,\n sprites) via the generation tools, wait for completion, put the real URLs into\n the config. No placeholders.\n\n6. **Boевой тест — mandatory.** Run the real verify against the DEV title:\n\n ```bash\n IDOS_TITLE_ID=\"ABCD1234-DEV\" npm run verify\n # optional: IDOS_BASE_URL=<runtime> IDOS_DEVICE_ID=<stable id>\n ```\n\n This does a device-id login and runs SDK scenarios against the DEV title,\n printing structured JSON and exiting 0 (green) / 1 (failures). Also run the\n project build (`npm run build`) so compile/type errors surface. Never call the\n task done without a green verify in this turn.\n\n7. **Fix loop.** On failures — read the structured result (build error, `reason`,\n `error`, `failedStep`), find the root cause, and fix the right layer:\n - build/type error → fix the **code**;\n - missing id/key → fix the **DEV title config**;\n - `reason: \"server\"` / failed SDK step → fix call logic, auth, or code↔config\n drift.\n Re-run verify. Repeat until fully green. Don't ask for confirmation between\n iterations — fix it yourself. Start each iteration with:\n `Провалилось: <что> → правлю: <что>`.\n\n## Writing scenarios\n\nThe default `npm run verify` runs the `auth-smoke` scenario (proves login works).\nFor a real feature test, add a scenario for it, using the per-module skill as the\nsource of truth for the calls. Pattern (see `@idosgames/harness` README and\n`packages/harness/src/scenarios.ts`):\n\n```ts\nimport type { Scenario } from \"@idosgames/harness\";\n\nexport const unlockThenUpgrade: Scenario = {\n name: \"unlock-then-upgrade\",\n run: async (ctx) => {\n await ctx.call(\"login\", () => ctx.client.auth.loginWithDeviceID());\n await ctx.call(\"defs\", () =>\n ctx.client.character.getCharacterDefinitions(),\n );\n await ctx.call(\"unlock\", () =>\n ctx.client.character.unlockCharacter(\"Knight\"),\n );\n const state = await ctx.call(\"state\", () =>\n ctx.client.character.getUserCharacters(),\n );\n ctx.expect(\n (state.Characters?.[\"Knight\"]?.Power ?? 0) > 0,\n \"Knight Power should be > 0\",\n );\n },\n};\n```\n\n`ctx.call` asserts `OperationResult.ok` and returns `data`; a failed SDK call\nbecomes a structured step failure with `reason`/`error`. `ctx.expect` asserts\nstate. Between big runs, reset the DEV title with\n`POST $IGE_API_URL/v2/ai-coder/ResetDevTitle {AuthToken, TitleID: devTitleId}`\n(full wipe + clean re-seed) so runs are deterministic.\n\n## Definition of Done (all required)\n\n- Project builds with no errors.\n- Every touched module is configured on the **DEV** title, consistent with the code.\n- All content referenced by code/config exists (real URLs, not placeholders).\n- `npm run verify` is **green** against the DEV title; you cited the passing result.\n\n## Prerequisites (env)\n\n- `IGE_API_URL` — management backend base (AICoder + config; `v2/ai-coder/*`).\n- `IGE_AUTH_TOKEN` — publisher auth token.\n- `IGE_TITLE_ID` — the prod title whose DEV sandbox you build against.\n- `IDOS_BASE_URL` — runtime engine base for the SDK (optional; defaults to\n `api.idosgames.com`).\n\n## References\n\n- Harness usage & result shape: `packages/harness/README.md`.\n- Per-module SDK recipes: the `*-system` skills in this folder.\n- Loop prompts (backend + Claude Code): the playbook in the backend repo,\n `Core/Controllers/v2/AICoder/AGENTIC_LOOP_PROMPTS.md`.\n",
5
+ "references": []
6
+ }