@idosgames/mcp 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "chat-system",
3
+ "description": "Build a player chat in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.chat (ChatService): list the title's channels, poll every room for new messages with one call, send to a channel, open and use 1-on-1 direct conversations, page back through history, mark a room read, join or leave opt-in channels, report a message, and keep a personal ignore list. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a world chat, global chat, server chat, guild or clan chat, private messages, whispers, DMs, an in-game messenger, a chat window or chat bubbles, unread badges for conversations, muting or blocking another player, reporting abuse, or otherwise touches client.chat, ChatService, ChatDefinitions, ChatChannelView, ChatMessageView, ChatCursor, or ChatPollResponse — even if they don't name the module explicitly.",
4
+ "content": "---\nname: chat-system\ndescription: >-\n Build a player chat in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.chat (ChatService): list the title's channels,\n poll every room for new messages with one call, send to a channel, open and\n use 1-on-1 direct conversations, page back through history, mark a room read,\n join or leave opt-in channels, report a message, and keep a personal ignore\n list. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a world chat, global chat,\n server chat, guild or clan chat, private messages, whispers, DMs, an\n in-game messenger, a chat window or chat bubbles, unread badges for\n conversations, muting or blocking another player, reporting abuse, or\n otherwise touches client.chat, ChatService, ChatDefinitions, ChatChannelView,\n ChatMessageView, ChatCursor, or ChatPollResponse — even if they don't name\n the module explicitly.\n---\n\n# Chat system (iDosGames TS SDK)\n\nThe Chat module runs a title's player chat: **persistent channels** declared in\nthe title config, and **1-on-1 conversations** addressed by the pair of\nparticipants. Everything is server-authoritative — the backend decides which\nchannels a player sees, which room he is placed into, whether he may write, and\nwhat the text ends up being after moderation.\n\nThis skill is for **using** the production `ChatService`, not for porting or\nextending it. A rejected call is the backend enforcing a rule (gate, slow mode,\nmute, blocked word) — surface the message, don't reproduce the check\nclient-side.\n\n## The three things you must understand first\n\n### 1. One poll covers every room — never loop over channels\n\n`client.chat.poll()` returns updates for **all** rooms the player can see:\nchannels and open conversations alike. It also remembers the cursors for you.\n\n```ts\nconst result = await client.chat.poll();\nif (result.ok) {\n for (const room of result.data.Rooms ?? []) {\n render(room.RoomID, room.Messages ?? []);\n }\n}\n```\n\n⚠ **Do not call `poll` per channel.** Every request is billed against the\npublisher's API quota, so a per-channel loop multiplies that bill by the number\nof channels — on every tick, for every player.\n\n### 2. The interval comes from the server, and you poll only while chat is open\n\n`PollIntervalSeconds` arrives with `getChannels()` and with every poll, and the\nserver may change it on the fly.\n\n```ts\nconst channels = await client.chat.getChannels();\nconst seconds = channels.data?.PollIntervalSeconds ?? 5;\n```\n\n⚠ **Stop polling when the chat screen closes.** The unread badge is refreshed by\n`getChannels()` the next time the player opens it, so a closed chat costs\nnothing. A background poll loop is the single easiest way to burn a publisher's\nquota on a feature nobody is looking at.\n\n### 3. Polling never marks anything read\n\nThat is deliberate: it is what keeps the poll a pure read on the server. Call\n`markRead()` when the player is _actually looking_ at a room — not when the app\nmerely fetched it.\n\n```ts\nawait client.chat.markRead(roomID, cursorOfTheLastMessageYouRendered);\n```\n\n⚠ **Pass the cursor when you open a room.** With nothing to fall back on the server marks the\nroom read up to _now_ — including messages that landed while the screen was opening and were\nnever drawn. Those are then gone for good: the next poll resumes from the cursor you just moved\npast them. Load the history page first and mark read up to its newest message.\n\n⚠ **Do not call it on every poll tick.** `markRead` is a WRITE, so a call per tick doubles the\ncost of an open chat, and every request is billed to the publisher. Keep the cursor from the poll\nand flush it when the player leaves the room, closes the chat, or every half-minute or so.\n\n## Channels and rooms are different things\n\nA **channel** is what the publisher configures (`ChannelID`: `\"world\"`,\n`\"trade\"`). A **room** is where _this_ player was placed inside it\n(`RoomID`: `\"world\"`, `\"bylang#ru\"`, `\"world##2\"`).\n\nAlways address messages by `ChannelID` when sending to a channel, and read\n`RoomID` from `getChannels()` for everything that identifies a feed (history,\nmark-read, reports).\n\nRooms exist because a channel can be **partitioned** by language or country and\n**sharded** by capacity. The partition key is computed by the server — the\nclient never chooses it.\n\n## Unread counts are nullable, and `null` is not zero\n\n```ts\n// WRONG — shows \"nothing new\" when the server simply did not count\nconst badge = channel.UnreadCount ?? 0;\n\n// RIGHT\nconst badge =\n channel.UnreadCount === null || channel.UnreadCount === undefined\n ? \"…\" // unknown\n : String(channel.UnreadCount);\n```\n\n`null` means the server's hot cache was cold, not that the room is quiet.\n\n## Sending\n\n```ts\nconst sent = await client.chat.sendMessage(\"world\", text);\nif (sent.ok) appendToFeed(sent.data.Message); // <- the SERVER's copy\n```\n\n⚠ **Render `sent.data.Message.Text`, not the string you typed.** With `Mask`\nmoderation the server returns the message with the offending word starred out;\nshowing the original would put text on screen that nobody else can see.\n\nCommon refusals to surface as-is: slow mode (`CooldownSeconds`), a daily cap,\nthe same text twice in a row, a blocked word, and being muted.\n\n## Direct conversations\n\n```ts\nconst opened = await client.chat.openDirectChannel(otherPlayerId);\nawait client.chat.sendDirect(opened.data.RoomID, \"gg\");\n\nconst list = await client.chat.getConversations(); // most recent first\n```\n\nThe address is derived from the sorted pair of user ids, so both sides get the\nsame `RoomID` and a conversation never doubles. Direct messages are **opt-in per\ntitle** (`Direct.IsEnabled`) and may be restricted to friends\n(`WhoCanMessage: \"Friends\"`).\n\n## Moderation the player controls\n\n```ts\nawait client.chat.ignoreUser(otherPlayerId); // one-sided, invisible to him\nawait client.chat.reportMessage(roomID, messageID, \"Abuse\", \"optional note\");\n```\n\nIgnoring is applied on the **server** at read time — the other player can still\npost, his messages simply stop being delivered here. Never build a client-side\nfilter instead: the messages would still arrive, and the filter is trivially\nbypassed.\n\nA repeat report returns `AlreadyReported: true` **with success**. Show a\nconfirmation, not an error — the player is being persistent, not wrong.\n\n## History\n\n```ts\nlet page = await client.chat.getHistory(roomID);\n// … later, for the next screenful:\npage = await client.chat.getHistory(roomID, page.data.NextBefore);\n```\n\n`NextBefore === null` means there is nothing older. History is bounded by the\nchannel's retention (hours for a world channel, much longer for conversations),\nso an empty page is normal, not an error.\n\n## Channel names are localization keys\n\n`DisplayNameKey` is a **key**, not a label:\n\n```ts\nconst title = client.localization.t(channel.DisplayNameKey ?? \"\");\n```\n\nA literal still renders (resolution falls back to the key itself), but a title\nwritten with literals cannot be translated.\n\n## Events\n\nSubscribe instead of diffing state yourself:\n\n- `chat:messages` — new messages in one room (once per room per poll)\n- `chat:channelsLoaded`, `chat:definitionsLoaded`\n- `chat:sent`, `chat:read`\n\n## What this module does NOT do\n\n- **No push.** Delivery is cursor polling over HTTP; there is no socket.\n- **No clan chat yet.** A `Group` channel resolves its roster from an external\n provider, and none exists in the engine today — such a channel is hidden\n rather than shown empty. When clans ship, the same channel starts working\n with no client change.\n- **Muting a player and resolving reports are publisher actions**, done from the\n dashboard, not from the game.\n",
5
+ "references": []
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "community-marketing-system",
3
+ "description": "Add a creator reward programme (\"community marketing\") to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.communityMarketing (CommunityMarketingService): show whether the title runs a programme, send the player to the creator portal, read what they earned for posting videos / clips / screenshots to YouTube, TikTok or Instagram, show the per-work breakdown of that number, and claim approved rewards in game currency or crypto. Use this whenever the user is working in the iDosGames TS SDK or its game templates and wants creator rewards, an ambassador or influencer programme, \"get paid for posting about the game\", UGC bounties, view-based payouts, a creator dashboard inside the game, or otherwise touches client.communityMarketing, CommunityMarketingService, CommunityMarketingState, CommunityEarningsResponse or CommunityClaimResponse — even if they don't name the module explicitly.",
4
+ "content": "---\nname: community-marketing-system\ndescription: >-\n Add a creator reward programme (\"community marketing\") to a game on the\n iDosGames TypeScript SDK (@idosgames/core) via client.communityMarketing\n (CommunityMarketingService): show whether the title runs a programme, send\n the player to the creator portal, read what they earned for posting videos /\n clips / screenshots to YouTube, TikTok or Instagram, show the per-work\n breakdown of that number, and claim approved rewards in game currency or\n crypto. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates and wants creator rewards, an ambassador or influencer\n programme, \"get paid for posting about the game\", UGC bounties, view-based\n payouts, a creator dashboard inside the game, or otherwise touches\n client.communityMarketing, CommunityMarketingService,\n CommunityMarketingState, CommunityEarningsResponse or CommunityClaimResponse\n — even if they don't name the module explicitly.\n---\n\n# Community Marketing (iDosGames TS SDK)\n\nA **creator reward programme**: community members post videos, clips or\nslideshows about the title on social platforms, the platform reads the public\nmetrics of those posts, and the creator gets paid — in the title's currency,\nan item, or crypto — according to rates and milestones the publisher set.\n\n**The game owns almost none of this**, and that is the single most important\nthing to understand before writing any UI.\n\n## What lives where\n\n| Step | Where it happens |\n| ----------------------------------------------------- | ----------------------------- |\n| Joining the programme, submitting a post link | Creator portal on the website |\n| Moderation, metric polling, anti-fraud, accrual maths | Management backend |\n| **Showing state, showing earnings, claiming money** | **The game — this module** |\n\nSo `client.communityMarketing` has exactly three calls. If you find yourself\nwanting a fourth — \"submit a video from the game\", \"approve a submission\" —\nstop: that surface does not exist and must not be recreated. Send the player to\nthe portal instead.\n\n```ts\nconst state = await client.communityMarketing.getState();\nif (isOk(state) && state.data.IsEnabled) {\n // ⚠ OPEN this URL. Never build one from a template.\n openExternal(state.data.CreatorPortalUrl!);\n}\n```\n\n## The three calls\n\n### `getState()`\n\nIs the programme on, is this player a creator, and where to send them.\n\n```ts\nconst res = await client.communityMarketing.getState();\nif (!isOk(res)) return;\n\nconst {\n IsEnabled,\n CreatorPortalUrl,\n ParticipationMode, // \"Open\" | \"Application\"\n IsCreator,\n CreatorStatus, // Pending | Approved | Rejected | Suspended | Left\n VerificationCode,\n Campaigns, // [{ CampaignID, DisplayNameKey, IconPath, IsRunning }]\n} = res.data;\n```\n\n- Answers with a **meaningful empty state**, not an error, for a player who\n never joined — that is the normal case for everyone opening the screen. Do\n not render an error banner for `IsCreator: false`.\n- `DisplayNameKey` / `DescriptionKey` are **localization keys**: wrap them in\n `client.localization.t()`. A title that never set up localization gets the\n literal back, so this is always safe.\n- `VerificationCode` is the creator's proof-of-authorship code — it only ever\n comes back to its owner. Showing it in-game is useful (they need it in the\n post description), but the post itself is submitted in the portal.\n\n### `getEarnings(limit?)`\n\nWhat the player earned, **why**, and the payout history.\n\n```ts\nconst res = await client.communityMarketing.getEarnings();\nif (!isOk(res)) return;\n\nconst { HoldDays, Campaigns, Payouts } = res.data;\n\nfor (const line of Campaigns ?? []) {\n line.Earned; // everything the formula has counted — an ESTIMATE, and it can go DOWN\n line.Mature; // the part past the hold period — still an estimate\n line.Granted; // ⚠ THE PROMISE: what a moderator approved for payout\n line.Paid; // already handed over\n line.Claimable; // what the button will actually pay, in WHOLE units, out of Granted\n line.BySubmission; // per-work breakdown: { [submissionID]: { Earned, Mature, CountedMetrics, ... } }\n}\n```\n\n⚠⚠ **`Earned` is an estimate; `Granted` is the promise.** The formula recomputes\nfrom metric snapshots every time, so `Earned` grows with views and **drops** when\nthe platform scrubs fake activity. It becomes money owed only once a human\napproves it. Showing only `Earned` is how this screen turns into a support\nticket — one day the number goes down with nothing on screen to explain it:\n\n| Number | Means |\n| ----------- | -------------------------------------------------------- |\n| `Earned` | what the current metrics are worth — moves both ways |\n| `Mature` | the part of it older than `HoldDays` — still an estimate |\n| `Granted` | **approved by a moderator; this is what gets paid** |\n| `Paid` | what has already been handed over |\n| `Claimable` | `floor(Granted − Paid)` — what the button pays now |\n\n⚠ **`Mature − Granted > 0` is a normal state, not a delay to hide.** It means\nthe work has matured and is waiting on a human. Say that: otherwise the creator\nsees two numbers that do not add up and no one to ask about it.\n\n`HoldDays` exists so the UI can **say why** maturing takes time: social\nplatforms strip fake activity in the first days, so paying immediately would\nmean paying for it. Show that sentence. A number that quietly refuses to be\nclaimable reads as a bug.\n\n⚠ **`Overpaid > 0` is not an error.** A metric fell after the player was\nalready paid for it. The platform never takes it back — future earnings absorb\nit. Say so, or `Mature < Paid` looks like broken arithmetic.\n\n⚠ **Render `BySubmission`.** The programme pays for someone's work by a formula\nthey never saw. A total with no \"which video, at what rate\" is\nindistinguishable from an arbitrary number.\n\n### `claim(campaignID)`\n\nHands over the **approved** reward in game currency or items. It lands in the\nordinary balance through the same path as any other reward.\n\n⚠ **Crypto campaigns are refused here** with `USE_WITHDRAW_FOR_CRYPTO` — see\n`withdrawCryptoReward` below.\n\n```ts\nconst res = await client.communityMarketing.claim(campaignID);\nif (!isOk(res)) return;\n\nconst { Claimed, CurrencyID, RemainingFraction, IdempotentReplay, Message } =\n res.data;\n```\n\n⚠ **`Claimed: 0` is a SUCCESS.** Payouts are whole units, so a creator holding\n0.4 currency gets zero and keeps the 0.4 in `RemainingFraction`. Show that\nnumber — without it the player concludes their earnings were eaten. `Message`\ncarries the server's reason for the zero.\n\n⚠ **`IdempotentReplay: true` means the money moved on an earlier attempt.** A\nretried claim is safe and never double-pays. Say \"already paid\", not \"paid\nagain\", and do not add the amount to the balance a second time.\n\n### `withdrawCryptoReward(campaignID, walletAddress, amount?)`\n\nCrypto rewards only. Sends the approved amount **out of the programme's pool\nbucket straight to the wallet** — it never passes through the in-game balance.\n\n```ts\nconst res = await client.communityMarketing.withdrawCryptoReward(\n campaignID,\n walletAddress,\n);\nif (!isOk(res)) return;\n\nconst {\n TitleTransactionID,\n ChainID,\n EvmSignature,\n SolanaSignature,\n NetAmountNative,\n} = res.data;\n\n// EVM — with @idosgames/wallet:\n// const hash = await submitEvmTokenWithdrawal(clients, EvmSignature);\n// await client.blockchain.confirmWithdrawal({ TitleTransactionID, TransactionHash: hash });\n\n// Solana — build `withdraw_spl` against the platform_pool program, ed25519 pre-instruction FIRST:\n// Ed25519Program.createInstructionWithPublicKey({ publicKey, message, signature }) // ← see below\n// ...then confirmWithdrawal the same way.\n```\n\n⚠⚠ **On Solana, do NOT re-encode the voucher message.** `SolanaSignature.Ed25519Message` is the\nexact byte string the server signed — hand those bytes to the precompile. The `WithdrawSigMessage`\nlayout is a cross-repo contract between the program, its SDK and two backends, and it has already\ndrifted once (`expires_at` was added, not every copy updated, the decoder silently died). Encoding\nthe instruction ARGUMENTS yourself is fine and unavoidable: the program rebuilds the signed message\nfrom them and compares, so a mistake yields `SigMismatch` — a rejected transaction, never a wrong\npayment.\n\n⚠ Solana specifics that cost real money to rediscover: the ed25519 instruction must be **first**\n(`SigIxIndex` points at it); every split recipient's ATA must exist (create idempotently, in the\nsame order as `Splits`); the token program comes from the **mint's owner** (Token-2022 lives\nelsewhere and the ATA addresses follow it); and browser wallets are safest with a **legacy**\ntransaction — not every adapter supports v0.\n\n⚠⚠ **Why this is a separate call and not a flavour of `claim`.** In-game crypto\nand Community Marketing money are different money with opposite rules. A player's\nin-game crypto balance promises no payout — if the pool is empty they simply\ncannot withdraw, and that is by design. A creator whose work was approved is owed\nthe money regardless. Put both in one balance and you lose both rules at once.\n\n⚠⚠ **The pool is debited and the request exists the moment this resolves**,\nbefore anything reaches the chain — exactly like an ordinary withdrawal. So\n\"it failed, press it again\" charges a **second** time. Keep `TitleTransactionID`\nand finish an interrupted one with `confirmWithdrawal` (if the transaction\nactually landed) or `retryWithdrawal`. Never ask for a fresh voucher.\n\n⚠ **`ChainID` comes from the server, next to the signature — do not derive it.**\n`NetworkID` is a name (`\"bsc\"`); the signature is bound to the number. Sent to\nthe wrong chain the transaction is not misdelivered, it is rejected — after\nspending gas. Solana carries `ProgramID` in the voucher for the same reason.\n\n⚠ **Which chain family a reward pays on is the server's answer, not a guess.** Publishers name\ntheir own networks, so `NetworkID` alone tells you nothing; the creator-facing programme view\ncarries the family explicitly. Guessing here means offering the wrong wallet.\n\n⚠ **`NetAmountNative` is what reaches the wallet**, `AmountNative` is what left\nthe pool. Commission and burn are the difference; show the one you mean.\n\n### `checkJoinEligibility()`\n\nEvaluates the publisher's join requirements against the signed-in player and, if they hold, joins\nthe programme.\n\n```ts\nconst res = await client.communityMarketing.checkJoinEligibility();\nif (!isOk(res)) return;\n\nconst { Passed, Approved, Conditions, WalletMissing } = res.data;\n```\n\n⚠ Only meaningful while the programme's participation mode is `Automatic` and the player has\nalready applied — the application itself is created in the creator portal. In the by-application\nmode the check still reports the breakdown but never admits anyone.\n\n⚠⚠ **It never overrides a human.** A creator the publisher rejected or suspended stays that way\neven with every condition satisfied — `Passed: true, Approved: false` is exactly that case, and the\nUI should say so rather than showing a silent no-op.\n\n⚠ **Render `Conditions`, including on success.** The programme pays for work by rules the person\nnever saw; a bare \"not allowed\" reads as arbitrary, while \"1000 GOLD needed, you have 400\" reads as\na rule they can meet. Showing it on success too tells them what they would lose by dropping below.\n\n⚠ **`Actual: null` means NOT MEASURED, not zero** — the condition was skipped because the outcome\nwas already decided, or the wallet balance could not be read. Rendering it as zero tells the player\nsomething false about their own balance.\n\n## Events\n\n```ts\nclient.on(\"communityMarketing:stateLoaded\", (state) => {});\nclient.on(\"communityMarketing:earningsLoaded\", (earnings) => {});\nclient.on(\"communityMarketing:claimed\", (claim) => {});\nclient.on(\"communityMarketing:withdrawn\", (voucher) => {});\nclient.on(\"communityMarketing:eligibilityChecked\", (verdict) => {});\n```\n\n⚠ `communityMarketing:withdrawn` fires when the **voucher is issued** — the pool\nis already debited, but nothing has reached the chain yet. It means \"payout\nstarted\", not \"money received\".\n\n⚠ `communityMarketing:claimed` fires on **any** successful claim — including\none that paid zero and one that was an idempotent replay. Check `Claimed` and\n`IdempotentReplay` before playing a reward animation.\n\n## A minimal screen\n\n```tsx\nconst [state, setState] = useState<CommunityMarketingState | null>(null);\nconst [earnings, setEarnings] = useState<CommunityEarningsResponse | null>(\n null,\n);\n\nuseEffect(() => {\n void (async () => {\n const s = await client.communityMarketing.getState();\n if (!isOk(s) || !s.data.IsEnabled) return;\n setState(s.data);\n\n // Earnings only make sense for someone actually in the programme.\n if (s.data.IsCreator) {\n const e = await client.communityMarketing.getEarnings();\n if (isOk(e)) setEarnings(e.data);\n }\n })();\n}, []);\n```\n\n- Not enabled → render nothing. A programme the publisher never set up should\n not leave a dead menu entry.\n- Enabled, not a creator → one button: open `CreatorPortalUrl`.\n- Creator → the four numbers (`Earned` / `Mature` / `Granted` / `Paid`), the\n breakdown, and per campaign either a Claim button (game currency) or a\n Withdraw-to-wallet button (crypto).\n\n## Do not\n\n- **Do not poll.** Metrics are sampled by a backend job on a slow schedule\n (hours, not seconds). Refresh on screen open and after a claim; a polling\n loop burns the publisher's API quota to re-read a number that did not move.\n- **Do not build `CreatorPortalUrl`.** It is a server contract; a locally\n assembled address silently 404s the day the route changes.\n- **Do not treat a rejected claim as a client bug.** Fund exhausted, hold\n period, per-creator cap, suspended participation — all of these are the\n backend enforcing the publisher's rules. Surface the message.\n- **Do not implement submission, moderation or metric reading.** They belong to\n the portal and the management backend, and a second implementation would be a\n second place able to create value.\n",
5
+ "references": []
6
+ }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "currency-system",
3
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",
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 *\nFee`; `net = sourceAmount - fee`; `output = net * rate`. Order matters for\n previews.\n- **`Fee` is a SHARE, not a percent**: `0.05` means 5%. This is the unit used\n across the whole platform (`RateSpec`), and the value is passed straight into\n `RateSpec.Rate` with no rescaling — there is no division by 100 anywhere. The\n field is deliberately not called `FeePercent`; the `Percent` suffix is banned\n platform-wide because it was the source of 0..1 vs 0..100 confusion. Show\n percents in a UI by multiplying by 100. A `Fee` of `1.0` is a deliberate ban\n on converting that currency, and is not clamped away.\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`, `Fee`, 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
5
  "references": [
6
6
  {
7
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, credited in BATCHES: every FULL `Period` seconds the\n // player gets `Rate` units at once, up to `Max`. Rate=5/Period=60 means \"+5 once a\n // minute\", NOT \"+1 every 12 seconds\" — an incomplete period credits nothing.\n // The batch is clipped exactly at `Max` (if less than Rate is missing, only the\n // remainder is credited); at or above `Max` nothing is credited.\n // `Max` is the auto-recharge cap only — explicit grants may exceed it, up to\n // Economy.MaxBalance.\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?:\n | \"Item\"\n | \"VirtualCurrency\"\n | \"CryptoCurrency\"\n | \"Purchase\"\n | \"RewardedVideoCredit\"; // ResourceEntryType\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 ProductID?: string; // set when Type is \"Purchase\": the IAP product that pays for this entry\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"
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, credited in BATCHES: every FULL `Period` seconds the\n // player gets `Rate` units at once, up to `Max`. Rate=5/Period=60 means \"+5 once a\n // minute\", NOT \"+1 every 12 seconds\" — an incomplete period credits nothing.\n // The batch is clipped exactly at `Max` (if less than Rate is missing, only the\n // remainder is credited); at or above `Max` nothing is credited.\n // `Max` is the auto-recharge cap only — explicit grants may exceed it, up to\n // Economy.MaxBalance.\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 MinWithdraw?: 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 Fee?: number; // SHARE of the debited amount: 0.05 = 5% (never 0..100)\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**: `Fee` is a SHARE (`0.05` = 5%), so `feeAmount = sourceAmount * Fee`\n — there is no division by 100. It is **not** clamped to a range: only `NaN`\n is treated as zero, and the fee is capped at the source amount itself. On the\n VC→VC path a fee below `1.0` additionally leaves at least one unit\n (`MaxPerPosition = sourceAmount - 1`), so that rounding alone can never make\n a small conversion impossible; `Fee = 1.0` is a deliberate ban and keeps its\n full effect. `netSource = sourceAmount - feeAmount`; `netSource <= 0` is\n 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?:\n | \"Item\"\n | \"VirtualCurrency\"\n | \"CryptoCurrency\"\n | \"Purchase\"\n | \"RewardedVideoCredit\"; // ResourceEntryType\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 ProductID?: string; // set when Type is \"Purchase\": the IAP product that pays for this entry\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
9
  }
10
10
  ]
11
11
  }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "deal-offer-system",
3
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, externalRefID?, options?)`)\n is \"the player performed this node's action right now\": pay its\n `Action.Purchase` cost (if `UseExternalRewards` is false and `PriceOptions` is\n set — `options.selectedOptionID` picks which way to pay, `options.payment`\n carries the store receipt when that option is paid in a store, which is the main\n monetization path of deal offers), 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",
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`), and they are a **real\n gate**: a non-root node with no runtime state yet is checked against them and\n plays if they are satisfied in this activation. `NextNodeIDs` remains the\n fast path — completing a node writes `Available` state to every ID it lists,\n unconditionally — but it is no longer the only way in. A non-root node that\n declares no rules at all is still refused (\"Node is locked\"): nothing could\n open it except a push. Still read\n `ActivationState.Nodes[nodeID].Status` from the server instead of unlocking\n locally — see [references/data-model.md](references/data-model.md) for the\n exact algorithm.\n- ⚠ **Slots, offers and nodes are decomposed into named blocks** — `Identity`, `Availability`,\n `Behaviour` on a slot; `Identity`, `Graph`, `Milestones` on an offer; `Identity`, `Pricing`,\n `Reward` on a node — and each block has its own independent preset binding. Presets are resolved\n SERVER-SIDE once when the config is materialised, so what you receive is already assembled.\n Practically this only changes where you read a field from: nodes are `offer.Graph.Nodes`, the\n price is `node.Pricing.Options`, the reward is `node.Reward.Grants`, the milestone ladder is\n `offer.Milestones.Milestones`. An absent block means \"not set\", which is a valid state — read\n through the documented defaults rather than assuming.\n- There is no declared \"graph mode\" field. The shape of an offer (single node,\n linear chain, branching, choice, metered) is whatever `RootNodeIDs`,\n `NextNodeIDs`, `ChoiceGroupID` and `UnlockRules` actually describe — derive\n it from the graph if your UI wants to label it, and it can never disagree\n with how the offer really behaves.\n- **Executing a node** (`executeNode(slotID, nodeID, externalRefID?, options?)`)\n is \"the player performed this node's action right now\": pay the node's\n `PriceOptions` cost — `options.selectedOptionID` picks which way to pay,\n `options.payment` carries the store receipt when that option is paid in a\n store, which is the main monetization path of deal offers — then the backend\n applies `Grants` (for `RewardedVideo` mid-sequence views only when\n `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; an explicit `Limits.PerActivationCap` on the node\nwins over both. `Limits.CooldownSeconds` (if set) rejects an early retry with\n`\"Node 'X' is on cooldown until <time>\"`.\n\n⚠ **The ad view is paid for with an ad credit, not asserted by the client.**\nAn ad node's price is an ordinary `PriceOptions` entry holding a\n`RewardedVideoCredit` cost, and only the server grants that credit — the\nAdvertising module does, on a view it confirmed itself. So the flow is: show\nthe ad through the Advertising module (which credits the player), then call\n`executeNode`, which spends the credit. There is no \"I watched it, trust me\"\nflag; a node whose credit balance is empty simply fails to pay.\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
5
  "references": [
6
6
  {
7
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 PriceOptions?: Record<string, PriceOption>; // ways to pay; 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** the selected option of `Action.Purchase.PriceOptions` has at\nleast one entry or event-token op in its `Cost.Standard`. 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). The option's `Cost.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-empty `PriceOptions` (the node's default option is used for the preview):\n\n- `Standard` — concatenation of every such node's default option `Cost.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"
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 Identity?: DealIdentity; // display name, description, SortOrder, tags, assets\n Queue?: DealSlotQueueEntry[]; // shown in ascending Order; loops after the last\n Availability?: { Schedule?: ScheduleSpec; Gate?: SegmentGate };\n Behaviour?: { AllowDismissSkip?: boolean; DismissSkipDelaySec?: number };\n Presets?: {\n Identity?: PresetBinding;\n Availability?: PresetBinding;\n Behaviour?: PresetBinding;\n };\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 Identity?: DealIdentity;\n Graph?: {\n RootNodeIDs?: string[]; // available immediately on a fresh activation\n Nodes?: DealNodeDefinition[];\n Tracks?: DealTrackDefinition[];\n ExhaustedWhenAllTerminalNodesCompleted?: boolean; // unset = true\n };\n Milestones?: {\n Milestones?: Record<string, MilestoneDefinition>; // key = MilestoneID; empty = no bar\n ClaimMode?: MilestoneClaimMode; // unset = \"Instant\"\n Token?: EventTokenDefinition; // caps for milestone points\n };\n Presets?: {\n Identity?: PresetBinding;\n Graph?: PresetBinding;\n Milestones?: PresetBinding;\n };\n}\n\n/** Shared identity block — used by slot, offer and node alike. */\ninterface DealIdentity {\n DisplayName?: string;\n Description?: string;\n SortOrder?: number; // CLIENT CONTRACT: the server never sorts anything\n Tags?: string[]; // presentation the engine never interprets\n AssetPaths?: Record<string, string>;\n CustomParams?: Record<string, string>;\n}\n```\n\nSource: `DealOfferDefinitions.cs`.\n\n⚠ **Slots, offers and nodes are decomposed into named BLOCKS**, each with its own independent\npreset binding into `DealOfferDefinitions.Presets` (a registry that mirrors the block names:\n`Identity`, `Availability`, `Behaviour`, `Graph`, `Milestones`, `Pricing`, `Reward`). Presets are\nresolved server-side once, when the config is materialised, so what the client receives is already\nassembled — you never merge anything yourself.\n\n⚠ Every block field is optional, and **\"unset\" is NOT the same as \"set to empty\"**: unset means\n\"inherit from the preset\", empty means \"final, inherit nothing\". Read a block through the defaults\nthe engine documents rather than assuming a value — an absent `Graph` is an offer with no nodes,\nnot a broken one.\n\n⚠ The milestone bar is ONE block on purpose. Presetting only the tier dictionary used to lose the\nclaim mode and the point caps silently, so a shared ladder came back as `Instant` and uncapped.\n\nThere is **no declared graph-mode field**. The shape of an offer (single node,\nlinear chain, branching, choice, metered) is whatever `RootNodeIDs`,\n`NextNodeIDs`, `UnlockRules` and `ChoiceGroupID` actually describe. Derive the\nlabel if your UI wants one; a declared mode could contradict how the offer\nreally behaves, which is why it was removed.\n\n`Enabled: false` on an offer blocks it only from being **newly activated** — it\ndoes not retroactively kill an activation already in progress. A disabled (or\nmissing) offer sitting in the middle of a slot's queue is **skipped**: the slot\nscans forward to the next playable entry instead of going empty. Earlier it\nzeroed the whole slot, which killed the slot permanently for everyone standing\non it — so disabling one offer is now a local edit, not a slot-wide outage.\n\n---\n\n## DealNodeDefinition + action params\n\n```ts\ninterface DealNodeDefinition {\n NodeID?: string;\n Identity?: DealIdentity; // display name, description, SortOrder, assets\n Type?: DealNodeType; // default \"Purchase\"\n Pricing?: { Options?: Record<string, PriceOption> }; // ways to pay; empty/absent = free\n Action?: DealNodeActionDefinition;\n Reward?: {\n Grants?: ResourceGrant; // paid out on node completion\n TrackChanges?: DealTrackChange[]; // applied on node completion\n MilestonePoints?: number; // unset = 0; points into the offer milestone bar per execution\n };\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 RewardedVideo?: DealRewardedVideoActionDefinition; // populated iff Type === \"RewardedVideo\"\n}\n\n// Progression of an ad node only — the PRICE is not here, it is the node's own\n// PriceOptions, and the pause between views is the shared Limits.CooldownSeconds.\ninterface DealRewardedVideoActionDefinition {\n ViewsRequiredToComplete?: number; // default 1\n MaxViewsPerActivation?: number; // default 1; 0 = unlimited\n GrantRewardsPerView?: boolean; // default false — see grant rules below\n}\n```\n\nSource: `DealOfferDefinitions.cs`.\n\n**Price lives on the node, for every node type.** A node paid in resources, one\npaid in a store product, one paid with an ad credit and a free one differ only\nby what is inside `PriceOptions` — there is no separate purchase block and no\nflag that switches charging off. An unset/empty `PriceOptions` means free.\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** — the selected option of the node's own `PriceOptions`, whatever the\nnode's `Type` is; if it has no entry and no event-token op in its\n`Cost.Standard`, the execution is simply free. The option's\n`Cost.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** — always paid out on the call that completes the node. The one\noverride: 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\nA node becomes playable one of two ways, and both are real:\n\n- A **root node** (`RootNodeIDs`) is playable the instant the offer activates —\n no runtime `UserDealNodeState` entry needed.\n- A node **pushed** by another node's `NextNodeIDs`: on completion the engine\n writes `Available` state for every ID listed there, unconditionally (it does\n not re-check that node's own `UnlockRules` at that point). Track thresholds\n do the same through `GetNodesUnlockedByTrack`, which is evaluated for every\n node in the offer whenever a track changes, regardless of `NextNodeIDs`\n membership.\n- A node with **no runtime state and no push** is now evaluated against its own\n `UnlockRules`: `RequiredCompletedNodeIDs` (all must be completed in this\n activation), `RequiredAnyCompletedNodeIDs` (at least one) and\n `RequiredTracks` (all thresholds met). Satisfied — it plays; not satisfied —\n it is refused with `\"... is locked (prerequisite nodes not completed)\"` or\n `\"... (track requirements not met)\"`. A non-root node that declares **no\n rules at all** is still refused: nothing could ever open it except a push.\n\n⚠ This is a behaviour change. Previously the rules were only tested for\nnon-emptiness, so a node that declared any prerequisite was locked **forever**;\noffers built on `RequiredCompletedNodeIDs` without a matching `NextNodeIDs`\nedge simply did not work. If you designed around that, re-check your graphs.\n\n⚠ A node whose status is `Hidden` counts as **completed** for prerequisite\npurposes — `HideWhenCompleted` sets that status after the node was played, and\nnot counting it would lock every node whose prerequisite is configured to hide\nitself. A missing track counts as `0`, its starting value.\n\nOnce a runtime state exists, its `Status` is the authority and `UnlockRules`\nare not consulted again.\n\n**Practical takeaway for the client:** still read\n`ActivationState.Nodes[nodeID].Status` from the server rather than unlocking\nthings locally — but `UnlockRules` are now a genuine gate, not just a UI hint.\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 — **unless the node declares its own non-zero\n `Limits.PerActivationCap`, which wins**. `TotalCap`/`DailyCap` pass through\n from `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 node with `Limits.CooldownSeconds > 0` additionally stamps\n`NextAvailableAtUtc` after each execution; a call before that time returns\n`\"Node 'X' is on cooldown until <ISO time>\"`. (This is the shared `LimitSpec`\naxis — ad nodes no longer carry a cooldown field of their own.)\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 the milestone block's `Token`\n `DailyEarnCap`/`MaxBalance`/`MaxPerGrant` — passed as an\n `EventTokenGrantContext`, `BuildMilestonePointsContext` lines 181-191; `null`\n `Token` 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`) is a single\naggregated `ResourceConsume` built by scanning every node in the offer that has\na non-empty `PriceOptions`, whatever its `Type` (the node's **default** option\nis used for the preview):\n\n⚠ The preview uses the default option, but execution charges the **selected**\none. In a title with per-platform prices those two differ by construction —\nthe slot summary has neither a player nor a platform — so treat `Cost` as an\nindication and read the exact price from the execute response.\n\n- `Standard` — concatenation of every such node's default option `Cost.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
9
  }
10
10
  ]
11
11
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "push-notifications",
3
+ "description": "Add push notifications to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.push (PushService): ask the player for permission from a tap, register the browser's Web Push subscription with the server, draw the right button state, list and remove the player's devices, and ship the service worker that displays a notification. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants push notifications, browser notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a re-engagement or comeback reminder, an energy-refilled or build-finished alert, a service worker, sw.js, VAPID, PushManager, Notification.permission, or otherwise touches client.push, PushService, PushConfigResponse, PushSubscriptionView or PushPermissionState — even if they don't name the module explicitly.",
4
+ "content": "---\nname: push-notifications\ndescription: >-\n Add push notifications to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.push (PushService): ask the player for\n permission from a tap, register the browser's Web Push subscription with the\n server, draw the right button state, list and remove the player's devices,\n and ship the service worker that displays a notification. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg, voxelcraft) and wants push notifications, browser\n notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a\n re-engagement or comeback reminder, an energy-refilled or build-finished\n alert, a service worker, sw.js, VAPID, PushManager, Notification.permission,\n or otherwise touches client.push, PushService, PushConfigResponse,\n PushSubscriptionView or PushPermissionState — even if they don't name the\n module explicitly.\n---\n\n# Push notifications (iDosGames TS SDK)\n\nPush reaches a player who is **not in the game**. That single fact shapes the\nwhole module: the text is resolved on the server (nobody is around to ask what\nlanguage to use), the sending is done by the backend from a queue, and the only\nthing the game does is get the browser registered and ship a service worker\nthat displays what arrives.\n\nThis skill is for **using** the production `PushService`. A refusal is almost\nalways the browser or the publisher's config, not a bug to work around.\n\n## The one rule that outranks everything else here\n\n**`client.push.subscribe()` runs from a real tap. Never on load, never in a\n`useEffect`, never \"just to check\".**\n\nBrowsers reject `Notification.requestPermission()` outside a user gesture, and\nChrome **permanently blocks an origin** after a few dismissals. So an automatic\nprompt does not merely fail — it takes away the player's ability to ever say\nyes, and nothing in your code can undo that afterwards. Only the person, in\ntheir browser's site settings.\n\nAsk when the player has just been given a reason to want it (\"tell me when my\nenergy is full\"), not when the game starts.\n\n## Drawing the button\n\n```ts\nimport { PushPermissionState } from \"@idosgames/core\";\n\nconst state = await client.push.getState();\n```\n\nFive values, and **four of them mean \"no button\"** — for different reasons:\n\n| State | What it means | What to draw |\n| ------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| `idle` | Available, nobody asked yet | **The button.** This is the only state that gets one |\n| `subscribed` | This browser is registered | \"Notifications on\", plus a way to turn them off |\n| `blocked` | The person said no, or Chrome said it for them | Nothing, or a line explaining that browser settings can undo it. **Do not offer to retry** |\n| `unsupported` | No Push API, or the publisher has not enabled the feature | Nothing at all |\n| `embedded` | Running inside the portal iframe | Nothing — the site owns this, see below |\n\n`unsupported` deliberately does not distinguish \"the platform has no keys\" from\n\"the publisher did not opt in\": in both cases there must be no button, and a\nfeature that cannot work has to be invisible rather than broken.\n\n## Subscribing\n\n```ts\nasync function onEnableNotificationsTapped() {\n const result = await client.push.subscribe();\n if (!result.ok) return showToast(result.error.message);\n\n // \"portal\" means the WEBSITE subscribed on the player's behalf — see below.\n showToast(result.data.Where === \"portal\" ? \"Notifications on\" : \"All set\");\n}\n```\n\nIdempotent: a browser that is already subscribed re-sends the same endpoint,\nwhich refreshes the row's language and timezone instead of creating a second\none. Calling it twice is harmless.\n\n`subscribe()` registers `./sw.js` by default — **relative on purpose**. A hosted\nbuild lives at `cloud.idosgames.com/drive/app/{titleID}/`, so a leading slash\nwould point at another game's folder, and the relative form also scopes the\nregistration to exactly this game's directory. That scope is what gives each\ngame its own subscription on a shared origin. Pass `swPath` only if your worker\nlives somewhere else.\n\n## ⚠⚠ Inside the portal it works differently, and that is not fixable\n\nA game opened on `idosgames.com/app/{id}` runs in a **cross-origin iframe**,\nwhere the browser blocks the permission prompt outright. Not a policy anyone can\nrelax — it is how permissions work.\n\nSo there `subscribe()` asks the **website** to do it, on its own top-level\ndocument. The site creates the row against the player's platform identity, the\n**engine still does the sending**, and the player's game id reaches that row on\ntheir next platform sign-in. `result.data.Where` tells you which happened; a\nportal subscription is real, it just does not belong to the game's origin, and\n`Subscription` comes back `null` because the game never sees its keys.\n\nConsequences for your UI: `getState()` returns `embedded` there, so the button\nis hidden by default. If you want an \"enable notifications\" affordance in the\nportal too, call `subscribe()` from a tap anyway and branch on the result — but\ndo not try to read `Notification.permission` or `pushManager`, which describe\nthe iframe, not the page the player is looking at.\n\n## The service worker\n\n`templates/host-starter/public/sw.js` ships as a starting point you own. Two\nthings in it are not negotiable:\n\n1. **⚠⚠ No `fetch` handler. Ever.** A caching worker looks like a free win and\n is the most expensive mistake available here: a cached `index.html` points at\n content-hashed chunks that the next deploy deletes, and the player gets a\n white screen served from _inside their own browser_, where neither a CDN\n purge nor Ctrl+F5 reaches. The platform already paid for that bug once in the\n publisher dashboard. If you want offline support, do it deliberately and\n never cache the document.\n2. **Always call `showNotification`.** The subscription was created with\n `userVisibleOnly: true`, which is a promise to display every message. Break it\n and the browser first warns the player about background activity, then revokes\n the permission.\n\nThe payload the server sends is small and already localised:\n`{ title, body?, url?, icon?, tag? }`. Do not translate it in the worker — the\ntext was resolved into the **device's** language at send time, using the locale\ncaptured when the subscription was created.\n\n## Managing devices\n\n```ts\nconst list = await client.push.getSubscriptions(); // every device, this title\nawait client.push.unsubscribeByHash(row.EndpointHash); // remove one of them\nawait client.push.unsubscribe(); // remove THIS browser\n```\n\nA subscription belongs to a **(browser, registration)** pair, not to an account:\none person's phone, laptop and portal tab are three separate rows. Clearing site\ndata destroys one silently, with no event — which is why `getState()` asks the\nregistration rather than trusting `Notification.permission`, and why every call\nhere is idempotent.\n\n`unsubscribe()` does both halves — the browser and the server — and both matter:\ndropping only the server row leaves a browser holding a live endpoint, and\ndropping only the browser one leaves a row that keeps being sent to until a push\nservice answers `410`.\n\n## Things not to do\n\n- **Do not compute `EndpointHash`.** It is `sha256(endpoint)` and trivial to\n reproduce — and it addresses a shared database that already has two\n implementations guarded by golden vectors. A third one, in another language\n and without those vectors, drifts silently and leaves the player looking at a\n button in the wrong state. Use the value the server returned.\n- **Do not bake the VAPID key into the build.** It arrives from `getConfig()`\n because it is a platform key shared by both backends; baking it in means a key\n rotation requires rebuilding every game.\n- **Do not poll `getState()`.** Nothing changes it except the player, in a\n prompt you started.\n- **Do not send notifications from the client.** There is no such call and there\n will not be one: producing a notification is a server concern, gated by the\n publisher's per-player and per-title daily caps and quiet hours. The publisher\n API quota does not even see sends — those caps are the only ceiling there is.\n- **Do not ask again after `blocked`.** There is nothing to ask.\n\n## What the publisher controls (and you cannot)\n\nIn the title config, under `Push`: the master switch (**off by default** — this\nspends a real person's attention), a per-player daily cap, a title-wide daily\ncap, quiet hours, and a default icon. Quiet hours are evaluated in the\n**device's** timezone, captured at subscribe time and separate from the\nlanguage — Portuguese is spoken in Lisbon and in São Paulo.\n\nNone of this is visible to the game. A notification that was capped or fell into\nquiet hours simply never arrives, and there is no client-side signal for it.\n",
5
+ "references": []
6
+ }