@idosgames/mcp 0.1.4 → 0.1.6

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.
@@ -5,7 +5,7 @@
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\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n MinDeposit?: string;\n MinWithdraw?: string;\n WithdrawFee?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nDeposit/withdrawal flows (and `Networks`/`Limits` enforcement for those flows)\nbelong to the Blockchain module — see the blockchain-system skill.\n`Permissions.ConvertibleToVirtual` is the flag most relevant here: it's what\nlets a crypto balance participate in `cryptoConvert`.\n\n---\n\n## Shared conversion config\n\nBoth currency kinds reuse the same `CurrencyConversion` shape for their\n`Conversion` field:\n\n```ts\ninterface CurrencyConversion {\n Enabled?: boolean;\n RateMode?: \"Automatic\" | \"Manual\";\n FeePercent?: string; // decimal string\n Targets?: ConversionTarget[]; // whitelist of valid conversion targets\n}\n\ninterface ConversionTarget {\n TargetCurrencyType?: \"Virtual\" | \"Crypto\";\n TargetCurrencyID: string;\n Rate?: string; // decimal string; used when RateMode is \"Manual\"\n MinAmount?: number;\n MaxAmount?: number;\n DailyLimit?: number;\n}\n```\n\nA conversion is only accepted if the source currency's `Conversion.Enabled`\nis true and the target appears in `Targets` (by type + id). `\"Automatic\"`\nrate mode means the backend derives the rate from each side's `ValueInUSD`;\n`\"Manual\"` uses the `Rate` pinned on the `ConversionTarget`. Either way, treat\n`RateApplied` on the response as the source of truth — don't recompute it.\n\n---\n\n## Backend conversion formulas\n\nTranscribed from `ConversionService.ConvertAsync` /\n`ConversionService.ConvertCryptoAsync` in the backend\n(`IDosGamesSDK/API/Client/v2/Currency/Services/ConversionService.cs`). These\nare enforced server-side; the SDK never recomputes them — use this section\nonly for building accurate cost/reward **previews**, not for validating a\nconversion before sending it.\n\n**Order of checks** (any failure short-circuits, no partial debit):\n\n1. Basic shape: non-empty `SourceID`/`TargetID`, positive amount, source ≠\n target.\n2. `Convert` (VC↔VC) rejects a `Crypto` source outright (\"Convert supports\n VC↔VC only\") and a `Crypto` target outright (\"Virtual→Crypto conversion is\n not supported\"). `CryptoConvert` rejects a non-`Crypto` source outright\n (\"CryptoConvert requires source to be Crypto\").\n3. **Status**: source or target `Maintenance` → rejected on that side (\"is\n under maintenance\"). Target (only) `Deprecated` → rejected (\"is deprecated\n and cannot receive new credits\"). A `Deprecated` **source** is allowed —\n deprecating a currency only stops new inflow, it doesn't trap the player's\n remaining balance.\n4. Source's `Conversion` must be non-null and `Enabled`, and must have a\n `Targets` entry matching `(TargetCurrencyType, TargetCurrencyID)` exactly —\n otherwise \"Conversion from 'X' to 'Y' is not allowed.\"\n5. Crypto-source → Virtual-target additionally requires\n `CryptoCurrencyPermissions.ConvertibleToVirtual` — false rejects even a\n listed target.\n6. Per-pair `MinAmount`/`MaxAmount` on the matched `ConversionTarget`, checked\n against the raw source amount before fee.\n7. **Rate resolution**:\n - `RateMode = Manual` → `rate = ConversionTarget.Rate`; a pair configured\n Manual with no `Rate` set is rejected (\"Manual conversion rate is not set\n for this pair\"), not treated as 0 or 1.\n - `RateMode = Automatic` → `rate = source.ValueInUSD / target.ValueInUSD`.\n Either side missing/zero `ValueInUSD` rejects the conversion (\"Automatic\n rate cannot be computed\").\n8. **Fee**: `FeePercent` is clamped to `[0, 100]` defensively, then\n `feeAmount = sourceAmount * FeePercent / 100`; `netSource = sourceAmount -\nfeeAmount`. `netSource <= 0` is rejected.\n9. **Output**: `output = netSource * rate`.\n - VC↔VC (`ConvertAsync`): `output` is cast straight to `long`, i.e.\n **truncated toward zero**. `output <= 0` after truncation is rejected\n (\"Resulting target amount is zero\").\n - Crypto-source (`ConvertCryptoAsync`): `output` stays a full-precision\n `decimal` if the target is `Crypto`. If the target is `Virtual`, it is\n **floored** (`Math.Floor`) to a `long` before the zero-check.\n10. Per-pair `DailyLimit` on the matched `ConversionTarget`: today's\n already-converted amount for this exact `(SourceType:SourceID ->\nTargetType:TargetID)` pair (tracked server-side per UTC day; not exposed\n to the client) plus this operation's raw source amount must not exceed\n it.\n11. The debit/credit itself runs through `ResourceService\n.ApplyResourceOperationAtomicAsync`, which additionally enforces the\n source currency's own `Economy.MinBalance`/`MaxBalance` (for VC) and\n `Economy.DailyEarnLimit`/`DailySpendLimit` — a **second, independent**\n limit layer scoped to the whole currency rather than this one pair. A\n crypto source's live balance is checked directly against\n `InventoryV2.CryptoCurrencies[id].Amount` before the debit.\n\n**Practical takeaway**: two conversions that look identical (same pair, same\namount) can differ in outcome depending on how much of the _daily_ pair\nallowance or the _daily_ currency-wide allowance is already used — always\nrender the server's `error` rather than trying to precompute eligibility.\n\n---\n\n## The shared resource primitives\n\nThis is the **canonical documentation** for these types — every other module\nskill (Store, Character, Craft, Lootbox, Blockchain, …) links here instead of\nredefining them. They describe \"spend this, receive that\" in one uniform\nshape used for offer costs, upgrade costs, craft inputs/outputs, lootbox\nprices/rewards, and blockchain deposit/withdrawal resource deltas.\n\n### ResourceEntry\n\nThe atomic unit: one currency or item quantity.\n\n```ts\ninterface ResourceEntry {\n Type?: \"Item\" | \"VirtualCurrency\" | \"CryptoCurrency\" | \"UsdCent\"; // ResourceEntryType — exactly these 4\n CurrencyID?: string; // set when Type is a currency kind\n Amount?: number; // integer amount; C# `long` on the wire, parsed via zVcAmount (exact up to 2^53-1)\n CatalogID?: string; // set when Type is \"Item\": which catalog\n ItemID?: string; // set when Type is \"Item\": which item definition\n}\n```\n\nOnly the fields relevant to `Type` are populated — e.g. a `VirtualCurrency`\nentry sets `CurrencyID` + `Amount` and leaves `CatalogID`/`ItemID` unset; an\n`Item` entry sets `CatalogID`/`ItemID` (+ `Amount` for stackable quantity) and\nleaves `CurrencyID` unset.\n\n### ResourceBundle\n\nA flat list of entries, plus optional event-token deltas:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[] | null;\n EventTokens?: EventTokenOperation[] | null;\n}\n```\n\n### PremiumTierBundle\n\nAn alternate bundle that only applies if the player holds a qualifying\npremium tier — used inside `ResourceGrant`/`ResourceConsume` to express\n\"VIPs get a better grant / a cheaper cost.\"\n\n```ts\ninterface PremiumTierBundle {\n MinPremiumTier?: number;\n RequiredPremiumID?: string;\n Resources?: ResourceBundle | null;\n}\n```\n\n### ResourceGrant\n\nWhat a player receives.\n\n```ts\ninterface ResourceGrant {\n Standard?: ResourceBundle | null; // baseline grant, always applies\n PremiumBonuses?: unknown[] | null; // reserved/opaque bonus list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated additional/alternate grants\n}\n```\n\n### ResourceConsume\n\nWhat a player is charged. Mirror-shaped to `ResourceGrant`, but the\ntier-based array is a **discount** mechanism rather than a bonus one — see\nGotchas below.\n\n```ts\ninterface ResourceConsume {\n Standard?: ResourceBundle | null; // baseline cost\n PremiumDiscounts?: unknown[] | null; // reserved/opaque discount list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated reduced/alternate cost\n}\n```\n\n### ResourceOperation\n\nThe full manifest for one action: what's granted and what's consumed. This is\nthe shape every \"did an action succeed\" response embeds under a `Resources`\nfield (e.g. `DepositNFTResponse.Resources`, `NFTWithdrawalResponse.Resources`\nin Blockchain).\n\n```ts\ninterface ResourceOperation {\n Grant?: ResourceGrant | null;\n Consume?: ResourceConsume | null;\n}\n```\n\nEither side can be `null`/absent — a pure grant (no cost) sets only `Grant`;\na pure charge (no payout) sets only `Consume`.\n\n### EventTokenOperation / EventTokenAddress\n\nEvent tokens are a lighter-weight counter mechanic (e.g. season/event\ncurrency) addressed by an entity rather than a flat `CurrencyID`:\n\n```ts\ninterface EventTokenAddress {\n Type?: string; // EventTokenType — which kind of entity owns the token bucket\n EntityID: string;\n}\n\ninterface EventTokenOperation {\n Address?: EventTokenAddress | null;\n Amount?: number;\n Source?: string; // free-form provenance tag\n}\n```\n\n### ResourceDualPartyResult / ResourceTransferResult\n\nUsed by PvP/transfer-style features where two accounts are affected by one\naction:\n\n```ts\n// Each side gets its own independent grant/consume manifest.\ninterface ResourceDualPartyResult {\n FromUserID?: string;\n ToUserID?: string;\n FromResult?: ResourceOperation | null;\n ToResult?: ResourceOperation | null;\n}\n\n// A straight transfer: one bundle moves from one account to another.\ninterface ResourceTransferResult {\n FromUserID?: string;\n ToUserID?: string;\n Transferred?: ResourceBundle | null;\n}\n```\n\n---\n\n## How the SDK applies a ResourceOperation\n\nEvery module that returns a `ResourceOperation` (directly, or via a\n`Resources` field) has already had it **applied server-side**; the SDK's job\nis only to mirror it into the local cache so balances/inventory read\ncorrectly without a re-fetch. Internally this goes through\n`UserData.applyResourceOperation(op, itemDefs)`, which:\n\n- walks `Consume.Standard.Entries` and `Grant.Standard.Entries` (the\n `PremiumDiscounts`/`PremiumTiers`/`PremiumBonuses` arrays describe _why_ the\n standard amount is what it is — the server has already resolved them into\n `Standard` before sending the response; the client does not re-apply tiers),\n- for `VirtualCurrency` entries, adjusts the integer balance and emits\n `user:virtualCurrencyUpdated`,\n- for `Item` entries, adjusts stackable counts / creates unstackable instances\n and emits `user:inventoryUpdated`,\n- for `EventTokens`, adjusts the addressed token bucket and emits\n `user:eventTokenUpdated`,\n- always emits the umbrella `user:anyUpdated` when anything changed.\n\n`CryptoCurrency` amounts do **not** flow through this integer pipeline —\nthey're decimal and go through a separate patch\n(`UserData.patchCryptoCurrencyDelta(currencyID, delta, serverTimeUtc)`), which\nis what `CurrencyService.cryptoConvert` and the Blockchain deposit/withdrawal\nmethods use directly instead of embedding crypto deltas in a\n`ResourceOperation`.\n\n**Practical takeaway when building UI in any module:** don't hand-roll cost\npreviews from `PremiumDiscounts`/`PremiumTiers` internals unless you're\nexplicitly building a \"your VIP tier saves you N%\" comparison — for \"what will\nthis cost me right now,\" prefer the value the server already resolved\n(`Standard`, or the flat response fields like `ConvertResponse.SourceSpent`).\nTreat `Amount` on VC entries as a signed integer conceptually (consume vs.\ngrant is which container it's in, not a negative number) and crypto strings as\nopaque decimal values to hand to `decimal.js`, not to parse with `Number()`\nonce you're near precision limits.\n"
8
+ "content": "# Currency data model — reference\n\nFull shape of the `CurrencyDefinitions` config, and — the canonical\ndocumentation for the whole SDK — the shared `ResourceConsume` /\n`ResourceGrant` / `ResourceOperation` / `ResourceEntry` cost-and-reward\nprimitives. All types are **strictly typed** and exported from\n`@idosgames/core`; every object schema keeps `.passthrough()`, so a field the\nbackend adds later still round-trips instead of being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CurrencyDefinitions](#config-currencydefinitions)\n- [VirtualCurrencyDefinition](#virtualcurrencydefinition)\n- [CryptoCurrencyDefinition](#cryptocurrencydefinition)\n- [Shared conversion config](#shared-conversion-config)\n- [Backend conversion formulas](#backend-conversion-formulas) — transcribed from `ConversionService.cs`\n- [The shared resource primitives](#the-shared-resource-primitives) — canonical home\n - [ResourceEntry](#resourceentry)\n - [ResourceBundle](#resourcebundle)\n - [PremiumTierBundle](#premiumtierbundle)\n - [ResourceGrant](#resourcegrant)\n - [ResourceConsume](#resourceconsume)\n - [ResourceOperation](#resourceoperation)\n - [EventTokenOperation / EventTokenAddress](#eventtokenoperation--eventtokenaddress)\n - [ResourceDualPartyResult / ResourceTransferResult](#resourcedualpartyresult--resourcetransferresult)\n- [How the SDK applies a ResourceOperation](#how-the-sdk-applies-a-resourceoperation)\n\n---\n\n## Config: CurrencyDefinitions\n\n```ts\ninterface CurrencyDefinitions {\n VirtualCurrencies?: Record<string, VirtualCurrencyDefinition> | null;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition> | null;\n}\n```\n\nKey in both maps is the `CurrencyID`. A currency is \"known\" iff it has an\nentry in one of these maps under its `CurrencyType` (`Virtual` or `Crypto`).\n\n---\n\n## VirtualCurrencyDefinition\n\n```ts\ninterface VirtualCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>; // \"icon\", ...\n Economy?: {\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string; // ISO timestamp\n InitialDeposit?: number; // starting balance for new players\n MinBalance?: number;\n MaxBalance?: number;\n DailyEarnLimit?: number;\n DailySpendLimit?: number;\n };\n Recharge?: {\n // energy-style auto-regen\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n MinDeposit?: string;\n MinWithdraw?: string;\n WithdrawFee?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nDeposit/withdrawal flows (and `Networks`/`Limits` enforcement for those flows)\nbelong to the Blockchain module — see the blockchain-system skill.\n`Permissions.ConvertibleToVirtual` is the flag most relevant here: it's what\nlets a crypto balance participate in `cryptoConvert`.\n\n---\n\n## Shared conversion config\n\nBoth currency kinds reuse the same `CurrencyConversion` shape for their\n`Conversion` field:\n\n```ts\ninterface CurrencyConversion {\n Enabled?: boolean;\n RateMode?: \"Automatic\" | \"Manual\";\n FeePercent?: string; // decimal string\n Targets?: ConversionTarget[]; // whitelist of valid conversion targets\n}\n\ninterface ConversionTarget {\n TargetCurrencyType?: \"Virtual\" | \"Crypto\";\n TargetCurrencyID: string;\n Rate?: string; // decimal string; used when RateMode is \"Manual\"\n MinAmount?: number;\n MaxAmount?: number;\n DailyLimit?: number;\n}\n```\n\nA conversion is only accepted if the source currency's `Conversion.Enabled`\nis true and the target appears in `Targets` (by type + id). `\"Automatic\"`\nrate mode means the backend derives the rate from each side's `ValueInUSD`;\n`\"Manual\"` uses the `Rate` pinned on the `ConversionTarget`. Either way, treat\n`RateApplied` on the response as the source of truth — don't recompute it.\n\n---\n\n## Backend conversion formulas\n\nTranscribed from `ConversionService.ConvertAsync` /\n`ConversionService.ConvertCryptoAsync` in the backend\n(`IDosGamesSDK/API/Client/v2/Currency/Services/ConversionService.cs`). These\nare enforced server-side; the SDK never recomputes them — use this section\nonly for building accurate cost/reward **previews**, not for validating a\nconversion before sending it.\n\n**Order of checks** (any failure short-circuits, no partial debit):\n\n1. Basic shape: non-empty `SourceID`/`TargetID`, positive amount, source ≠\n target.\n2. `Convert` (VC↔VC) rejects a `Crypto` source outright (\"Convert supports\n VC↔VC only\") and a `Crypto` target outright (\"Virtual→Crypto conversion is\n not supported\"). `CryptoConvert` rejects a non-`Crypto` source outright\n (\"CryptoConvert requires source to be Crypto\").\n3. **Status**: source or target `Maintenance` → rejected on that side (\"is\n under maintenance\"). Target (only) `Deprecated` → rejected (\"is deprecated\n and cannot receive new credits\"). A `Deprecated` **source** is allowed —\n deprecating a currency only stops new inflow, it doesn't trap the player's\n remaining balance.\n4. Source's `Conversion` must be non-null and `Enabled`, and must have a\n `Targets` entry matching `(TargetCurrencyType, TargetCurrencyID)` exactly —\n otherwise \"Conversion from 'X' to 'Y' is not allowed.\"\n5. Crypto-source → Virtual-target additionally requires\n `CryptoCurrencyPermissions.ConvertibleToVirtual` — false rejects even a\n listed target.\n6. Per-pair `MinAmount`/`MaxAmount` on the matched `ConversionTarget`, checked\n against the raw source amount before fee.\n7. **Rate resolution**:\n - `RateMode = Manual` → `rate = ConversionTarget.Rate`; a pair configured\n Manual with no `Rate` set is rejected (\"Manual conversion rate is not set\n for this pair\"), not treated as 0 or 1.\n - `RateMode = Automatic` → `rate = source.ValueInUSD / target.ValueInUSD`.\n Either side missing/zero `ValueInUSD` rejects the conversion (\"Automatic\n rate cannot be computed\").\n8. **Fee**: `FeePercent` is clamped to `[0, 100]` defensively, then\n `feeAmount = sourceAmount * FeePercent / 100`; `netSource = sourceAmount -\nfeeAmount`. `netSource <= 0` is rejected.\n9. **Output**: `output = netSource * rate`.\n - VC↔VC (`ConvertAsync`): `output` is cast straight to `long`, i.e.\n **truncated toward zero**. `output <= 0` after truncation is rejected\n (\"Resulting target amount is zero\").\n - Crypto-source (`ConvertCryptoAsync`): `output` stays a full-precision\n `decimal` if the target is `Crypto`. If the target is `Virtual`, it is\n **floored** (`Math.Floor`) to a `long` before the zero-check.\n10. Per-pair `DailyLimit` on the matched `ConversionTarget`: today's\n already-converted amount for this exact `(SourceType:SourceID ->\nTargetType:TargetID)` pair (tracked server-side per UTC day; not exposed\n to the client) plus this operation's raw source amount must not exceed\n it.\n11. The debit/credit itself runs through `ResourceService\n.ApplyResourceOperationAtomicAsync`, which additionally enforces the\n source currency's own `Economy.MinBalance`/`MaxBalance` (for VC) and\n `Economy.DailyEarnLimit`/`DailySpendLimit` — a **second, independent**\n limit layer scoped to the whole currency rather than this one pair. A\n crypto source's live balance is checked directly against\n `InventoryV2.CryptoCurrencies[id].Amount` before the debit.\n\n**Practical takeaway**: two conversions that look identical (same pair, same\namount) can differ in outcome depending on how much of the _daily_ pair\nallowance or the _daily_ currency-wide allowance is already used — always\nrender the server's `error` rather than trying to precompute eligibility.\n\n---\n\n## The shared resource primitives\n\nThis is the **canonical documentation** for these types — every other module\nskill (Store, Character, Craft, Lootbox, Blockchain, …) links here instead of\nredefining them. They describe \"spend this, receive that\" in one uniform\nshape used for offer costs, upgrade costs, craft inputs/outputs, lootbox\nprices/rewards, and blockchain deposit/withdrawal resource deltas.\n\n### ResourceEntry\n\nThe atomic unit: one currency or item quantity.\n\n```ts\ninterface ResourceEntry {\n Type?:\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)`) is \"the player\n performed this node's action right now\": pay its `Action.Purchase` cost (if\n `UseExternalRewards` is false and a `DirectCost` is set), or register a\n `RewardedVideo` view, or acknowledge a `FreeClaim`/`Info` node — then the\n backend applies `Grants` (skipped for `Purchase` nodes with\n `UseExternalRewards: true`, and for `RewardedVideo` mid-sequence views\n unless `GrantRewardsPerView` is true), applies `TrackChanges`, adds\n `MilestonePoints` to the offer's milestone bar, and marks the node\n `Completed` once its execution count reaches the required amount (1 by\n default; `Limits.PerActivationCap` for ordinary nodes,\n `Action.RewardedVideo.ViewsRequiredToComplete` for ad nodes). The response\n tells you `NodeCompleted` (this call finished the node) and\n `OfferExhausted` (this completion ended the whole activation, e.g. via\n `ExhaustOfferOnComplete` or all terminal nodes now being complete).\n- **Tracks** (`DealTrackDefinition`) are small offer-local counters (e.g.\n \"shells collected this activation\") that nodes write via `TrackChanges` and\n that can independently unlock nodes whose `UnlockRules.RequiredTracks`\n threshold is crossed — a lightweight in-offer state machine layered on top\n of node completion, reset to `StartValue` every fresh activation.\n- **Milestones** are a separate reward ladder over `MilestonePoints` earned\n from nodes in this same activation — see `claimMilestone` below.\n\nIn short: a **slot** shows one **offer** at a time; an **offer** is a graph of\n**nodes**; executing a node pays/claims that node and can unlock further\nnodes via `NextNodeIDs` (or via a track threshold); enough node completions\ncan exhaust the offer and/or clear milestone thresholds. Always read the\nreturned `DealNodeRuntimeStatus` per node (`Locked | Available | InProgress |\nCompleted | Hidden`) as ground truth rather than computing it yourself.\n\nFor the full field-by-field shape of slots, offers, nodes, tracks,\nmilestones, runtime state, the exact unlock algorithm, the milestone\naddressing/reward math, and slot-resolution/schedule-mode rules, read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config (progress bars,\nlocked/available badges, choice-group rendering).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst deals = client.dealOffer; // the DealOfferService\n```\n\nEvery deal-offer method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason, e.g. `\"Node 'X' is\nlocked\"`, `\"Node 'X' is already completed\"`, `\"Deal in slot 'Y' has\nexpired\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| --------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |\n| `getDefinition()` | Load the title's slot + offer catalog (config). | `DealOffersDefinitionResponse` (`DealOfferDefinitions`) |\n| `getUserState()` | Load this player's raw deal-offer state (all slots + history). | `UserDealOffersStateResponse` (`DealOffers`) |\n| `getActiveDeals()` | Load the resolved, ready-to-render active offer per slot. | `GetActiveDealsResponse` (`Slots: ActiveDealSlotInfo[]`) |\n| `dismissDeal(slotID)` | Dismiss the active offer in a slot before finishing it. | `DismissDealResponse` |\n| `executeNode(slotID, nodeID, externalRefID?)` | Perform one node's action (purchase / claim / ad view / ack info). | `ExecuteNodeResponse` (`NodeCompleted`, `OfferExhausted`, `Idempotent`) |\n| `recordShow(slotID)` | Record an impression (the offer popup was shown to the player). | `RecordShowResponse` |\n| `claimMilestone(slotID, milestoneID)` | Claim one reached-and-unclaimed milestone reward. | `ClaimDealMilestoneResponse` |\n| `claimMilestonesBatch(slotID, milestoneIDs)` | Claim many milestones for one slot's active offer in one call. | `ClaimDealMilestonesBatchResponse` (`ClaimedIDs`, `Rejected`) |\n\n`getActiveDeals()` is the one to render a deal popup/carousel from directly —\neach `ActiveDealSlotInfo` bundles the slot's offer definition (`OfferDef`),\nthe player's runtime progress on it (`ActivationState`), whether this is a\nfreshly-started activation (`IsNewActivation`), a computed expiry\n(`ComputedExpiresAtUtc`), an aggregated cost preview (`Cost`), and milestone\nprogress (`Milestone`) — you don't have to manually join `getDefinition()` +\n`getUserState()` yourself, though both remain available for lower-level reads\n(e.g. offer history, or definitions for slots with no active offer). Slots\nthat fail their audience `Gate`, have no live/next offer, or are paused\nbetween cycles are simply omitted from `Slots` — there's no \"locked slot\"\nplaceholder.\n\nOn success, each method **emits an event**; only `dismissDeal`, `executeNode`,\n`recordShow`, `claimMilestone`, and `claimMilestonesBatch` also carry a\n`Resources: ResourceOperation` that's mirrored into the inventory/currency\ncache (grants and/or consumes already applied — read updated balances from\n`client.data.user.state?.<Currency/Item>` as usual). `dismissDeal` and\n`recordShow` always carry an empty `Resources` (they never move\ncurrency/items — the field exists for response-shape consistency).\n`executeNode` skips re-applying resources when `data.Idempotent` is true (a\nretried/duplicate call replaying a result already applied server-side without\nopening a new transaction). Note that unlike some other modules, the service\ndoes **not** yet optimistically patch the per-slot/per-node runtime state in\nthe cache on these five calls — refresh with `getUserState()` /\n`getActiveDeals()` (or rely on the event payload) to see updated node\nstatuses.\n\n## Reading state and reacting to changes\n\n```ts\n// Raw per-slot runtime state (present after getUserState() or getActiveDeals()):\nconst dealState = client.data.user.state?.DealOffer;\nconst slot = dealState?.Slots?.[\"Slot1\"];\nslot?.ActiveOfferID;\nslot?.ActiveOffer?.Nodes?.[\"node_1\"]?.Status; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\nslot?.ActiveOffer?.Tracks?.[\"shells\"]?.CurrentValue;\n\n// Definitions (cached after getDefinition()):\nimport type { DealOfferDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<DealOfferDefinitions>(\"DealOffer\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `dealOffer:definitionLoaded` → `DealOfferDefinitions`\n- `dealOffer:userStateLoaded` → `UserDealOffersState`\n- `dealOffer:activeDealsLoaded` → `GetActiveDealsResponse`\n- `dealOffer:dealDismissed` → `DismissDealResponse`\n- `dealOffer:nodeExecuted` → `ExecuteNodeResponse`\n- `dealOffer:showRecorded` → `RecordShowResponse`\n- `dealOffer:milestoneClaimed` → `ClaimDealMilestoneResponse`\n- `dealOffer:milestonesBatchClaimed` → `ClaimDealMilestonesBatchResponse`\n\nThe coarse `user:dealOfferUpdated` (and `user:anyUpdated`) fire only from\n`getUserState()` (which replaces the whole cached `DealOffer` state via\n`applyDealOffer`) — not from the other six calls, since those don't yet patch\n`client.data.user.state?.DealOffer` themselves. Treat the specific\n`dealOffer:*` event payload above as the source of truth for what just\nhappened, and call `getActiveDeals()` / `getUserState()` afterward if you need\nthe refreshed per-slot cache.\n\n```ts\nconst off = client.on(\"dealOffer:nodeExecuted\", (r) => {\n console.log(\n `node ${r.NodeID} completed=${r.NodeCompleted} exhausted=${r.OfferExhausted}`,\n );\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the active deal in a slot\n\n```ts\nawait client.dealOffer.getDefinition();\nconst res = await client.dealOffer.getActiveDeals();\nif (!res.ok) return showError(res.error);\n\nfor (const slot of res.data.Slots ?? []) {\n // slot.OfferDef.Nodes describes the graph; slot.ActivationState.Nodes has\n // per-node runtime Status/ExecutionCount for THIS activation.\n const rootNodes = (slot.OfferDef?.RootNodeIDs ?? []).map((id) =>\n slot.OfferDef?.Nodes?.find((n) => n.NodeID === id),\n );\n // render rootNodes first; reveal further nodes as their Status flips to\n // \"Available\"/\"Completed\" after each executeNode call.\n}\n```\n\n### Record an impression, then execute a node (golden path)\n\n```ts\nawait client.dealOffer.recordShow(\"Slot1\"); // fire once when the popup opens\n\nconst res = await client.dealOffer.executeNode(\"Slot1\", \"node_purchase_1\");\nif (!res.ok) return showError(res.error); // e.g. \"Node 'node_purchase_1' is locked\", insufficient funds\nif (res.data.NodeCompleted) unlockNextNodesInUI();\nif (res.data.OfferExhausted) closeDealPopup(); // no more nodes to work through\n```\n\n### Rewarded-video node needing multiple views\n\nA `RewardedVideo` node's `Action.RewardedVideo.ViewsRequiredToComplete` can be\ngreater than 1 — call `executeNode` again after each ad view; the node only\nflips to completed (and grants `Grants`, unless `GrantRewardsPerView` is set)\nonce enough views have been recorded.\n\n```ts\nasync function watchAdForNode(slotID: string, nodeID: string) {\n await showRewardedAd(); // your ad SDK\n const res = await client.dealOffer.executeNode(slotID, nodeID);\n if (!res.ok) return showError(res.error);\n if (!res.data.NodeCompleted) {\n // still needs more views — show \"1 of N watched\" from ActivationState.Nodes[nodeID].ExecutionCount\n }\n}\n```\n\nPer-activation view cap is derived from the ad config\n(`max(MaxViewsPerActivation, ViewsRequiredToComplete)`, or unlimited if\n`MaxViewsPerActivation <= 0`) — it's never accidentally lower than what's\nneeded to finish the node. `CooldownSecondsBetweenViews` (if set) rejects an\nearly retry with `\"Node 'X' is on cooldown until <time>\"`.\n\n### Claim milestones (single, then batch)\n\n```ts\nconst one = await client.dealOffer.claimMilestone(\"Slot1\", \"milestone_1\");\nif (!one.ok) return showError(one.error); // e.g. \"Not enough earned...\", \"Milestone already claimed.\"\n\nconst batch = await client.dealOffer.claimMilestonesBatch(\"Slot1\", [\n \"milestone_2\",\n \"milestone_3\",\n \"milestone_2\", // duplicates are deduped client-side before the call\n]);\nif (!batch.ok) return showError(batch.error);\nbatch.data.ClaimedIDs; // milestone IDs that were actually claimed\nbatch.data.Rejected; // Record<milestoneID, reasonString> for ones that weren't\n```\n\nThe milestone bar's progress address is derived from the offer's _current_\nslot position (`\"{offerID}:{slotID}:c{cycleIndex}:q{queueIndex}\"`), so it\nresets to zero automatically whenever the slot advances to a new queue entry\nor cycle — there's no explicit \"reset the bar\" call. A `MilestoneClaimMode`\nof `AfterEventEnd` (or `FeaturedAfterEnd`, for `IsFeatured` milestones only)\nrejects the claim with `\"Milestone can only be claimed after the offer\nends.\"` until the activation is no longer live — see\n[references/data-model.md](references/data-model.md) for the exact\n\"activation ended\" check.\n\n### Dismiss a deal early\n\n```ts\nconst res = await client.dealOffer.dismissDeal(\"Slot1\");\nif (!res.ok) return showError(res.error);\n// slot's activation is marked Dismissed server-side; the slot's queue can\n// advance to the next offer per its Schedule/AllowDismissSkip config.\n```\n\n## Gotchas\n\n- **The node graph's real edge list is `NextNodeIDs`, not `UnlockRules`.**\n Completing a node writes `Available` state to every ID in its `NextNodeIDs`\n unconditionally; a non-root node with no runtime state yet is always\n rejected regardless of whether its own `UnlockRules` look satisfied. The\n one exception is `UnlockRules.RequiredTracks`, which genuinely does unlock\n a node the moment the relevant track crosses its threshold. Don't compute\n node availability client-side — read `ActivationState.Nodes[nodeID].Status`\n (refreshed via `getActiveDeals()` / `getUserState()`, or the latest\n `dealOffer:nodeExecuted` event).\n- **Cache isn't optimistically patched for five of the eight calls.**\n `dismissDeal`, `executeNode`, `recordShow`, `claimMilestone`, and\n `claimMilestonesBatch` mirror resource grants/consumes into the\n currency/item cache, but they do **not** patch\n `client.data.user.state?.DealOffer` themselves (per the \"optimistic slot\n patches deferred\" note in the source) — only `getUserState()` does, via\n `applyDealOffer`, which replaces the whole `DealOffer` state wholesale.\n Re-fetch `getActiveDeals()`/`getUserState()` after a mutating call if your\n UI needs the updated per-node/per-slot status rather than relying on stale\n cache reads.\n- **`executeNode`'s `Idempotent` flag matters.** When `true`, the resources in\n the response were already applied by an earlier call with the same\n `RelatedEntityID` (matched against that node's last stored execution ref) —\n the service intentionally skips both re-applying them and opening a new\n transaction. Pass your own `externalRefID` if you need a stable idempotency\n key across retries (e.g. after a network drop); otherwise the SDK mints a\n fresh `deal_exec_<slot>_<node>_<uuid>` each call — still disable the\n control while a call is in flight to guard against double-submits on the UI\n side.\n- **Milestones are a separate reward ladder from node `Grants`, and go\n through the platform-wide progression-multiplier resolver.** A node's\n `Grants` pay out immediately on that node's completion; `MilestonePoints`\n from completed nodes accumulate toward the offer's `Milestones` thresholds,\n which need their own explicit `claimMilestone`/`claimMilestonesBatch` call\n — reaching a threshold does not auto-grant its reward. The actual payout is\n the milestone's base `Rewards` scaled by the title's\n `Reward.MilestoneRewardMultiplier` progression curve if one is configured\n (same resolver Quest/CommunityChest/Referral use) — don't assume the\n claimed amount equals the milestone's raw `Rewards` field.\n- **Batch milestone claims are partial-aware, not all-or-nothing on\n rejection, and uncapped in size.** `ClaimedIDs` / `Rejected` tell you\n per-milestone outcome inside one call; an entry can be rejected on its own\n merits (not yet reached, already claimed, wrong claim-mode timing)\n independent of the others. There's no batch-size cap like Character's\n 50-item limit — send however many milestone IDs you have for one slot. The\n combined resource grant across all claimed IDs in a batch is applied as one\n atomic operation (one Mongo write, one merged `Resources.Grant` summing\n every claimed milestone's reward) — but which IDs land in `Claimed` vs\n `Rejected` is decided before that atomicity boundary.\n- **Tracks reset per activation.** `Tracks` on `ActivationState` belong to the\n current offer activation in that slot — when the slot cycles to the next\n queued offer (or the same offer re-activates later), track values start\n fresh per the offer's `DealTrackDefinition.StartValue`, they don't carry\n over. Lifetime counting instead lives in `OfferHistory`/`NodeCounts`.\n- **`AllowDismissSkip` changes what dismiss actually does.** With it `false`\n (the default), `dismissDeal` only marks the activation `Dismissed` and the\n slot's existing timer keeps ticking — the same (now-inert) offer stays\n \"current\" until it naturally expires. With it `true` (meant for\n one-time-offer slots), dismissing lets the slot advance to the next queued\n offer after `DismissSkipDelaySec` seconds.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: slot queues/schedule modes, the full node/graph shape and its\nexact unlock/exhaustion algorithm, tracks, node execution limits, the\nmilestone-bar addressing and reward-multiplier math, cost-preview\naggregation, and the idempotency/OCC mechanics behind node execution.\n",
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",
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 DirectCost?: ResourceConsume; // used only if no StoreOfferID/BillingProductID\n UseExternalRewards?: boolean; // default true — see grant rules below\n}\n\ninterface DealRewardedVideoActionDefinition {\n AdPlacementID?: string;\n ViewsRequiredToComplete?: number; // default 1\n MaxViewsPerActivation?: number; // default 1; 0 = unlimited\n CooldownSecondsBetweenViews?: number; // default 0\n RequireServerVerification?: boolean; // default true\n GrantRewardsPerView?: boolean; // default false — see grant rules below\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 286-454.\n\n### What actually gets charged/granted per node execution\n\n`DealOfferHelpers.BuildNodeOperation` (lines 352-402), run identically on both\nthe create (bootstrap) and update paths:\n\n**Cost** — only for `Type: \"Purchase\"` nodes where `Action.Purchase.UseExternalRewards`\nis `false` **and** `Action.Purchase.DirectCost.Standard` has at least one\nentry or event-token op. Otherwise no cost is charged by this call at all\n(e.g. `UseExternalRewards: true` means the real-money/Store purchase flow\nhandles the charge elsewhere; this node is just an acknowledgement +\nbonus-grant step). `DirectCost.PremiumDiscounts` ride along as-is — the\nbackend's `ResourceService.FilterByPremium` auto-picks the best matching tier\nand reduces `Consume.Standard` accordingly at charge time; the aggregated\n`Cost` preview on `ActiveDealSlotInfo` shows the **pre-discount** standard\nprice.\n\n**Grants** — `shouldGrant` is true unless the node is a `Purchase` node with\n`UseExternalRewards: true` (that combination means the _external_ system, not\nthis node, pays out the primary reward — `Grants` on such a node would be a\nbonus you'd instead need to design as `UseExternalRewards: false`, so in\npractice `UseExternalRewards: true` nodes rely on the store-purchase grant\npath). There is one more override: a `RewardedVideo` node with\n`GrantRewardsPerView: false` (the default) only grants `Grants` on the call\nthat **completes** the node (i.e. the final required view), not on every\nintermediate view — otherwise a multi-view node would overpay.\n\n**Milestone points** — independent of the above; if the offer has\n`Milestones` and the executed node's `MilestonePoints > 0`, an\n`EventTokenOperation` for `EventTokenType.DealOffer` is appended into the\n_same_ `Grant.Standard.EventTokens` list, in the same atomic transaction\n(`AppendMilestonePointsGrant`, lines 141-191).\n\n---\n\n## Node unlock rules and the graph traversal algorithm\n\n```ts\ninterface DealNodeUnlockRules {\n RequiredCompletedNodeIDs?: string[]; // ALL must be Completed\n RequiredAnyCompletedNodeIDs?: string[]; // AT LEAST ONE must be Completed\n RequiredTracks?: DealTrackRequirement[]; // offer-local track thresholds\n VisibleWhileLocked?: boolean; // default true\n}\n\ninterface DealTrackRequirement {\n TrackID?: string;\n Operator?: \"Eq\" | \"Gte\" | \"Lte\"; // default \"Gte\"\n Value?: number; // default 0\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 462-546.\n\nDespite `UnlockRules` existing independently of `NextNodeIDs` on paper, the\n**actual server algorithm resolves reachability from execution history, not\nfrom re-evaluating `UnlockRules` against arbitrary node IDs at read time.**\nConcretely (`DealOfferHelpers`, `ValidateNodeAccess` lines 1252-1286 +\n`BuildExecutionPatches`/`BuildFreshActivation` unlock blocks):\n\n- A **root node** (`RootNodeIDs`) is unlocked the instant the offer activates\n — no runtime `UserDealNodeState` entry needed; `ValidateNodeAccess` special-cases\n \"no state yet + `isRoot`\" as allowed.\n- A **non-root node with no runtime state yet** is rejected outright — \"Node\n is locked\" — **even if you construct a hypothetical case where its\n `UnlockRules` would already be satisfied.** The only way a non-root node's\n state ever gets created and set to `Available` is:\n - it appears in some other node's `NextNodeIDs` **and that other node just\n completed** (`BuildExecutionPatches` lines 896-905 / `BuildFreshActivation`\n lines 1069-1078: on completion, the engine writes `Available` state for\n every ID in `NextNodeIDs`, unconditionally — it does **not** re-check that\n node's own `UnlockRules` at that point), or\n - it has `RequiredTracks` and a `TrackChange` on some node execution just\n pushed the relevant track(s) over the threshold\n (`GetNodesUnlockedByTrack`, lines 1416-1458 — evaluated for **every**\n node in the offer whenever a track changes, regardless of `NextNodeIDs`\n membership, and only for nodes whose current status is `Locked` or absent).\n- Once a runtime state exists and is `Available`/`InProgress`, `UnlockRules`\n fields (`RequiredCompletedNodeIDs` / `RequiredAnyCompletedNodeIDs`) are\n **never consulted again** — they only produce the generic \"is locked\n (prerequisite nodes not completed)\" message on the **no-state, non-root**\n branch, purely to give a nicer error string; they do not gate anything once\n a node has been reached via `NextNodeIDs` or a track threshold.\n\n**Practical takeaway for the client:** treat `NextNodeIDs` as the _only_ real\nedge list, and `RequiredTracks`/`RequiredCompletedNodeIDs` as: (a) descriptive\nUI hints for what a locked node is waiting on, and (b) the mechanism for\ntrack-gated unlocks specifically (which do work as documented, via\n`GetNodesUnlockedByTrack`). Don't write client logic that unlocks a node\nbecause you've locally determined its `UnlockRules` are satisfied — always\nread `ActivationState.Nodes[nodeID].Status` from the server.\n\n### Choice groups\n\nOn completion of a node with `ChoiceGroupID` set, every **other** node sharing\nthat `ChoiceGroupID` is force-set to `Locked` and\n`ActivationState.SelectedChoiceNodeID` is stamped with the winner\n(`BuildExecutionPatches` lines 907-921, `BuildFreshActivation` lines\n1080-1092). This happens even if a sibling was already `Available`.\n\n### Offer exhaustion\n\nAn offer's activation flips to `Exhausted` the moment either is true\n(checked identically on create and update paths, e.g. lines 248-252 and\n318-322):\n\n- the just-completed node has `ExhaustOfferOnComplete: true`, or\n- the offer has `ExhaustedWhenAllTerminalNodesCompleted: true` (the default)\n **and** every node with an empty/absent `NextNodeIDs` (a \"terminal\" node) is\n now `Completed` or `Hidden` (`AreAllTerminalNodesCompleted`, lines\n 1374-1405).\n\n### Multi-execution nodes (`Limits.PerActivationCap` and RewardedVideo)\n\nA node is not necessarily a single-execution action:\n\n- For non-`RewardedVideo` nodes, `IsNodeCompleted` (lines 1350-1362) compares\n the new execution count against `Limits?.PerActivationCap` (default `1` if\n `Limits` is null) — so a node with e.g. `PerActivationCap: 3` requires 3\n `executeNode` calls before it flips to `Completed`, going through\n `InProgress` in between (`ComputeNodeStatus`, lines 1364-1372).\n- For `RewardedVideo` nodes, completion instead compares against\n `Action.RewardedVideo.ViewsRequiredToComplete` (default `1`), **not**\n `Limits.PerActivationCap` — see `ResolveEffectiveLimits` below.\n\n---\n\n## Tracks\n\n```ts\ninterface DealTrackDefinition {\n TrackID?: string;\n DisplayName?: string;\n StartValue?: number; // default 0\n MinValue?: number; // default 0\n MaxValue?: number; // default 0 = no maximum\n ClampToMin?: boolean; // default true\n ClampToMax?: boolean; // default true\n HiddenFromUI?: boolean; // default false\n}\n\ninterface DealTrackChange {\n TrackID?: string;\n Amount?: number; // positive = add, negative = subtract\n RespectBounds?: boolean; // default true\n}\n```\n\nSource: `DealOfferDefinitions.cs` lines 490-546; math in `ApplyTrackChange`\n(`DealOfferHelpers.cs` lines 1460-1471): `newValue = currentValue + Amount`,\nthen if `RespectBounds` and a matching `DealTrackDefinition` exists, clamp to\n`MinValue` (if `ClampToMin`) and to `MaxValue` (if `ClampToMax` **and**\n`MaxValue > 0`).\n\nTracks are seeded to `StartValue` when a fresh activation is bootstrapped\n(`BuildFreshActivation` lines 1024-1033) — they do **not** persist or carry\nover between activations of the same offer; a new activation (queue advance,\ncycle wrap, or re-trigger) always starts every track at its configured\n`StartValue`.\n\n---\n\n## Node execution limits\n\n`DealNodeDefinition.Limits` is the shared `LimitSpec` (`PerActivationCap` /\n`TotalCap` / `DailyCap`), but Deal Offer resolves an **effective** limit\nbefore checking it (`ResolveEffectiveLimits`, `DealOfferHelpers.cs` lines\n1331-1348):\n\n- **Non-RewardedVideo nodes**: `nodeDef.Limits ?? { PerActivationCap: 1 }` —\n i.e. no `Limits` block at all means \"once per activation,\" matching the\n doc comment on the config field.\n- **RewardedVideo nodes**: the per-activation cap is derived from the ad\n config, not from a generic default — `Math.max(MaxViewsPerActivation,\nViewsRequiredToComplete)` when `MaxViewsPerActivation > 0`, or `0`\n (unlimited) when `MaxViewsPerActivation <= 0`. This guarantees a multi-view\n ad node (`ViewsRequiredToComplete > 1`) can never be capped out before it's\n able to reach completion. `TotalCap`/`DailyCap` pass through from\n `nodeDef.Limits` unchanged (`0` = no cap) regardless of node type.\n\n`ValidateNodeLimits` (lines 1288-1311) checks, in order: `PerActivationCap`\nagainst the node's `ExecutionCount` this activation, `TotalCap` against\nlifetime `NodeCounts[nodeID].TotalExecutions`, and `DailyCap` against\n`NodeCounts[nodeID].DailyExecutions` (reset to `0` once `now >=\nDailyResetUtc`, which is set to the next UTC midnight after the first\nexecution of the day). Rejections surface as, respectively: `\"Execution\nlimit reached for this deal (X/Y)\"`, `\"Total execution limit reached for\nthis node\"`, `\"Daily execution limit reached for this node\"`.\n\nA `RewardedVideo` node with `CooldownSecondsBetweenViews > 0` additionally\nstamps `NextAvailableAtUtc` after each view; a call before that time returns\n`\"Node 'X' is on cooldown until <ISO time>\"`.\n\n---\n\n## Milestones — addressing and reward math\n\nMilestones use the shared `MilestoneDefinition` / `MilestoneClaimMode` (Core/Milestone\n— the same primitive as TimedEvent/Leaderboard/Quest/CommunityChest), but Deal\nOffer's progress source and instance addressing are module-specific.\n\n### Addressing (why the bar resets per activation)\n\n```\nInstanceKey = \"{slotID}:c{cycleIndex}:q{queueIndex}\"\nEventToken address = { Type: \"DealOffer\", EntityID: \"{offerID}:{InstanceKey}\" }\n```\n\nSource: `BuildDealInstanceKey` / `BuildMilestoneAddress`,\n`DealOfferHelpers.cs` lines 119-131. This address is recomputed fresh from\nthe _currently resolved_ slot position every time (`GetActiveDeals`,\n`ClaimMilestone`, `ClaimMilestonesBatch`, and node execution's\n`AppendMilestonePointsGrant` all call `BuildMilestoneAddress` with the live\n`CycleIndex`/`QueueIndex`) — so as soon as the slot advances to a new queue\nposition or cycle, the milestone bar's `EntityID` changes and the player\nstarts a **fresh** `EventTokenType.DealOffer` bucket at `TotalEarned: 0`.\nThere is no explicit \"reset\" step; it's a natural consequence of the address\nbeing derived from position, not from a monotonic counter.\n\n### Progress and reward computation\n\n- Points are earned via `DealNodeDefinition.MilestonePoints` on node\n execution, added to `EventTokenType.DealOffer`'s `Balance.TotalEarned` for\n that address (capped, if configured, by `DealOfferDefinition.MilestoneToken`'s\n `DailyEarnCap`/`MaxBalance`/`MaxPerGrant` — passed as an\n `EventTokenGrantContext`, `BuildMilestonePointsContext` lines 181-191; `null`\n `MilestoneToken` means points accrue with no global caps).\n- A milestone is \"reached\" when `TotalEarned >= milestoneDef.RequiredProgress`\n (`ComputeMilestoneClaim`, `EventTokenService.cs` line 352-353, and the\n `ReachedUnclaimedIDs` computation in `DealOffer.cs` lines 162-165).\n- The reward is **not** the milestone's flat `Rewards` — it's resolved through\n the shared `MilestoneRewardResolver.Resolve(milestoneDef, context)`\n (`DealOffer.cs` lines 652-657, 792-797), which applies the title's\n `Reward.MilestoneRewardMultiplier` progression overlay (a `RewardProgressionMultiplierSpec`\n keyed off things like board stage/rank/character level/season tier — see\n `references/_shared` `MilestoneModels.ts`) on top of the base `Rewards`. Deal\n Offer does not set a bonus-window or season-tier overlay itself, so in\n practice you get \"base reward, optionally scaled by the title-wide\n progression multiplier if one is configured on that milestone.\"\n- Batch claims combine every claimed milestone's resolved grant via\n `BonusWindowHelpers.MergeRewards` (concatenation of entries) into a single\n `ResourceOperation.Grant` before charging — so `claimMilestonesBatch`'s\n `Resources.Grant` in the response is the **sum of all claimed milestones**\n in that call, not itemized per milestone ID.\n\n### Claim mode gate (`MilestoneClaimMode`)\n\n`CheckMilestoneClaimMode` (`DealOffer.cs` lines 866-873):\n\n| Mode | Rule |\n| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| `Instant` (default) | Claimable as soon as reached — no additional gate. |\n| `AfterEventEnd` | Rejected with `\"Milestone can only be claimed after the offer ends.\"` unless the activation has ended. |\n| `FeaturedAfterEnd` | Same rejection, but only for milestones with `IsFeatured: true`; non-featured milestones under this mode claim instantly. |\n\n\"Activation ended\" (`IsActivationEnded`, lines 856-863) means: not a fresh\n`IsNewActivation`, **and** either `ActivationState.Status !== \"Active\"` or\n`now > ComputedExpiresAtUtc`. Deal Offer has **no grace window** after the\nslot advances — once the position moves on, the old bar's milestones under\n`AfterEventEnd`/`FeaturedAfterEnd` are governed by whatever `IsActivationEnded`\nevaluates to for the _newly resolved_ position, which for a genuinely-past\nactivation will read as ended.\n\n### Batch claim mechanics\n\n`ClaimMilestonesBatch` has no server-side cap on the number of milestone IDs\nper call (unlike Character's 50-item batch limit) — it just processes\nwhatever you send after de-duplication. Each requested ID is independently\nscreened for \"exists in `offerDef.Milestones`\" and the `ClaimMilestoneMode`\ngate _before_ being handed to `EventTokenService.ComputeMilestoneClaimBatch`,\nwhich re-checks `TotalEarned >= RequiredProgress` and \"not already claimed\"\nper ID (`EventTokenService.cs` lines 375-408). All rejections — not-found,\nclaim-mode-gated, not-reached, already-claimed — land in the same `Rejected`\ndictionary. The whole batch's DB write is one `PushEach` into `ClaimedIDs`\nguarded by one `Nin` filter, and the combined resource grant rides in the\nsame atomic `ResourceService.ApplyResourceOperationAtomicAsync` call — so\nclaimed milestones in one batch call either all persist or none do, but which\nIDs count as \"claimed\" vs \"rejected\" is decided before that atomicity\nboundary.\n\n---\n\n## Player state\n\nReturned by `getUserState()` as `{ DealOffers: UserDealOffersState }` and\nmirrored into `client.data.user.state?.DealOffer` (note: response field is\n`DealOffers`, the cached property is `DealOffer` — singular).\n\n```ts\ninterface UserDealOffersState {\n Slots?: Record<string, UserDealSlotState>;\n OfferHistory?: Record<string, UserDealOfferHistory>; // lifetime stats, key = OfferID\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealSlotState {\n SlotID?: string;\n QueueIndex?: number;\n CycleIndex?: number;\n ActiveOfferID?: string; // denormalized for convenience\n ActiveOfferStartedAtUtc?: string;\n ActiveOfferExpiresAtUtc?: string | null; // null = no timer\n ActiveOffer?: UserDealOfferActivationState;\n NextCycleStartsAtUtc?: string | null; // set while paused between Chain cycles\n LastUpdatedUtc?: string;\n DismissSkipAvailableAtUtc?: string | null;\n LastDismissedAtUtc?: string | null;\n}\n\ninterface UserDealOfferActivationState {\n OfferID?: string;\n InstanceKey?: string; // \"{slotID}:c{cycleIndex}:q{queueIndex}\" — see Milestones\n SourceOfferVersion?: number; // DealOfferDefinition.Version at activation time\n Status?: DealOfferActivationStatus; // \"Active\" | \"Exhausted\" | \"Expired\" | \"Dismissed\"\n ActivatedAtUtc?: string;\n ExhaustedAtUtc?: string | null;\n ExpiredAtUtc?: string | null;\n DismissedAtUtc?: string | null;\n ShowCount?: number; // recordShow calls this activation\n LastShownAtUtc?: string | null;\n Nodes?: Record<string, UserDealNodeState>; // key = NodeID\n Tracks?: Record<string, UserDealTrackState>; // key = TrackID\n SelectedChoiceNodeID?: string | null;\n}\n\ninterface UserDealNodeState {\n NodeID?: string;\n Status?: DealNodeRuntimeStatus; // \"Locked\" | \"Available\" | \"InProgress\" | \"Completed\" | \"Hidden\"\n ExecutionCount?: number; // executions this activation\n UnlockedAtUtc?: string | null;\n CompletedAtUtc?: string | null;\n LastExecutedAtUtc?: string | null;\n NextAvailableAtUtc?: string | null; // RewardedVideo cooldown\n LastExecutionRefID?: string | null; // last RelatedEntityID — idempotency key\n}\n\ninterface UserDealTrackState {\n TrackID?: string;\n CurrentValue?: number;\n LastUpdatedUtc?: string;\n}\n\ninterface UserDealOfferHistory {\n TotalActivations?: number;\n TotalExhausted?: number;\n TotalExpired?: number;\n TotalDismissed?: number;\n TotalShows?: number; // across ALL activations, including pre-activation show-only calls\n LastShownAtUtc?: string | null;\n LastActivatedAtUtc?: string | null;\n LastExhaustedAtUtc?: string | null;\n NodeCounts?: Record<string, UserDealNodeLifetimeCounts>; // key = NodeID\n}\n\ninterface UserDealNodeLifetimeCounts {\n TotalExecutions?: number;\n DailyExecutions?: number;\n DailyResetUtc?: string;\n}\n```\n\nSource: `UserDealOffersState.cs` in full.\n\n`OfferHistory` is cross-activation (lifetime), keyed by `OfferID` — separate\nfrom the per-activation `Nodes`/`Tracks` under `Slots[x].ActiveOffer`, which\nget wholesale replaced every time a fresh activation bootstraps\n(`BuildBootstrapPatches` does one `Set(activeOfferBase, activation)`, wiping\nthe previous activation's node/track state).\n\n---\n\n## Slot resolution rules (GetActiveDeals)\n\n`GetActiveDeals` (`DealOffer.cs` lines 95-178) walks every **enabled** slot\nwith a non-empty `Queue`, applies the slot's `Gate` (`SegmentGate`, read\nlazily — only fetches the gate-relevant player projection once, and only if\nat least one slot actually has a `Gate` configured), then calls\n`DealOfferHelpers.ResolveSlotState` per slot (the same pure resolver\n`ExecuteNode` uses internally, so reads and writes agree on \"what's active\nright now\"). Slots that fail the gate, have no enabled current/next offer, or\nare mid-pause are simply omitted from the response — there's no \"locked slot\"\nplaceholder entry.\n\nEach returned `ActiveDealSlotInfo.Milestone` is populated only if the\nresolved offer has `Milestones` — its `TotalEarned`/`ClaimedIDs` come from a\nlive `EventTokenService.ReadProgress` read against the _current_ milestone\naddress, and `ReachedUnclaimedIDs` is computed inline (defined milestones\nwhose `RequiredProgress` is met and not yet in `ClaimedIDs`).\n\n**Note on the earning-vs-spending gate split**: `ExecuteNode` re-checks the\nslot's `SegmentGate` itself, but **only** for `FreeClaim` and `RewardedVideo`\nnode types (`DealOffer.cs` lines 343-372) — `Purchase` and `Info` node\nexecutions are never blocked by the slot gate directly (a gated-out player\nsimply never sees the slot via `GetActiveDeals`, but a direct `executeNode`\ncall against a `Purchase`/`Info` node id they somehow know about isn't\nre-gated here). This mirrors the platform-wide principle: gate visibility and\nearning, not spending or claiming.\n\n---\n\n## Cost preview aggregation\n\n`ActiveDealSlotInfo.Cost` (`GetNodesCost`, `DealOfferHelpers.cs` lines\n1483-1542) is a single aggregated `ResourceConsume` built by scanning every\n`Purchase` node in the offer that has `UseExternalRewards: false` and a\nnon-null `DirectCost`:\n\n- `Standard` — concatenation of every such node's `DirectCost.Standard.Entries`\n and `.EventTokens` (i.e. the **sum total** if a player bought every\n purchasable node in the offer, not any single node's price).\n- `PremiumDiscounts` — unioned across nodes; when two nodes declare a discount\n for the same `(MinPremiumTier, RequiredPremiumID)` key, the **larger**\n `DiscountPercent` wins (most favorable to the player is shown).\n- `PremiumTiers` — unioned across nodes; on a duplicate `(MinPremiumTier,\nRequiredPremiumID)` key, the **first** one encountered wins (structural\n tier prices aren't summed/merged).\n\nThis is a **preview only** — the actual charge for a specific node is\ncomputed fresh, per-node, at `executeNode` time via `BuildNodeOperation`, with\nthe real discount resolution happening inside\n`ResourceService.ApplyResourceOperationAtomicAsync` → `FilterByPremium`.\n\n---\n\n## Idempotency and OCC\n\nEvery mutating call resolves its Mongo write with `ResourceService.ResolveRelatedEntityID(requestID,\nprefix)`: if the client supplied a `RelatedEntityID` (the SDK's\n`externalRefID` param on `executeNode`, or the auto-minted UUID-suffixed\ndefault), that's the key; otherwise a fallback keyed by slot/offer/node plus\nUnix-seconds guards against sub-second retries. On top of that generic\nmechanism, `ExecuteNode` layers a **domain-level idempotent replay**: if the\nsame `RelatedEntityID` matches `UserDealNodeState.LastExecutionRefID` for\nthat exact node in the current activation, the handler returns success with\n`Idempotent: true` and an empty `ResourceOperation` **without opening a Mongo\ntransaction at all** (`ComputeNodeExecution`, lines 70-79 for the\nalready-known-node fast path, and lines 227-228 in the update path) — this is\nwhy the SDK skips re-applying `Resources` into the cache when\n`data.Idempotent` is true.\n\nNode execution races (two `executeNode` calls hitting the CREATE/bootstrap\npath at once) are guarded by an optimistic-concurrency filter\n(`BuildBootstrapFilter`, lines 1189-1202) that fails the write if a live\n`Active` activation of the same offer at the same queue/cycle position\nalready exists; on that specific race the HTTP handler retries **once** by\nre-reading fresh state and resolving again (`DealOffer.cs` lines 382-472) —\nthis is transparent to the client, no special handling needed on your side\nbeyond normal retry-on-`\"connection\"`/`\"server\"` policy.\n"
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"
9
9
  }
10
10
  ]
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Game loop data model — reference\n\nFull shape of the config (Definitions) and player state for both the board\nloop and Community Chest, the request/response payloads, and the cache\nmutation rules. All of these are **strictly typed in the SDK** —\n`GameLoopDefinitions`, `BoardLoopDefinition`, `BoardLoopState`, every nested\nblock, and all Community Chest types are exported from `@idosgames/core`, so\n`getGameLoops()`, `getBoardDefinition()`, and `getSection<T>(...)` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Player state: BoardLoopState](#player-state-boardloopstate)\n- [Pending interaction](#pending-interaction)\n- [Special pending state](#special-pending-state)\n- [Config root: GameLoopDefinitions](#config-root-gameloopdefinitions)\n- [Config: BoardLoopDefinition](#config-boardloopdefinition)\n- [Stages, templates, buildings, tiles](#stages-templates-buildings-tiles)\n- [Procedural economy](#procedural-economy)\n- [Bank Heist (raid) config](#bank-heist-raid-config)\n- [Chance tables](#chance-tables)\n- [Special mode config](#special-mode-config)\n- [Community Chest: config](#community-chest-config)\n- [Community Chest: player + group state](#community-chest-player--group-state)\n- [Responses](#responses)\n- [Request shape + action ids](#request-shape--action-ids)\n- [Cache mutation rules](#cache-mutation-rules)\n\n---\n\n## Player state: BoardLoopState\n\nCached at `client.data.user.state?.GameLoop?.Board`, loaded via\n`getUserBoardState()` (full replace) and patched in place by every board\naction.\n\n```ts\ninterface BoardLoopState {\n StageLevel?: number;\n Position?: number; // tile index on the ring\n AvailableRollMultipliers?: number[];\n BuildingStates?: BuildingState[] | null;\n Pending?: BoardPendingInteraction | null; // non-null while an action is unresolved\n CyclesCompleted?: number; // full loops of the ring\n SpecialStats?: SpecialModeStats | null; // lifetime Special-mode counters\n LastRollAtUtc?: string;\n AllStagesCompleted?: boolean;\n [key: string]: unknown; // extra server fields pass through\n}\n\ninterface BuildingState {\n SlotIndex: number;\n Level?: number | null;\n IsDamaged?: boolean | null; // true after being attacked/hit, until rebuilt\n MaxLevelRewardClaimed?: boolean | null; // one-time reward at MaxLevel, already granted\n}\n\ninterface SpecialModeStats {\n PlayedCount?: number | null;\n RewardsClaimedCount?: number | null;\n InstantClaimsCount?: number | null;\n EarlyClaimsCount?: number | null;\n LateClaimsCount?: number | null;\n AdViewsTotal?: number | null;\n}\n```\n\n---\n\n## Pending interaction\n\n`BoardLoopState.Pending` — set by `boardLoopRoll` when the landed tile\nrequires a follow-up action; cleared by resolving it (attack/raid-completion/\nspecial-claim).\n\n```ts\ninterface BoardPendingInteraction {\n Type: string; // \"ATTACK\" | \"RAID\" | \"SPECIAL\" (server-defined strings)\n TargetUserID?: string | null; // ATTACK/RAID vs a real player\n TargetPublicData?: UserPublicDataModel | null; // target's public profile snapshot\n TargetBuildingStates?: BuildingState[]; // ATTACK: target's buildings to hit\n TargetHasShield?: boolean;\n RollMultiplier?: number; // multiplier in effect when this was triggered\n ExpiresAtUtc?: string; // client-side TTL (15 min from the roll), mirrors server expiry\n RaidLayout?: HeistCell[]; // RAID: the 12-cell heist grid\n OpenedIndices?: number[]; // RAID: cells already dug (Sequential mode)\n HeistVariantTag?: string | null;\n IsJackpotRaid?: boolean;\n JackpotFinalMultiplier?: number;\n GuaranteedBonus?: ResourceGrant | null;\n Special?: SpecialPendingState | null; // SPECIAL: chosen-choice tracking\n}\n\ninterface HeistCell {\n Symbol?: string | number | null; // revealed only once dug; match 3-of-a-kind\n OnOpenBonus?: ResourceGrant | null; // already-scaled reward snapshot for this cell\n BonusTag?: string | null;\n}\n```\n\n`RaidLayout` cells' `Symbol`/`OnOpenBonus` are populated by the server as cells\nare dug (Sequential) or all at once up-front (Fast mode sends the full layout\nso the client can reveal locally before submitting).\n\n---\n\n## Special pending state\n\n`BoardPendingInteraction.Special` — tracks a chosen Special-tile choice through\nits Instant/Timed lifecycle.\n\n```ts\ninterface SpecialPendingState {\n ModeID?: string;\n ChoiceID?: string | null;\n ChosenMode?: string | number | null; // \"Instant\"/\"Timed\" or 0/1 — check both\n StartedAtUtc?: string | null;\n DurationSeconds?: number | null;\n AdViewsUsed?: number;\n AccumulatedMultiplier?: number;\n GradationTiers?: SpecialGradationTierSnapshot[] | null; // already-scaled reward-per-elapsed-time snapshot\n BelowFirstTierReward?: ResourceGrant | null;\n Multipliers?: SpecialClaimMultipliers | null;\n /** Offer choices stashed from the roll's SpecialModeOffer so the UI can render\n * them in-session (the server's GetUserBoardState pending only carries ModeID). */\n Choices?: SpecialModeChoice[] | null;\n}\n\ninterface SpecialGradationTierSnapshot {\n ElapsedSeconds?: number | null;\n Reward?: ResourceGrant | null; // already scaled — read directly, don't reapply formulas\n}\n```\n\n`Choices` is why the SDK stashes the roll's `SpecialModeOffer.Choices` into\n`Pending.Special` at choose-time — if the app reloads mid-flow,\n`getUserBoardState()` alone would only return `ModeID`, not the original\nchoice list/rewards, so the client-stashed copy is the only way to re-render\nthe original offer.\n\n---\n\n## Config root: GameLoopDefinitions\n\nCached via `client.data.config.getSection<GameLoopDefinitions>(\"GameLoop\")`,\nloaded by `getGameLoops()`.\n\n```ts\ninterface GameLoopDefinitions {\n Board?: BoardLoopDefinition | null;\n CommunityChest?: CommunityChestDefinition | null;\n [key: string]: unknown;\n}\n```\n\nNote this is a **separate cache section** from what `getBoardDefinition()`\ncaches (see below) — `getGameLoops()` is the only call that also gives you\n`CommunityChest` config.\n\n---\n\n## Config: BoardLoopDefinition\n\nCached via `client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\")`,\nloaded by `getBoardDefinition()` / `getBoardDefinitionForLevel(level)`.\n\n```ts\ninterface BoardLoopDefinition {\n RollCurrencyID?: string | null; // currency spent to roll (if any)\n ShieldCurrencyID?: string | null; // currency spent on defensive shields\n RaidMode?: string | null; // \"Fast\" | \"Sequential\"\n AllowedRollMultipliers?: number[] | null; // valid values for boardLoopRoll(mult)\n BoardTemplatesByID?: Record<string, BoardTemplateDefinition> | null;\n StageTemplatesByID?: Record<string, BoardStageTemplate> | null;\n StagesByLevel?: Record<string, BoardStageDefinition> | null; // key = level as string\n SoftCurrencyID?: string | null;\n ProceduralEconomy?: ProceduralEconomyConfig | null;\n Dice?: BoardDiceConfig | null;\n Bots?: BoardBotConfig | null;\n}\n\ninterface BoardDiceConfig {\n Count?: number | null; // dice rolled per turn\n Sides?: number | null; // faces per die; step = sum of Count dice each 1..Sides\n}\n\ninterface BoardBotConfig {\n ShieldChance?: number | null; // probability a bot target has a shield\n DamagedBuildingChance?: number | null;\n RankMultiplierMin?: number | null; // bot power scaling vs player, sampled range\n RankMultiplierMax?: number | null;\n RankOffsetMin?: number | null;\n RankOffsetMax?: number | null;\n}\n```\n\n`RaidMode` is a **global** setting (not per-stage) — it decides whether the\nwhole title uses `boardLoopRaid` (Sequential) or `boardLoopRaidFast` (Fast) for\nevery raid; read it once via `readBoardConfig(client).RaidMode` (as\n`templates/board-game` does) to pick which raid method your RaidPanel calls.\n\n---\n\n## Stages, templates, buildings, tiles\n\n```ts\ninterface BoardTemplateDefinition {\n Tiles?: Record<string, BoardTileDefinition> | null; // key = tile index as string\n}\n\ninterface BoardTileDefinition {\n Index?: number | null;\n Type?: string | null; // \"Attack\" | \"Raid\" | \"Chance\" | \"Special\" | ... (server-defined)\n CustomTypeID?: string | null;\n RandomActionAttackWeight?: number | null; // for tiles that randomly pick Attack vs Raid\n RandomActionRaidWeight?: number | null;\n ChanceTableID?: string | null; // routes to StageOperations.ChanceTablesByID\n SpecialModeID?: string | null; // routes to StageOperations.SpecialModesByID\n Params?: Record<string, string> | null;\n}\n\ninterface BuildingDefinition {\n SlotIndex?: number | null;\n Name?: string | null;\n AssetPaths?: Record<string, string> | null;\n MaxLevel?: number | null;\n MaxLevelReward?: ResourceGrant | null; // one-time reward on hitting MaxLevel\n}\n\ninterface BoardStageDefinition {\n Name?: string | null;\n AssetPaths?: Record<string, string> | null;\n BoardTemplateID?: string | null; // which tile ring this stage uses\n Buildings?: BuildingDefinition[] | null;\n StageTemplateID?: string | null; // which economy template this stage uses\n CostMatrixTemplateID?: string | null;\n UnitValue?: number | null;\n Override?: BoardStageTemplate | null; // per-stage economy override (see below)\n}\n\ninterface BoardStageTemplate {\n BaseAttackReward?: ResourceGrant | null;\n BaseRaidReward?: ResourceGrant | null;\n MaxRollMultiplier?: number | null;\n MaxShields?: number | null;\n Buildings?: BuildingDefinition[] | null;\n HeistGridBonusConfig?: HeistGridBonusConfig | null;\n StageOperations?: StageOperations | null;\n TileLandingMultiplier?: RewardMultiplierRange | null;\n EconomyOverride?: StageEconomyOverride | null;\n}\n```\n\n`BoardStageTemplate` is reused both as the shared template referenced by\n`StageTemplateID` (in `StageTemplatesByID`) and as the shape of a per-stage\n`Override` block — an `Override` field, when present, wins over the\ntemplate's value for that stage.\n\n```ts\ninterface RewardMultiplierRange {\n Min?: number | null;\n Max?: number | null; // reward multiplier sampled uniformly in [Min, Max]\n}\n\ninterface StageOperations {\n OnPassStart?: ScaledResourceOperation | null; // granted on passing the start tile\n OnTileLanding?: Record<string, ScaledResourceOperation> | null; // keyed by tile type\n OnAttack?: Record<string, ScaledResourceOperation> | null; // keyed by outcome (\"Hit\"/\"Blocked\")\n OnRaid?: Record<string, ScaledResourceOperation> | null;\n OnBuild?: ScaledResourceOperation | null;\n OnStageComplete?: ScaledResourceOperation | null;\n OnAttackBonusDrops?: AttackBonusDrop[] | null; // independent probabilistic bonus drops\n ChanceTablesByID?: Record<string, ChanceTable> | null;\n SpecialModesByID?: Record<string, SpecialModeDefinition> | null;\n}\n\ninterface ScaledResourceOperation {\n Operation?: ResourceOperation | null;\n ScaleWithRollMultiplier?: boolean | null; // if true, Operation amounts scale by the roll's multiplier\n}\n\ninterface AttackBonusDrop {\n Chance?: number | null;\n RequiredOutcome?: string | null; // only rolls if the attack's Outcome matches\n Reward?: ScaledResourceOperation | null;\n Tag?: string | null;\n}\n```\n\n---\n\n## Procedural economy\n\nDrives cost/reward scaling as stages progress, independent of hand-authored\nper-stage numbers. **All of this is server-computed** — the client never\nderives attack/raid/build values itself; read the resolved amounts off each\nresponse's `Operation`/`Reward` fields. The formulas below (transcribed from\n`EconomyMath.cs` and `GameLoop.cs`) are for building cost/reward _previews_ in\nUI, not for computing anything that gets charged.\n\n```ts\ninterface ProceduralEconomyConfig {\n CostMatrixTemplatesByID?: Record<string, number[][]> | null; // named normalized cost matrices\n PerBoardGrowth?: number | null; // tail growth rate applied per stage past the last authored anchor\n VisualTemplateCycle?: string[] | null; // board-template ids cycled for synthesized (unauthored) stages\n EconomyScaledResources?: ResourceBundle | null; // which currencies/items get EconomyScale applied\n ScaleRaidStealWithEconomy?: boolean | null;\n ScaleAttackRewardWithEconomy?: boolean | null;\n AttackValueFromCostMatrix?: boolean | null;\n AttackBlockedRewardFactor?: number | null;\n ScaleVictimLossWithEconomy?: boolean | null;\n ScaleVictimLossWithTier?: boolean | null;\n SynthesizedBuildingSlots?: number | null; // building slot count for stages past authored content\n SynthesizedBuildingMaxLevel?: number | null;\n}\n\ninterface StageEconomyOverride {\n CostMatrix?: number[][] | null; // replaces the whole matrix for this stage\n PerBoardGrowth?: number | null;\n EconomyScale?: number | null; // manual override; wins over the computed Unit(N)/Unit(1) ratio\n ScaledResources?: ResourceBundle | null;\n}\n```\n\n### The board is infinite: `Unit(N)` and stage synthesis\n\n`BoardStageDefinition.UnitValue` marks a stage as an \"anchor\" — the intended\nsoft-currency cost of that stage's slot-0/level-0 building. `EconomyMath.ResolveUnitValue(N)`\n(`IDosGamesSDK/API/Client/v2/GameLoop/Services/EconomyMath.cs:50-87`) derives a\nunit price for **any** stage level `N`, authored or not:\n\n- Exact anchor match → that anchor's `UnitValue`.\n- `N` below the lowest anchor → clamped to the lowest anchor's value (no\n extrapolation backward).\n- `N` between two anchors → **geometric interpolation**:\n `lower.Unit * (upper.Unit / lower.Unit) ^ t`, where\n `t = (N - lower.Level) / (upper.Level - lower.Level)`.\n- `N` above the highest anchor → **geometric extrapolation** using a tail\n growth rate `g`: `last.Unit * g ^ (N - last.Level)`. `g` is the last\n authored stage's `Override.EconomyOverride.PerBoardGrowth` if set (> 0),\n otherwise the global `ProceduralEconomy.PerBoardGrowth` (`EconomyMath.cs:125-142`).\n\n`EconomyScale(N) = Unit(N) / Unit(1)` (`EconomyMath.cs:93-101`), i.e. the\nboard's overall reward/cost magnitude relative to stage 1 — unless a stage's\n`EconomyOverride.EconomyScale` is set (> 0), which wins outright.\n\nBecause of this, **a title's board never runs out of stages**: once a player's\n`StageLevel` exceeds the highest key in `StagesByLevel`, the server\nsynthesizes a stage on the fly (`GameLoop.cs:2899-2932`) — visuals cycle\nthrough `VisualTemplateCycle`, the economy _shape_ (which `StageTemplateID`,\ni.e. which reward/attack/raid rules apply) is inherited from the nearest\nauthored stage below it, and building slots come from `SynthesizedBuildingSlots`/\n`SynthesizedBuildingMaxLevel`. `BoardLoopState.AllStagesCompleted` exists in\nthe SDK's types but the backend never sets it — don't build UI around a \"final\nstage.\"\n\n### Build cost\n\n`EconomyMath.CalcBuildCost(matrix, unit, slot, level)` (`EconomyMath.cs:107-118`):\n\n```\nrawCost = ceil(Unit(N) × CostMatrix[slot][level])\n```\n\n`level` is the building's **current** level (0-based) before the upgrade —\ni.e. the cost to go from `level` to `level + 1`. The result then passes\nthrough the player's `EconomyTuning` cost multiplier and any active\n`BoardBuildCost`-targeted `TimedBoost` (`GameLoop.cs:1480-1485`), floored at 1.\n`EconomyScale` is **not** applied to build cost — `Unit(N)` already encodes\nthe board's cost progression on its own (`GameLoop.cs:1475-1476` comment).\n`CostMatrix` is resolved per stage: an inline `EconomyOverride.CostMatrix` on\nthe stage wins outright; otherwise it's looked up by\n`CostMatrixTemplateID`/`CostMatrixTemplatesByID` (default template id\n`\"Universal\"`, case-insensitive) — see `BoardStageResolver.cs:142-160`.\n\n### Attack reward\n\nBase attacker reward is `BaseAttackReward` scaled by the current\n`Pending.RollMultiplier`, then optionally by a \"building value weight\" and/or\n`EconomyScale`, then halved (or whatever factor) if blocked\n(`GameLoop.cs:900-918`):\n\n```\nattackWeight = AttackValueFromCostMatrix\n ? CostMatrix[targetSlot][targetBuildingLevel - 1] // value of the building actually hit\n : 1.0\nreward = BaseAttackReward × RollMultiplier (roll-scale, ScaleBundleForReward)\nreward *= attackWeight (building-value weight, if enabled)\nreward *= EconomyScale (if ScaleAttackRewardWithEconomy)\nreward *= AttackBlockedRewardFactor (only if Outcome == Blocked)\n```\n\nThe `OnAttack[outcome]` stage-hook reward (separate from `BaseAttackReward`)\nis scaled by `RollMultiplier` only, then also by `EconomyScale` if\n`ScaleAttackRewardWithEconomy` is set — it does **not** get the building-value\nweight. For a bot target, the \"building\" is a random slot/level sampled the\nsame way bot buildings are generated; for a real victim it's the actual\nbuilding about to be hit (level read **before** the hit decrements it).\n\n### Raid (Bank Heist) reward — attacker gain vs. victim loss are decoupled\n\nThese are two independent numbers (`GameLoop.cs:2370-2383` doc comment, math\nat `2521-2578`) — the attacker's gain is **not** derived from what the victim\nactually loses:\n\n```\nattackerGain = BaseRaidReward × RollMultiplier × heistMultiplier (heistMultiplier: 1/2/5, see below)\nattackerGain *= EconomyScale(attacker's own board) (if ScaleRaidStealWithEconomy)\nattackerGain *= JackpotFinalMultiplier (only if the raid variant IsJackpot)\n\nvictimLossRequested = BaseRaidReward × (heistMultiplier if ScaleVictimLossWithTier else 1)\nvictimLossRequested *= EconomyScale(victim's own board) (if ScaleVictimLossWithEconomy)\nvictimLoss = min(victimLossRequested, victim's actual balance) (clipped per-entry, never overdraws)\n```\n\n`heistMultiplier` is 1/2/5 for Small/Medium/Big (3-of-a-kind), and jackpot\nraids apply `JackpotFinalMultiplier` **only to `attackerGain`** — the victim's\nloss never carries the jackpot multiplier. If the victim's balance for a\nrequested resource is 0, that resource is simply excluded from what's taken\n(the attacker still receives their full system-side `attackerGain`\nregardless — a raid against an empty bank never fails, it just steals\nnothing). Bot targets have no real balance to clip, so a raid against a bot\nalways \"steals\" the full unclipped amount.\n\nCell bonuses (`HeistCell.OnOpenBonus`) and any `GuaranteedBonus` are\nindependent system-side grants to the attacker, scaled once at raid-creation\ntime by `RollMultiplier` and (if `ScaleRaidStealWithEconomy`) `EconomyScale` —\nthey are never clipped by the victim's balance either.\n\n---\n\n## Bank Heist (raid) config\n\n```ts\ninterface HeistGridBonusConfig {\n Variants?: HeistRaidVariant[] | null;\n}\n\ninterface HeistRaidVariant {\n Weight?: number | null; // relative chance this variant is picked for a given raid\n Tag?: string | null;\n MinBonusCells?: number | null;\n MaxBonusCells?: number | null;\n BonusPool?: WeightedHeistCellBonus[] | null;\n GuaranteedBonus?: ScaledResourceOperation | null;\n IsJackpot?: boolean | null;\n JackpotFinalMultiplier?: number | null;\n JackpotSymbolDistribution?: Record<string, number> | null; // symbol name -> cell count\n}\n\ninterface WeightedHeistCellBonus {\n Weight?: number | null;\n Bonus?: ScaledResourceOperation | null;\n Tag?: string | null;\n}\n```\n\nThe grid is always a fixed 12 cells (`digIndex` 0-11 in `boardLoopRaid`,\nmatching `zHeistCell` array length conventions used elsewhere in the module).\nA raid's specific layout (symbols, bonuses, jackpot-ness) is generated\nserver-side from a weighted `HeistRaidVariant` pick and sent down as\n`Pending.RaidLayout` — the client never generates or validates the layout.\n\n### Layout generation (server-side, `GameLoop.cs:3240-3339`)\n\n1. **Variant pick**: standard weighted selection over `Variants` — cumulative\n sum of positive `Weight`s, uniform roll in `[0, total)`. No `Variants`\n configured (or none with positive weight) → the classic fallback layout: a\n shuffled 4×Small / 4×Medium / 4×Big.\n2. **Symbols**: if the picked variant `IsJackpot` and its\n `JackpotSymbolDistribution` values sum to exactly 12, that exact symbol mix\n is used (shuffled); any other case (non-jackpot, or a distribution that\n doesn't sum to 12) falls back to the classic 4/4/4 shuffle.\n3. **Bonus cells**: if `MaxBonusCells > 0` and `BonusPool` is non-empty, a\n random count in `[MinBonusCells, MaxBonusCells]` (clamped to `[0, 12]`) of\n distinct cell indices are chosen, and each gets an independently\n weighted-picked bonus from `BonusPool` (same cumulative-weight algorithm as\n the variant pick). Each bonus is scaled once at generation time\n (`RollMultiplier` + boosts) and frozen into `HeistCell.OnOpenBonus` — it is\n not re-scaled at reveal or claim time.\n4. **`GuaranteedBonus`**, if set on the variant, is scaled the same way and\n returned separately as `Pending.GuaranteedBonus` (folded into the\n attacker's system-side gain at raid finalization, not per-cell).\n\nWin condition: **3 of the same symbol among opened cells** ends the raid,\nchecked after every dig — in `Sequential` mode this means exactly 3 opened\ncells share a symbol (checked with `==` since a 4th identical symbol can't be\ndug after the raid already ended); in `Fast` mode the whole submitted batch is\ncounted at once (checked with `>=` since a client could submit more than 3\nmatching indices in one call). `RaidOutcome`/`Status` mapping: 3×Small →\n`\"Small\"` outcome / `heistMultiplier` 1, 3×Medium → `\"Medium\"` / 2, 3×Big →\n`\"Big\"` / 5; if the raid's variant `IsJackpot`, the outcome is forced to\n`\"Jackpot\"` regardless of which symbol matched, and `JackpotFinalMultiplier`\nis applied to the attacker's gain only (see the raid reward formula above).\n`RaidResponse.Status` on the terminal call is `\"FINISHED_\" + tier` in\nupper-case (`\"FINISHED_SMALL\"`, `\"FINISHED_MEDIUM\"`, `\"FINISHED_BIG\"`,\n`\"FINISHED_JACKPOT\"`) — never a bare `\"Complete\"`.\n\n---\n\n## Chance tables\n\n```ts\ninterface ChanceTable {\n Outcomes?: ChanceOutcome[] | null;\n}\n\ninterface ChanceOutcome {\n Weight?: number | null;\n OutcomeID?: string | null;\n Reward?: ScaledResourceOperation | null;\n ForceAction?: string | null; // can force the tile to also trigger Attack/Raid/Special\n SpecialModeID?: string | null;\n}\n```\n\nA `Chance`-type tile resolves one weighted `ChanceOutcome` server-side on\nlanding; the outcome's `Reward` (and possibly `ForceAction`) is what shows up\nin the `boardLoopRoll` response — there's no separate \"resolve chance\" method.\n\n---\n\n## Special mode config\n\n```ts\ninterface SpecialModeDefinition {\n Choices?: SpecialModeChoice[] | null;\n OfferExpireSeconds?: number | null; // how long the offer stays choosable\n ClaimExpireSeconds?: number | null; // how long a claim stays valid after the window\n}\n\ninterface SpecialModeChoice {\n ChoiceID?: string | null;\n Mode?: string | null; // \"Instant\" | \"Timed\"\n Reward?: ScaledResourceOperation | null; // used directly for Instant\n DurationSeconds?: number | null; // Timed window length\n EntryCost?: ResourceConsume | null; // charged on boardSpecialChoose, if set\n Multipliers?: SpecialClaimMultipliers | null;\n Gradation?: SpecialGradationConfig | null;\n}\n\ninterface SpecialClaimMultipliers {\n PerAdMultiplierRange?: RewardMultiplierRange | null; // range each ad-view multiplier is sampled from\n MaxAdViews?: number | null; // cap on boardSpecialApplyMultiplier calls (enforced as-configured; no hidden hard ceiling)\n AdCreditCost?: number | null;\n FormulaKind?: string | null; // \"Additive\" (default) | \"Multiplicative\" — the only two values the backend defines\n ApplyMultiplierOnEarlyClaim?: boolean | null; // default false\n}\n\ninterface SpecialGradationConfig {\n Tiers?: SpecialGradationTier[] | null; // reward grows the longer the player waits\n BelowFirstTierReward?: ScaledResourceOperation | null; // paid if claimed before the first tier\n}\n\ninterface SpecialGradationTier {\n ElapsedSeconds?: number | null;\n Reward?: ScaledResourceOperation | null;\n}\n```\n\n### Multiplier accumulation and claim formulas (`GameLoop.cs:1981-2254`)\n\nEach `boardSpecialApplyMultiplier` call rolls one step uniformly from\n`PerAdMultiplierRange` (`[Min, Max]`, or the fixed value if `Min == Max`) and\nfolds it into `AccumulatedMultiplier`:\n\n```\nFormulaKind \"Additive\" (default): AccumulatedMultiplier += rolled (starts at 0.0)\nFormulaKind \"Multiplicative\": AccumulatedMultiplier *= rolled (starts at 1.0)\n```\n\n`MaxAdViews` is enforced exactly as configured — there is no separate\nhardcoded safety ceiling beyond it.\n\nAt `boardSpecialClaim`, the gradation tier reached determines the _base_\nreward snapshot, and a separate `finalMultiplier` is applied on top of it:\n\n- **Tier selection**: walk the pre-sorted (ascending `ElapsedSeconds`) tier\n list and take the **highest** tier whose `ElapsedSeconds` threshold has\n already elapsed. If none has elapsed yet, `reachedTierIndex = -1` and the\n claim uses `BelowFirstTierReward` — but only if that field is configured;\n otherwise the claim is rejected with `\"Play window not closed yet\"` (there's\n no way to claim nothing).\n- **`IsEarlyClaim`** is `reachedTierIndex < 0` specifically — i.e., true only\n for the below-first-tier case. Reaching tier 0 (the very first configured\n tier) already counts as a real claim, `IsEarlyClaim = false`.\n- **`finalMultiplier`**: if early **and** `ApplyMultiplierOnEarlyClaim` is\n `false` (the default), `finalMultiplier = 1.0` — every ad-view multiplier\n rolled is discarded. Otherwise: `\"Additive\"` → `1 + AccumulatedMultiplier`;\n `\"Multiplicative\"` → `AccumulatedMultiplier` itself (floored to 1.0 if it\n somehow ended up ≤ 0).\n- The tier's frozen `Reward` snapshot (or `BelowFirstTierReward`) is cloned and\n every amount multiplied by `finalMultiplier`, then granted.\n\nResponse-side preview types mirror the config but strip fields the client\nshouldn't see ahead of time:\n\n```ts\ninterface SpecialModeChoicePreview {\n ChoiceID?: string | null;\n Mode?: string | null;\n Reward?: ScaledResourceOperation | null;\n DurationSeconds?: number | null;\n EntryCost?: ResourceConsume | null;\n Multipliers?: SpecialClaimMultipliers | null;\n // (no Gradation — gradation tiers are resolved and applied server-side at claim time)\n}\n\ninterface SpecialModeOfferData {\n ModeID?: string | null;\n Choices?: SpecialModeChoicePreview[] | null;\n}\n```\n\n---\n\n## Community Chest: config\n\nPart of `GameLoopDefinitions.CommunityChest` (loaded via `getGameLoops()`,\ncached under section `\"GameLoop\"` — not `\"BoardDefinition\"`).\n\n```ts\ninterface CommunityChestDefinition {\n IsActive?: boolean | null; // feature kill-switch\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n AnchorUtc?: string | null; // reference time rounds are scheduled from\n RoundDurationSec?: number | null;\n PauseBetweenRoundsSec?: number | null;\n MaxRounds?: number | null; // lifetime cap on rounds per group/player, if any\n PartnerCount?: number | null; // target group size\n MatchmakingTimeoutMinutes?: number | null; // how long a \"Forming\" group waits before bot-fill\n MemberGracePeriodMinutes?: number | null; // declared but currently unused by the backend (see note below)\n ContributionMin?: number | null; // per-roll contribution range\n ContributionMax?: number | null;\n ScaleContributionWithRollMultiplier?: boolean | null;\n MaxProgress?: number | null; // shared meter's completion threshold\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; shared Core/Milestone block\n GrandPrize?: ResourceGrant | null;\n}\n```\n\n`Milestones` uses the same shared `MilestoneDefinition` type as other modules\nin the SDK (imported from `../_shared/MilestoneModels`) — it is not a\nGameLoop-specific shape, so if you've already read that reference elsewhere,\nit applies unchanged here.\n\n**`MemberGracePeriodMinutes` has no reader anywhere in `CommunityChestService.cs`\n/ `CommunityChestDBService.cs`** — it's a declared-but-dead config field today.\n`LeaveAsync` marks the leaving member `\"Left\"` immediately and, if that empties\nout a still-`\"Forming\"` group, flips it straight to `\"Failed\"` with no grace\nwindow. Don't build UI implying a departed slot gets a grace period before\nbackfill.\n\n### Round scheduling and matchmaking (`CommunityChestService.cs`)\n\nRounds are a **title-wide schedule**, not per-player: `ComputeRound` derives\nthe current round purely from wall-clock time (`CommunityChestService.cs:628-653`):\n\n```\ncycle = RoundDurationSec + max(0, PauseBetweenRoundsSec)\nelapsed = now - AnchorUtc\nroundIndex = floor(elapsed / cycle)\nposInCycle = elapsed - roundIndex * cycle\nround is active only if: now >= AnchorUtc, posInCycle < RoundDurationSec,\n and (MaxRounds <= 0 || roundIndex < MaxRounds)\n```\n\nSo `MaxRounds` caps the number of rounds ever scheduled title-wide (once\nexhausted, the feature goes permanently inactive for everyone) — it is not a\nper-player attempt limit. During the `PauseBetweenRoundsSec` gap between two\nrounds, `joinOrCreateCommunityChest` fails with `\"No active Community Chest\nround.\"`.\n\n`joinOrCreateCommunityChest` (`JoinOrCreateAsync`): group size is\n`1 + max(0, PartnerCount)`. It looks for an existing `\"Forming\"` group for the\ncurrent round with free slots; if none exists it creates one; if the found\ngroup is full or a race loses the OCC-guarded join, it retries up to 5 times\nbefore failing with `\"Matchmaking failed after retries. Please try again.\"`\nBot-filling is **lazy, not proactive** — `TryFillWithBotsIfNeeded` only runs\nwhen a player's state is actually read (`getCommunityChestState` or another\n`joinOrCreateCommunityChest` call) and checks\n`now >= group.CreatedAtUtc + MatchmakingTimeoutMinutes`; if so, it fills every\nremaining slot with bots and flips the group to `\"Active\"` in one shot (no\npartial/gradual backfill).\n\nContribution per landing on the `CommunityChest` tile (`TryContributeAsync`):\n\n```\ndelta = uniform_random(ContributionMin, ContributionMax) // inclusive; min if Max<=Min\nif ScaleContributionWithRollMultiplier and usedMultiplier > 1: delta *= usedMultiplier\nnewProgress = min(oldProgress + delta, MaxProgress) // clipped, never overshoots\ngroup.Status becomes \"Completed\" the instant newProgress >= MaxProgress\n```\n\n---\n\n## Community Chest: player + group state\n\n```ts\ninterface UserCommunityChestState {\n ActiveGroupID?: string | null;\n ActiveRoundIndex?: number | null;\n History?: CommunityChestHistoryEntry[] | null; // recent completed rounds\n}\n\ninterface CommunityChestHistoryEntry {\n GroupID?: string | null;\n RoundIndex?: number | null;\n FinalStatus?: CommunityChestStatus | null;\n GrandPrizeReceived?: boolean | null;\n FinishedAtUtc?: string | null;\n}\n\n// enum CommunityChestStatus\ntype CommunityChestStatus =\n \"Forming\" | \"Active\" | \"Completed\" | \"Failed\" | \"Expired\";\n\n// enum CommunityChestMemberStatus\ntype CommunityChestMemberStatus = \"Active\" | \"Left\" | \"Replaced\";\n\ninterface CommunityChestGroupDocument {\n GroupID?: string | null;\n TitleID?: string | null;\n RoundIndex?: number | null;\n Members?: CommunityChestMember[] | null;\n SharedProgress?: CommunityChestSharedState | null;\n Status?: CommunityChestStatus | null;\n CreatedAtUtc?: string | null;\n ExpiresAtUtc?: string | null;\n Version?: number | null;\n}\n\ninterface CommunityChestMember {\n UserID?: string | null;\n PublicData?: UserPublicDataModel | null;\n IsBot?: boolean | null; // groups may be filled out with bots if matchmaking times out\n ContributionPoints?: number | null;\n ClaimedMilestoneIDs?: string[] | null;\n GrandPrizeClaimed?: boolean | null;\n MemberStatus?: CommunityChestMemberStatus | null;\n JoinedAtUtc?: string | null;\n LeftAtUtc?: string | null;\n}\n\ninterface CommunityChestSharedState {\n CurrentProgress?: number | null;\n MaxProgress?: number | null;\n}\n```\n\n`UserCommunityChestState` (the small per-player pointer) is what's cached at\n`client.data.user.state?.GameLoop?.CommunityChest`. The richer\n`CommunityChestGroupDocument` (full member list + shared meter) is **not**\nseparately cached — it only comes back inline in\n`CommunityChestUserStateResponse.ActiveGroup` and\n`CommunityChestGroupStateResponse.Group`; hold onto the response if you need\nto render the roster/leaderboard, or re-call `getCommunityChestState()`.\n\n---\n\n## Responses\n\n```ts\ninterface RollActionData {\n TargetUserID?: string | null;\n IsBot?: boolean | null;\n PublicData?: UserPublicDataModel | null;\n TargetBuildingStates?: BuildingState[] | null;\n TargetHasShield?: boolean | null;\n}\n\ninterface CommunityChestContributionResult {\n GroupID?: string | null;\n Delta?: number | null; // points added by this roll\n NewProgress?: number | null;\n MaxProgress?: number | null;\n UnlockedMilestoneIDs?: string[] | null; // newly crossed this roll\n Completed?: boolean | null; // meter hit MaxProgress\n}\n\ninterface BoardRollResponse {\n UsedMultiplier?: number | null;\n Steps?: number | null; // dice total this roll\n OldPosition?: number | null;\n NewPosition: number;\n CyclesCompletedDelta?: number | null;\n LandedTileType?: string | null;\n Operation?: ResourceOperation | null; // e.g. OnTileLanding / OnPassStart reward\n ActionRequired?: string | null; // \"ATTACK\" | \"RAID\" — mirrors Pending.Type when set\n ActionData?: RollActionData | null;\n SpecialModeOffer?: SpecialModeOfferData | null;\n CommunityChestContribution?: CommunityChestContributionResult | null;\n}\n\ninterface AttackResponse {\n Outcome?: string | null; // \"Hit\" | \"Blocked\"\n IsBotTarget?: boolean | null;\n BuildingIndexHit?: number | null;\n Operation?: ResourceOperation | null; // bot fights: full resource delta\n DualResult?: ResourceDualPartyResult | null; // PvP fights: FromResult/ToResult split\n}\n\ninterface RaidResponse {\n Status?: string | null; // \"CONTINUE\" | (a terminal status, e.g. \"Complete\")\n Outcome?: string | null;\n FoundSymbol?: string | number | null;\n FoundBonus?: ResourceGrant | null; // already-scaled snapshot for the dug cell\n OpenedIndex?: number | null;\n AttemptsLeft?: number | null;\n RaidLayout?: HeistCell[] | null;\n HeistVariantTag?: string | null;\n IsJackpot?: boolean | null;\n Operation?: ResourceOperation | null;\n DualResult?: ResourceDualPartyResult | null;\n}\n\ninterface BuildResponse {\n BuiltIndex: number;\n NewLevel?: number | null;\n StageComplete?: boolean | null;\n MaxLevelRewardClaimed?: boolean | null;\n Operation?: ResourceOperation | null;\n}\n\ninterface SpecialChooseResponse {\n Mode?: string | number | null; // \"Instant\"/\"Timed\" or 0/1\n Operation?: ResourceOperation | null; // Instant reward, granted immediately\n DurationSeconds?: number | null;\n StartedAtUtc?: string | null;\n ExpiresAtUtc?: string | null;\n}\n\ninterface SpecialApplyMultiplierResponse {\n AdViewsUsed?: number | null;\n RolledMultiplier?: number | null; // this call's sampled multiplier step\n AccumulatedMultiplier?: number | null; // running total across all ad views\n RemainingAdViews?: number | null;\n}\n\ninterface SpecialClaimResponse {\n FinalMultiplier?: number | null;\n AdViewsUsed?: number | null;\n AccumulatedMultiplier?: number | null;\n IsEarlyClaim?: boolean | null;\n Operation?: ResourceOperation | null;\n}\n\ninterface CommunityChestUserStateResponse {\n ServerTimeUtc?: string | null;\n UserState?: UserCommunityChestState | null;\n ActiveGroup?: CommunityChestGroupDocument | null;\n SecondsRemaining?: number | null;\n}\n\ninterface CommunityChestGroupStateResponse {\n ServerTimeUtc?: string | null;\n Group?: CommunityChestGroupDocument | null;\n SecondsRemaining?: number | null;\n}\n\ninterface CommunityChestClaimResponse {\n ServerTimeUtc?: string | null;\n GroupID?: string | null;\n RewardType?: string | null; // \"Milestone\" | \"GrandPrize\"\n MilestoneID?: string | null;\n Resources?: ResourceOperation | null;\n}\n\ninterface CommunityChestLeaveResponse {\n ServerTimeUtc?: string | null;\n GroupID?: string | null;\n Success?: boolean | null;\n}\n```\n\n`ResourceGrant`, `ResourceConsume`, `ResourceOperation`, `ResourceBundle`, and\n`ResourceDualPartyResult` are the shared resource types used across the whole\nSDK (`../_shared/ResourceModels`) — same shapes as in other modules' rewards\nand costs.\n\n---\n\n## Request shape + action ids\n\n```ts\ninterface GameLoopRequest extends BaseRequest {\n RollMultiplier?: number;\n BuildingIndex?: number;\n DigIndex?: number;\n StageLevel?: number;\n DigIndices?: number[];\n ChoiceID?: string;\n GroupID?: string;\n MilestoneID?: string;\n}\n```\n\nEvery mutating board call (`boardLoopRoll`, `boardLoopAttack`,\n`boardLoopRaid`, `boardLoopRaidFast`, `boardLoopBuild`, `boardSpecialChoose`,\n`boardSpecialApplyMultiplier`, `boardSpecialClaim`) attaches a client-generated\n`RelatedEntityID` idempotency-style key (`\"<verb>_<userID>_<uuid>\"`) — this is\ninternal plumbing, not something you construct yourself, but it explains why\ntwo rapid duplicate calls are two distinct server operations rather than being\ndeduped (see Gotchas in SKILL.md).\n\n`GameLoopAction` is the string-enum of every action id\n(`GetGameLoops`, `GetBoardDefinition`, `GetBoardDefinitionForLevel`,\n`GetUserBoardState`, `BoardLoopRoll`, `BoardLoopAttack`, `BoardLoopRaid`,\n`BoardLoopRaidFast`, `BoardLoopBuild`, `BoardSpecialChoose`,\n`BoardSpecialApplyMultiplier`, `BoardSpecialClaim`, `GetCommunityChestState`,\n`JoinOrCreateCommunityChest`, `ClaimCommunityChestMilestone`,\n`ClaimCommunityChestGrandPrize`, `LeaveCommunityChest`) — used internally for\nrouting; you won't need to reference it directly when calling\n`client.gameLoop.*` methods.\n\n---\n\n## Cache mutation rules\n\nWhat each method writes into `client.data.user.state?.GameLoop`, precisely\n(all board writes emit `user:gameLoopUpdated` + `user:anyUpdated`; Community\nChest writes emit the same two events):\n\n| Method | Cache effect |\n| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `getUserBoardState` | Full replace: `Board = data`. |\n| `boardLoopRoll` | Patches `Position`, `CyclesCompleted` (+= delta), `LastRollAtUtc`; sets `Pending` from `ActionRequired`/`ActionData` or `SpecialModeOffer` (or leaves it as-is if neither is set — it is **not** explicitly cleared on a plain-tile roll); applies `Operation` if present. |\n| `boardLoopAttack` | Clears `Pending` unconditionally. Applies `Operation` (bot target) or `DualResult.FromResult` (PvP). On failure, re-fetches `getUserBoardState()` instead of patching. |\n| `boardLoopRaid` | If `Status === \"CONTINUE\"`: patches `Pending.RaidLayout` + appends to `Pending.OpenedIndices`. Otherwise: clears `Pending`, applies `Operation`/`DualResult.FromResult`. On failure, re-fetches state. |\n| `boardLoopRaidFast` | Only acts if `Status !== \"CONTINUE\"`: clears `Pending`, applies reward. A `CONTINUE` result is a no-op on the cache. On failure, re-fetches state. |\n| `boardLoopBuild` | If `StageComplete`: `StageLevel += 1`, `Position = 0`, `Pending = null`, `BuildingStates = null`. Else: patches (creates if missing) the `BuildingStates` entry for `BuiltIndex` — sets `Level`, clears `IsDamaged`, sets `MaxLevelRewardClaimed` if granted. Applies `Operation` either way. |\n| `boardSpecialChoose` | If `Mode` is Instant: clears `Pending`. Else (Timed): sets/creates `Pending.Special` (`ChoiceID`, `ChosenMode`, `StartedAtUtc`, `DurationSeconds`, resets `AdViewsUsed = 0`). Applies `Operation` if present (e.g. an `EntryCost` charge). |\n| `boardSpecialApplyMultiplier` | Patches `Pending.Special.AdViewsUsed` and `AccumulatedMultiplier`. |\n| `boardSpecialClaim` | Clears `Pending`. Applies `Operation`. |\n| `getCommunityChestState` | Full replace: `CommunityChest = data.UserState ?? {}`. |\n| `joinOrCreateCommunityChest` | If a group came back: sets `CommunityChest.ActiveGroupID`/`ActiveRoundIndex` (partial patch, not a full replace). |\n| `claimCommunityChestMilestone` | No `CommunityChest` state patch — only applies `Resources` to currency/item balances. (Milestone-claimed tracking lives server-side on the group document, not mirrored locally.) |\n| `claimCommunityChestGrandPrize` | Same as milestone claim: applies `Resources` only. |\n| `leaveCommunityChest` | If `Success`: clears `ActiveGroupID` (`undefined`) and sets `ActiveRoundIndex = -1`. |\n\nNote the asymmetry: `boardLoopRoll` never explicitly nulls out `Pending` for a\nplain (non-action, non-special) tile landing — in practice this is safe\nbecause a plain landing only happens when there was no prior unresolved\n`Pending` (the server won't let you roll again while one is open), so there is\nnothing stale to clear. If you're debugging a \"stale Pending\" UI bug, this is\nthe first place to look.\n"
8
+ "content": "# Game loop data model — reference\n\nFull shape of the config (Definitions) and player state for both the board\nloop and Community Chest, the request/response payloads, and the cache\nmutation rules. All of these are **strictly typed in the SDK** —\n`GameLoopDefinitions`, `BoardLoopDefinition`, `BoardLoopState`, every nested\nblock, and all Community Chest types are exported from `@idosgames/core`, so\n`getGameLoops()`, `getBoardDefinition()`, and `getSection<T>(...)` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Player state: BoardLoopState](#player-state-boardloopstate)\n- [Pending interaction](#pending-interaction)\n- [Special pending state](#special-pending-state)\n- [Config root: GameLoopDefinitions](#config-root-gameloopdefinitions)\n- [Config: BoardLoopDefinition](#config-boardloopdefinition)\n- [Stages, templates, buildings, tiles](#stages-templates-buildings-tiles)\n- [Procedural economy](#procedural-economy)\n- [Bank Heist (raid) config](#bank-heist-raid-config)\n- [Chance tables](#chance-tables)\n- [Special mode config](#special-mode-config)\n- [Community Chest: config](#community-chest-config)\n- [Community Chest: player + group state](#community-chest-player--group-state)\n- [Responses](#responses)\n- [Request shape + action ids](#request-shape--action-ids)\n- [Cache mutation rules](#cache-mutation-rules)\n\n---\n\n## Player state: BoardLoopState\n\nCached at `client.data.user.state?.GameLoop?.Board`, loaded via\n`getUserBoardState()` (full replace) and patched in place by every board\naction.\n\n```ts\ninterface BoardLoopState {\n StageLevel?: number;\n Position?: number; // tile index on the ring\n AvailableRollMultipliers?: number[];\n BuildingStates?: BuildingState[] | null;\n Pending?: BoardPendingInteraction | null; // non-null while an action is unresolved\n CyclesCompleted?: number; // full loops of the ring\n SpecialStats?: SpecialModeStats | null; // lifetime Special-mode counters\n LastRollAtUtc?: string;\n AllStagesCompleted?: boolean;\n [key: string]: unknown; // extra server fields pass through\n}\n\ninterface BuildingState {\n SlotIndex: number;\n Level?: number | null;\n IsDamaged?: boolean | null; // true after being attacked/hit, until rebuilt\n MaxLevelRewardClaimed?: boolean | null; // one-time reward at MaxLevel, already granted\n}\n\ninterface SpecialModeStats {\n PlayedCount?: number | null;\n RewardsClaimedCount?: number | null;\n InstantClaimsCount?: number | null;\n EarlyClaimsCount?: number | null;\n LateClaimsCount?: number | null;\n AdViewsTotal?: number | null;\n}\n```\n\n---\n\n## Pending interaction\n\n`BoardLoopState.Pending` — set by `boardLoopRoll` when the landed tile\nrequires a follow-up action; cleared by resolving it (attack/raid-completion/\nspecial-claim).\n\n```ts\ninterface BoardPendingInteraction {\n Type: string; // \"ATTACK\" | \"RAID\" | \"SPECIAL\" (server-defined strings)\n TargetUserID?: string | null; // ATTACK/RAID vs a real player\n TargetPublicData?: UserPublicDataModel | null; // target's public profile snapshot\n TargetBuildingStates?: BuildingState[]; // ATTACK: target's buildings to hit\n TargetHasShield?: boolean;\n RollMultiplier?: number; // multiplier in effect when this was triggered\n ExpiresAtUtc?: string; // client-side TTL (15 min from the roll), mirrors server expiry\n RaidLayout?: HeistCell[]; // RAID: the 12-cell heist grid\n OpenedIndices?: number[]; // RAID: cells already dug (Sequential mode)\n HeistVariantTag?: string | null;\n IsJackpotRaid?: boolean;\n JackpotFinalMultiplier?: number;\n GuaranteedBonus?: ResourceGrant | null;\n Special?: SpecialPendingState | null; // SPECIAL: chosen-choice tracking\n}\n\ninterface HeistCell {\n Symbol?: string | number | null; // revealed only once dug; match 3-of-a-kind\n OnOpenBonus?: ResourceGrant | null; // already-scaled reward snapshot for this cell\n BonusTag?: string | null;\n}\n```\n\n`RaidLayout` cells' `Symbol`/`OnOpenBonus` are populated by the server as cells\nare dug (Sequential) or all at once up-front (Fast mode sends the full layout\nso the client can reveal locally before submitting).\n\n---\n\n## Special pending state\n\n`BoardPendingInteraction.Special` — tracks a chosen Special-tile choice through\nits Instant/Timed lifecycle.\n\n```ts\ninterface SpecialPendingState {\n ModeID?: string;\n ChoiceID?: string | null;\n ChosenMode?: string | number | null; // \"Instant\"/\"Timed\" or 0/1 — check both\n StartedAtUtc?: string | null;\n DurationSeconds?: number | null;\n AdViewsUsed?: number;\n AccumulatedMultiplier?: number;\n GradationTiers?: SpecialGradationTierSnapshot[] | null; // already-scaled reward-per-elapsed-time snapshot\n BelowFirstTierReward?: ResourceGrant | null;\n Multipliers?: SpecialClaimMultipliers | null;\n /** Offer choices stashed from the roll's SpecialModeOffer so the UI can render\n * them in-session (the server's GetUserBoardState pending only carries ModeID). */\n Choices?: SpecialModeChoice[] | null;\n}\n\ninterface SpecialGradationTierSnapshot {\n ElapsedSeconds?: number | null;\n Reward?: ResourceGrant | null; // already scaled — read directly, don't reapply formulas\n}\n```\n\n`Choices` is why the SDK stashes the roll's `SpecialModeOffer.Choices` into\n`Pending.Special` at choose-time — if the app reloads mid-flow,\n`getUserBoardState()` alone would only return `ModeID`, not the original\nchoice list/rewards, so the client-stashed copy is the only way to re-render\nthe original offer.\n\n---\n\n## Config root: GameLoopDefinitions\n\nCached via `client.data.config.getSection<GameLoopDefinitions>(\"GameLoop\")`,\nloaded by `getGameLoops()`.\n\n```ts\ninterface GameLoopDefinitions {\n Board?: BoardLoopDefinition | null;\n CommunityChest?: CommunityChestDefinition | null;\n [key: string]: unknown;\n}\n```\n\nNote this is a **separate cache section** from what `getBoardDefinition()`\ncaches (see below) — `getGameLoops()` is the only call that also gives you\n`CommunityChest` config.\n\n---\n\n## Config: BoardLoopDefinition\n\nCached via `client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\")`,\nloaded by `getBoardDefinition()` / `getBoardDefinitionForLevel(level)`.\n\n```ts\ninterface BoardLoopDefinition {\n RollCurrencyID?: string | null; // currency spent to roll (if any)\n ShieldCurrencyID?: string | null; // currency spent on defensive shields\n RaidMode?: string | null; // \"Fast\" | \"Sequential\"\n AllowedRollMultipliers?: number[] | null; // valid values for boardLoopRoll(mult)\n BoardTemplatesByID?: Record<string, BoardTemplateDefinition> | null;\n StageTemplatesByID?: Record<string, BoardStageTemplate> | null;\n StagesByLevel?: Record<string, BoardStageDefinition> | null; // key = level as string\n SoftCurrencyID?: string | null;\n ProceduralEconomy?: ProceduralEconomyConfig | null;\n Dice?: BoardDiceConfig | null;\n Bots?: BoardBotConfig | null;\n}\n\ninterface BoardDiceConfig {\n Count?: number | null; // dice rolled per turn\n Sides?: number | null; // faces per die; step = sum of Count dice each 1..Sides\n}\n\ninterface BoardBotConfig {\n ShieldChance?: number | null; // probability a bot target has a shield\n DamagedBuildingChance?: number | null;\n RankMultiplierMin?: number | null; // bot power scaling vs player, sampled range\n RankMultiplierMax?: number | null;\n RankOffsetMin?: number | null;\n RankOffsetMax?: number | null;\n}\n```\n\n`RaidMode` is a **global** setting (not per-stage) — it decides whether the\nwhole title uses `boardLoopRaid` (Sequential) or `boardLoopRaidFast` (Fast) for\nevery raid; read it once via `readBoardConfig(client).RaidMode` (as\n`templates/board-game` does) to pick which raid method your RaidPanel calls.\n\n---\n\n## Stages, templates, buildings, tiles\n\n```ts\ninterface BoardTemplateDefinition {\n Tiles?: Record<string, BoardTileDefinition> | null; // key = tile index as string\n}\n\ninterface BoardTileDefinition {\n Index?: number | null;\n Type?: string | null; // \"Attack\" | \"Raid\" | \"Chance\" | \"Special\" | ... (server-defined)\n CustomTypeID?: string | null;\n RandomActionAttackWeight?: number | null; // for tiles that randomly pick Attack vs Raid\n RandomActionRaidWeight?: number | null;\n ChanceTableID?: string | null; // routes to StageOperations.ChanceTablesByID\n SpecialModeID?: string | null; // routes to StageOperations.SpecialModesByID\n Params?: Record<string, string> | null;\n}\n\ninterface BuildingDefinition {\n SlotIndex?: number | null;\n Name?: string | null;\n AssetPaths?: Record<string, string> | null;\n MaxLevel?: number | null;\n MaxLevelReward?: ResourceGrant | null; // one-time reward on hitting MaxLevel\n}\n\ninterface BoardStageDefinition {\n Name?: string | null;\n AssetPaths?: Record<string, string> | null;\n BoardTemplateID?: string | null; // which tile ring this stage uses\n Buildings?: BuildingDefinition[] | null;\n StageTemplateID?: string | null; // which economy template this stage uses\n CostMatrixTemplateID?: string | null;\n UnitValue?: number | null;\n Override?: BoardStageTemplate | null; // per-stage economy override (see below)\n}\n\ninterface BoardStageTemplate {\n BaseAttackReward?: ResourceGrant | null;\n BaseRaidReward?: ResourceGrant | null;\n MaxRollMultiplier?: number | null;\n MaxShields?: number | null;\n Buildings?: BuildingDefinition[] | null;\n HeistGridBonusConfig?: HeistGridBonusConfig | null;\n StageOperations?: StageOperations | null;\n TileLandingMultiplier?: RewardMultiplierRange | null;\n EconomyOverride?: StageEconomyOverride | null;\n}\n```\n\n`BoardStageTemplate` is reused both as the shared template referenced by\n`StageTemplateID` (in `StageTemplatesByID`) and as the shape of a per-stage\n`Override` block — an `Override` field, when present, wins over the\ntemplate's value for that stage.\n\n```ts\ninterface RewardMultiplierRange {\n Min?: number | null;\n Max?: number | null; // reward multiplier sampled uniformly in [Min, Max]\n}\n\ninterface StageOperations {\n OnPassStart?: ScaledResourceOperation | null; // granted on passing the start tile\n OnTileLanding?: Record<string, ScaledResourceOperation> | null; // keyed by tile type\n OnAttack?: Record<string, ScaledResourceOperation> | null; // keyed by outcome (\"Hit\"/\"Blocked\")\n OnRaid?: Record<string, ScaledResourceOperation> | null;\n OnBuild?: ScaledResourceOperation | null;\n OnStageComplete?: ScaledResourceOperation | null;\n OnAttackBonusDrops?: AttackBonusDrop[] | null; // independent probabilistic bonus drops\n ChanceTablesByID?: Record<string, ChanceTable> | null;\n SpecialModesByID?: Record<string, SpecialModeDefinition> | null;\n}\n\ninterface ScaledResourceOperation {\n Operation?: ResourceOperation | null;\n ScaleWithRollMultiplier?: boolean | null; // if true, Operation amounts scale by the roll's multiplier\n}\n\ninterface AttackBonusDrop {\n Chance?: number | null;\n RequiredOutcome?: string | null; // only rolls if the attack's Outcome matches\n Reward?: ScaledResourceOperation | null;\n Tag?: string | null;\n}\n```\n\n---\n\n## Procedural economy\n\nDrives cost/reward scaling as stages progress, independent of hand-authored\nper-stage numbers. **All of this is server-computed** — the client never\nderives attack/raid/build values itself; read the resolved amounts off each\nresponse's `Operation`/`Reward` fields. The formulas below (transcribed from\n`EconomyMath.cs` and `GameLoop.cs`) are for building cost/reward _previews_ in\nUI, not for computing anything that gets charged.\n\n```ts\ninterface ProceduralEconomyConfig {\n CostMatrixTemplatesByID?: Record<string, number[][]> | null; // named normalized cost matrices\n PerBoardGrowth?: number | null; // tail growth rate applied per stage past the last authored anchor\n VisualTemplateCycle?: string[] | null; // board-template ids cycled for synthesized (unauthored) stages\n EconomyScaledResources?: ResourceBundle | null; // which currencies/items get EconomyScale applied\n ScaleRaidStealWithEconomy?: boolean | null;\n ScaleAttackRewardWithEconomy?: boolean | null;\n AttackValueFromCostMatrix?: boolean | null;\n AttackBlockedRewardFactor?: number | null;\n ScaleVictimLossWithEconomy?: boolean | null;\n ScaleVictimLossWithTier?: boolean | null;\n SynthesizedBuildingSlots?: number | null; // building slot count for stages past authored content\n SynthesizedBuildingMaxLevel?: number | null;\n}\n\ninterface StageEconomyOverride {\n CostMatrix?: number[][] | null; // replaces the whole matrix for this stage\n PerBoardGrowth?: number | null;\n EconomyScale?: number | null; // manual override; wins over the computed Unit(N)/Unit(1) ratio\n ScaledResources?: ResourceBundle | null;\n}\n```\n\n### The board is infinite: `Unit(N)` and stage synthesis\n\n`BoardStageDefinition.UnitValue` marks a stage as an \"anchor\" — the intended\nsoft-currency cost of that stage's slot-0/level-0 building. `EconomyMath.ResolveUnitValue(N)`\n(`IDosGamesSDK/API/Client/v2/GameLoop/Services/EconomyMath.cs:50-87`) derives a\nunit price for **any** stage level `N`, authored or not:\n\n- Exact anchor match → that anchor's `UnitValue`.\n- `N` below the lowest anchor → clamped to the lowest anchor's value (no\n extrapolation backward).\n- `N` between two anchors → **geometric interpolation**:\n `lower.Unit * (upper.Unit / lower.Unit) ^ t`, where\n `t = (N - lower.Level) / (upper.Level - lower.Level)`.\n- `N` above the highest anchor → **geometric extrapolation** using a tail\n growth rate `g`: `last.Unit * g ^ (N - last.Level)`. `g` is the last\n authored stage's `Override.EconomyOverride.PerBoardGrowth` if set (> 0),\n otherwise the global `ProceduralEconomy.PerBoardGrowth` (`EconomyMath.cs:125-142`).\n\n`EconomyScale(N) = Unit(N) / Unit(1)` (`EconomyMath.cs:93-101`), i.e. the\nboard's overall reward/cost magnitude relative to stage 1 — unless a stage's\n`EconomyOverride.EconomyScale` is set (> 0), which wins outright.\n\nBecause of this, **a title's board never runs out of stages**: once a player's\n`StageLevel` exceeds the highest key in `StagesByLevel`, the server\nsynthesizes a stage on the fly (`GameLoop.cs:2899-2932`) — visuals cycle\nthrough `VisualTemplateCycle`, the economy _shape_ (which `StageTemplateID`,\ni.e. which reward/attack/raid rules apply) is inherited from the nearest\nauthored stage below it, and building slots come from `SynthesizedBuildingSlots`/\n`SynthesizedBuildingMaxLevel`. `BoardLoopState.AllStagesCompleted` exists in\nthe SDK's types but the backend never sets it — don't build UI around a \"final\nstage.\"\n\n### Build cost\n\n`EconomyMath.CalcBuildCost(matrix, unit, slot, level)` (`EconomyMath.cs:107-118`):\n\n```\nrawCost = ceil(Unit(N) × CostMatrix[slot][level])\n```\n\n`level` is the building's **current** level (0-based) before the upgrade —\ni.e. the cost to go from `level` to `level + 1`. The result then passes\nthrough the player's `EconomyTuning` cost multiplier and any active\n`BoardBuildCost`-targeted `TimedBoost` (`GameLoop.cs:1480-1485`), floored at 1.\n`EconomyScale` is **not** applied to build cost — `Unit(N)` already encodes\nthe board's cost progression on its own (`GameLoop.cs:1475-1476` comment).\n`CostMatrix` is resolved per stage: an inline `EconomyOverride.CostMatrix` on\nthe stage wins outright; otherwise it's looked up by\n`CostMatrixTemplateID`/`CostMatrixTemplatesByID` (default template id\n`\"Universal\"`, case-insensitive) — see `BoardStageResolver.cs:142-160`.\n\n### Attack reward\n\nBase attacker reward is `BaseAttackReward` scaled by the current\n`Pending.RollMultiplier`, then optionally by a \"building value weight\" and/or\n`EconomyScale`, then halved (or whatever factor) if blocked\n(`GameLoop.cs:900-918`):\n\n```\nattackWeight = AttackValueFromCostMatrix\n ? CostMatrix[targetSlot][targetBuildingLevel - 1] // value of the building actually hit\n : 1.0\nreward = BaseAttackReward × RollMultiplier (roll-scale, ScaleBundleForReward)\nreward *= attackWeight (building-value weight, if enabled)\nreward *= EconomyScale (if ScaleAttackRewardWithEconomy)\nreward *= AttackBlockedRewardFactor (only if Outcome == Blocked)\n```\n\nThe `OnAttack[outcome]` stage-hook reward (separate from `BaseAttackReward`)\nis scaled by `RollMultiplier` only, then also by `EconomyScale` if\n`ScaleAttackRewardWithEconomy` is set — it does **not** get the building-value\nweight. For a bot target, the \"building\" is a random slot/level sampled the\nsame way bot buildings are generated; for a real victim it's the actual\nbuilding about to be hit (level read **before** the hit decrements it).\n\n### Raid (Bank Heist) reward — attacker gain vs. victim loss are decoupled\n\nThese are two independent numbers (`GameLoop.cs:2370-2383` doc comment, math\nat `2521-2578`) — the attacker's gain is **not** derived from what the victim\nactually loses:\n\n```\nattackerGain = BaseRaidReward × RollMultiplier × heistMultiplier (heistMultiplier: 1/2/5, see below)\nattackerGain *= EconomyScale(attacker's own board) (if ScaleRaidStealWithEconomy)\nattackerGain *= JackpotFinalMultiplier (only if the raid variant IsJackpot)\n\nvictimLossRequested = BaseRaidReward × (heistMultiplier if ScaleVictimLossWithTier else 1)\nvictimLossRequested *= EconomyScale(victim's own board) (if ScaleVictimLossWithEconomy)\nvictimLoss = min(victimLossRequested, victim's actual balance) (clipped per-entry, never overdraws)\n```\n\n`heistMultiplier` is 1/2/5 for Small/Medium/Big (3-of-a-kind), and jackpot\nraids apply `JackpotFinalMultiplier` **only to `attackerGain`** — the victim's\nloss never carries the jackpot multiplier. If the victim's balance for a\nrequested resource is 0, that resource is simply excluded from what's taken\n(the attacker still receives their full system-side `attackerGain`\nregardless — a raid against an empty bank never fails, it just steals\nnothing). Bot targets have no real balance to clip, so a raid against a bot\nalways \"steals\" the full unclipped amount.\n\nCell bonuses (`HeistCell.OnOpenBonus`) and any `GuaranteedBonus` are\nindependent system-side grants to the attacker, scaled once at raid-creation\ntime by `RollMultiplier` and (if `ScaleRaidStealWithEconomy`) `EconomyScale` —\nthey are never clipped by the victim's balance either.\n\n---\n\n## Bank Heist (raid) config\n\n```ts\ninterface HeistGridBonusConfig {\n Variants?: HeistRaidVariant[] | null;\n}\n\ninterface HeistRaidVariant {\n Weight?: number | null; // relative chance this variant is picked for a given raid\n Tag?: string | null;\n MinBonusCells?: number | null;\n MaxBonusCells?: number | null;\n BonusPool?: WeightedHeistCellBonus[] | null;\n GuaranteedBonus?: ScaledResourceOperation | null;\n IsJackpot?: boolean | null;\n JackpotFinalMultiplier?: number | null;\n JackpotSymbolDistribution?: Record<string, number> | null; // symbol name -> cell count\n}\n\ninterface WeightedHeistCellBonus {\n Weight?: number | null;\n Bonus?: ScaledResourceOperation | null;\n Tag?: string | null;\n}\n```\n\nThe grid is always a fixed 12 cells (`digIndex` 0-11 in `boardLoopRaid`,\nmatching `zHeistCell` array length conventions used elsewhere in the module).\nA raid's specific layout (symbols, bonuses, jackpot-ness) is generated\nserver-side from a weighted `HeistRaidVariant` pick and sent down as\n`Pending.RaidLayout` — the client never generates or validates the layout.\n\n### Layout generation (server-side, `GameLoop.cs:3240-3339`)\n\n1. **Variant pick**: standard weighted selection over `Variants` — cumulative\n sum of positive `Weight`s, uniform roll in `[0, total)`. No `Variants`\n configured (or none with positive weight) → the classic fallback layout: a\n shuffled 4×Small / 4×Medium / 4×Big.\n2. **Symbols**: if the picked variant `IsJackpot` and its\n `JackpotSymbolDistribution` values sum to exactly 12, that exact symbol mix\n is used (shuffled); any other case (non-jackpot, or a distribution that\n doesn't sum to 12) falls back to the classic 4/4/4 shuffle.\n3. **Bonus cells**: if `MaxBonusCells > 0` and `BonusPool` is non-empty, a\n random count in `[MinBonusCells, MaxBonusCells]` (clamped to `[0, 12]`) of\n distinct cell indices are chosen, and each gets an independently\n weighted-picked bonus from `BonusPool` (same cumulative-weight algorithm as\n the variant pick). Each bonus is scaled once at generation time\n (`RollMultiplier` + boosts) and frozen into `HeistCell.OnOpenBonus` — it is\n not re-scaled at reveal or claim time.\n4. **`GuaranteedBonus`**, if set on the variant, is scaled the same way and\n returned separately as `Pending.GuaranteedBonus` (folded into the\n attacker's system-side gain at raid finalization, not per-cell).\n\nWin condition: **3 of the same symbol among opened cells** ends the raid,\nchecked after every dig — in `Sequential` mode this means exactly 3 opened\ncells share a symbol (checked with `==` since a 4th identical symbol can't be\ndug after the raid already ended); in `Fast` mode the whole submitted batch is\ncounted at once (checked with `>=` since a client could submit more than 3\nmatching indices in one call). `RaidOutcome`/`Status` mapping: 3×Small →\n`\"Small\"` outcome / `heistMultiplier` 1, 3×Medium → `\"Medium\"` / 2, 3×Big →\n`\"Big\"` / 5; if the raid's variant `IsJackpot`, the outcome is forced to\n`\"Jackpot\"` regardless of which symbol matched, and `JackpotFinalMultiplier`\nis applied to the attacker's gain only (see the raid reward formula above).\n`RaidResponse.Status` on the terminal call is `\"FINISHED_\" + tier` in\nupper-case (`\"FINISHED_SMALL\"`, `\"FINISHED_MEDIUM\"`, `\"FINISHED_BIG\"`,\n`\"FINISHED_JACKPOT\"`) — never a bare `\"Complete\"`.\n\n---\n\n## Chance tables\n\n```ts\ninterface ChanceTable {\n Outcomes?: ChanceOutcome[] | null;\n}\n\ninterface ChanceOutcome {\n Weight?: number | null;\n OutcomeID?: string | null;\n Reward?: ScaledResourceOperation | null;\n ForceAction?: string | null; // can force the tile to also trigger Attack/Raid/Special\n SpecialModeID?: string | null;\n}\n```\n\nA `Chance`-type tile resolves one weighted `ChanceOutcome` server-side on\nlanding; the outcome's `Reward` (and possibly `ForceAction`) is what shows up\nin the `boardLoopRoll` response — there's no separate \"resolve chance\" method.\n\n---\n\n## Special mode config\n\n```ts\ninterface SpecialModeDefinition {\n Choices?: SpecialModeChoice[] | null;\n OfferExpireSeconds?: number | null; // how long the offer stays choosable\n ClaimExpireSeconds?: number | null; // how long a claim stays valid after the window\n}\n\ninterface SpecialModeChoice {\n ChoiceID?: string | null;\n Mode?: string | null; // \"Instant\" | \"Timed\"\n Reward?: ScaledResourceOperation | null; // used directly for Instant\n DurationSeconds?: number | null; // Timed window length\n PriceOptions?: Record<string, PriceOption> | null; // ways to pay entry; the selected one is charged on boardSpecialChoose\n Multipliers?: SpecialClaimMultipliers | null;\n Gradation?: SpecialGradationConfig | null;\n}\n\ninterface SpecialClaimMultipliers {\n PerAdMultiplierRange?: RewardMultiplierRange | null; // range each ad-view multiplier is sampled from\n MaxAdViews?: number | null; // cap on boardSpecialApplyMultiplier calls (enforced as-configured; no hidden hard ceiling)\n AdCreditCost?: number | null;\n FormulaKind?: string | null; // \"Additive\" (default) | \"Multiplicative\" — the only two values the backend defines\n ApplyMultiplierOnEarlyClaim?: boolean | null; // default false\n}\n\ninterface SpecialGradationConfig {\n Tiers?: SpecialGradationTier[] | null; // reward grows the longer the player waits\n BelowFirstTierReward?: ScaledResourceOperation | null; // paid if claimed before the first tier\n}\n\ninterface SpecialGradationTier {\n ElapsedSeconds?: number | null;\n Reward?: ScaledResourceOperation | null;\n}\n```\n\n### Multiplier accumulation and claim formulas (`GameLoop.cs:1981-2254`)\n\nEach `boardSpecialApplyMultiplier` call rolls one step uniformly from\n`PerAdMultiplierRange` (`[Min, Max]`, or the fixed value if `Min == Max`) and\nfolds it into `AccumulatedMultiplier`:\n\n```\nFormulaKind \"Additive\" (default): AccumulatedMultiplier += rolled (starts at 0.0)\nFormulaKind \"Multiplicative\": AccumulatedMultiplier *= rolled (starts at 1.0)\n```\n\n`MaxAdViews` is enforced exactly as configured — there is no separate\nhardcoded safety ceiling beyond it.\n\nAt `boardSpecialClaim`, the gradation tier reached determines the _base_\nreward snapshot, and a separate `finalMultiplier` is applied on top of it:\n\n- **Tier selection**: walk the pre-sorted (ascending `ElapsedSeconds`) tier\n list and take the **highest** tier whose `ElapsedSeconds` threshold has\n already elapsed. If none has elapsed yet, `reachedTierIndex = -1` and the\n claim uses `BelowFirstTierReward` — but only if that field is configured;\n otherwise the claim is rejected with `\"Play window not closed yet\"` (there's\n no way to claim nothing).\n- **`IsEarlyClaim`** is `reachedTierIndex < 0` specifically — i.e., true only\n for the below-first-tier case. Reaching tier 0 (the very first configured\n tier) already counts as a real claim, `IsEarlyClaim = false`.\n- **`finalMultiplier`**: if early **and** `ApplyMultiplierOnEarlyClaim` is\n `false` (the default), `finalMultiplier = 1.0` — every ad-view multiplier\n rolled is discarded. Otherwise: `\"Additive\"` → `1 + AccumulatedMultiplier`;\n `\"Multiplicative\"` → `AccumulatedMultiplier` itself (floored to 1.0 if it\n somehow ended up ≤ 0).\n- The tier's frozen `Reward` snapshot (or `BelowFirstTierReward`) is cloned and\n every amount multiplied by `finalMultiplier`, then granted.\n\nResponse-side preview types mirror the config but strip fields the client\nshouldn't see ahead of time:\n\n```ts\ninterface SpecialModeChoicePreview {\n ChoiceID?: string | null;\n Mode?: string | null;\n Reward?: ScaledResourceOperation | null;\n DurationSeconds?: number | null;\n PriceOptions?: Record<string, PriceOption> | null;\n Multipliers?: SpecialClaimMultipliers | null;\n // (no Gradation — gradation tiers are resolved and applied server-side at claim time)\n}\n\ninterface SpecialModeOfferData {\n ModeID?: string | null;\n Choices?: SpecialModeChoicePreview[] | null;\n}\n```\n\n---\n\n## Community Chest: config\n\nPart of `GameLoopDefinitions.CommunityChest` (loaded via `getGameLoops()`,\ncached under section `\"GameLoop\"` — not `\"BoardDefinition\"`).\n\n```ts\ninterface CommunityChestDefinition {\n IsActive?: boolean | null; // feature kill-switch\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n AnchorUtc?: string | null; // reference time rounds are scheduled from\n RoundDurationSec?: number | null;\n PauseBetweenRoundsSec?: number | null;\n MaxRounds?: number | null; // lifetime cap on rounds per group/player, if any\n PartnerCount?: number | null; // target group size\n MatchmakingTimeoutMinutes?: number | null; // how long a \"Forming\" group waits before bot-fill\n MemberGracePeriodMinutes?: number | null; // declared but currently unused by the backend (see note below)\n ContributionMin?: number | null; // per-roll contribution range\n ContributionMax?: number | null;\n ScaleContributionWithRollMultiplier?: boolean | null;\n MaxProgress?: number | null; // shared meter's completion threshold\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; shared Core/Milestone block\n GrandPrize?: ResourceGrant | null;\n}\n```\n\n`Milestones` uses the same shared `MilestoneDefinition` type as other modules\nin the SDK (imported from `../_shared/MilestoneModels`) — it is not a\nGameLoop-specific shape, so if you've already read that reference elsewhere,\nit applies unchanged here.\n\n**`MemberGracePeriodMinutes` has no reader anywhere in `CommunityChestService.cs`\n/ `CommunityChestDBService.cs`** — it's a declared-but-dead config field today.\n`LeaveAsync` marks the leaving member `\"Left\"` immediately and, if that empties\nout a still-`\"Forming\"` group, flips it straight to `\"Failed\"` with no grace\nwindow. Don't build UI implying a departed slot gets a grace period before\nbackfill.\n\n### Round scheduling and matchmaking (`CommunityChestService.cs`)\n\nRounds are a **title-wide schedule**, not per-player: `ComputeRound` derives\nthe current round purely from wall-clock time (`CommunityChestService.cs:628-653`):\n\n```\ncycle = RoundDurationSec + max(0, PauseBetweenRoundsSec)\nelapsed = now - AnchorUtc\nroundIndex = floor(elapsed / cycle)\nposInCycle = elapsed - roundIndex * cycle\nround is active only if: now >= AnchorUtc, posInCycle < RoundDurationSec,\n and (MaxRounds <= 0 || roundIndex < MaxRounds)\n```\n\nSo `MaxRounds` caps the number of rounds ever scheduled title-wide (once\nexhausted, the feature goes permanently inactive for everyone) — it is not a\nper-player attempt limit. During the `PauseBetweenRoundsSec` gap between two\nrounds, `joinOrCreateCommunityChest` fails with `\"No active Community Chest\nround.\"`.\n\n`joinOrCreateCommunityChest` (`JoinOrCreateAsync`): group size is\n`1 + max(0, PartnerCount)`. It looks for an existing `\"Forming\"` group for the\ncurrent round with free slots; if none exists it creates one; if the found\ngroup is full or a race loses the OCC-guarded join, it retries up to 5 times\nbefore failing with `\"Matchmaking failed after retries. Please try again.\"`\nBot-filling is **lazy, not proactive** — `TryFillWithBotsIfNeeded` only runs\nwhen a player's state is actually read (`getCommunityChestState` or another\n`joinOrCreateCommunityChest` call) and checks\n`now >= group.CreatedAtUtc + MatchmakingTimeoutMinutes`; if so, it fills every\nremaining slot with bots and flips the group to `\"Active\"` in one shot (no\npartial/gradual backfill).\n\nContribution per landing on the `CommunityChest` tile (`TryContributeAsync`):\n\n```\ndelta = uniform_random(ContributionMin, ContributionMax) // inclusive; min if Max<=Min\nif ScaleContributionWithRollMultiplier and usedMultiplier > 1: delta *= usedMultiplier\nnewProgress = min(oldProgress + delta, MaxProgress) // clipped, never overshoots\ngroup.Status becomes \"Completed\" the instant newProgress >= MaxProgress\n```\n\n---\n\n## Community Chest: player + group state\n\n```ts\ninterface UserCommunityChestState {\n ActiveGroupID?: string | null;\n ActiveRoundIndex?: number | null;\n History?: CommunityChestHistoryEntry[] | null; // recent completed rounds\n}\n\ninterface CommunityChestHistoryEntry {\n GroupID?: string | null;\n RoundIndex?: number | null;\n FinalStatus?: CommunityChestStatus | null;\n GrandPrizeReceived?: boolean | null;\n FinishedAtUtc?: string | null;\n}\n\n// enum CommunityChestStatus\ntype CommunityChestStatus =\n \"Forming\" | \"Active\" | \"Completed\" | \"Failed\" | \"Expired\";\n\n// enum CommunityChestMemberStatus\ntype CommunityChestMemberStatus = \"Active\" | \"Left\" | \"Replaced\";\n\ninterface CommunityChestGroupDocument {\n GroupID?: string | null;\n TitleID?: string | null;\n RoundIndex?: number | null;\n Members?: CommunityChestMember[] | null;\n SharedProgress?: CommunityChestSharedState | null;\n Status?: CommunityChestStatus | null;\n CreatedAtUtc?: string | null;\n ExpiresAtUtc?: string | null;\n Version?: number | null;\n}\n\ninterface CommunityChestMember {\n UserID?: string | null;\n PublicData?: UserPublicDataModel | null;\n IsBot?: boolean | null; // groups may be filled out with bots if matchmaking times out\n ContributionPoints?: number | null;\n ClaimedMilestoneIDs?: string[] | null;\n GrandPrizeClaimed?: boolean | null;\n MemberStatus?: CommunityChestMemberStatus | null;\n JoinedAtUtc?: string | null;\n LeftAtUtc?: string | null;\n}\n\ninterface CommunityChestSharedState {\n CurrentProgress?: number | null;\n MaxProgress?: number | null;\n}\n```\n\n`UserCommunityChestState` (the small per-player pointer) is what's cached at\n`client.data.user.state?.GameLoop?.CommunityChest`. The richer\n`CommunityChestGroupDocument` (full member list + shared meter) is **not**\nseparately cached — it only comes back inline in\n`CommunityChestUserStateResponse.ActiveGroup` and\n`CommunityChestGroupStateResponse.Group`; hold onto the response if you need\nto render the roster/leaderboard, or re-call `getCommunityChestState()`.\n\n---\n\n## Responses\n\n```ts\ninterface RollActionData {\n TargetUserID?: string | null;\n IsBot?: boolean | null;\n PublicData?: UserPublicDataModel | null;\n TargetBuildingStates?: BuildingState[] | null;\n TargetHasShield?: boolean | null;\n}\n\ninterface CommunityChestContributionResult {\n GroupID?: string | null;\n Delta?: number | null; // points added by this roll\n NewProgress?: number | null;\n MaxProgress?: number | null;\n UnlockedMilestoneIDs?: string[] | null; // newly crossed this roll\n Completed?: boolean | null; // meter hit MaxProgress\n}\n\ninterface BoardRollResponse {\n UsedMultiplier?: number | null;\n Steps?: number | null; // dice total this roll\n OldPosition?: number | null;\n NewPosition: number;\n CyclesCompletedDelta?: number | null;\n LandedTileType?: string | null;\n Operation?: ResourceOperation | null; // e.g. OnTileLanding / OnPassStart reward\n ActionRequired?: string | null; // \"ATTACK\" | \"RAID\" — mirrors Pending.Type when set\n ActionData?: RollActionData | null;\n SpecialModeOffer?: SpecialModeOfferData | null;\n CommunityChestContribution?: CommunityChestContributionResult | null;\n}\n\ninterface AttackResponse {\n Outcome?: string | null; // \"Hit\" | \"Blocked\"\n IsBotTarget?: boolean | null;\n BuildingIndexHit?: number | null;\n Operation?: ResourceOperation | null; // bot fights: full resource delta\n DualResult?: ResourceDualPartyResult | null; // PvP fights: FromResult/ToResult split\n}\n\ninterface RaidResponse {\n Status?: string | null; // \"CONTINUE\" | (a terminal status, e.g. \"Complete\")\n Outcome?: string | null;\n FoundSymbol?: string | number | null;\n FoundBonus?: ResourceGrant | null; // already-scaled snapshot for the dug cell\n OpenedIndex?: number | null;\n AttemptsLeft?: number | null;\n RaidLayout?: HeistCell[] | null;\n HeistVariantTag?: string | null;\n IsJackpot?: boolean | null;\n Operation?: ResourceOperation | null;\n DualResult?: ResourceDualPartyResult | null;\n}\n\ninterface BuildResponse {\n BuiltIndex: number;\n NewLevel?: number | null;\n StageComplete?: boolean | null;\n MaxLevelRewardClaimed?: boolean | null;\n Operation?: ResourceOperation | null;\n}\n\ninterface SpecialChooseResponse {\n Mode?: string | number | null; // \"Instant\"/\"Timed\" or 0/1\n Operation?: ResourceOperation | null; // Instant reward, granted immediately\n DurationSeconds?: number | null;\n StartedAtUtc?: string | null;\n ExpiresAtUtc?: string | null;\n}\n\ninterface SpecialApplyMultiplierResponse {\n AdViewsUsed?: number | null;\n RolledMultiplier?: number | null; // this call's sampled multiplier step\n AccumulatedMultiplier?: number | null; // running total across all ad views\n RemainingAdViews?: number | null;\n}\n\ninterface SpecialClaimResponse {\n FinalMultiplier?: number | null;\n AdViewsUsed?: number | null;\n AccumulatedMultiplier?: number | null;\n IsEarlyClaim?: boolean | null;\n Operation?: ResourceOperation | null;\n}\n\ninterface CommunityChestUserStateResponse {\n ServerTimeUtc?: string | null;\n UserState?: UserCommunityChestState | null;\n ActiveGroup?: CommunityChestGroupDocument | null;\n SecondsRemaining?: number | null;\n}\n\ninterface CommunityChestGroupStateResponse {\n ServerTimeUtc?: string | null;\n Group?: CommunityChestGroupDocument | null;\n SecondsRemaining?: number | null;\n}\n\ninterface CommunityChestClaimResponse {\n ServerTimeUtc?: string | null;\n GroupID?: string | null;\n RewardType?: string | null; // \"Milestone\" | \"GrandPrize\"\n MilestoneID?: string | null;\n Resources?: ResourceOperation | null;\n}\n\ninterface CommunityChestLeaveResponse {\n ServerTimeUtc?: string | null;\n GroupID?: string | null;\n Success?: boolean | null;\n}\n```\n\n`ResourceGrant`, `ResourceConsume`, `ResourceOperation`, `ResourceBundle`, and\n`ResourceDualPartyResult` are the shared resource types used across the whole\nSDK (`../_shared/ResourceModels`) — same shapes as in other modules' rewards\nand costs.\n\n---\n\n## Request shape + action ids\n\n```ts\ninterface GameLoopRequest extends BaseRequest {\n RollMultiplier?: number;\n BuildingIndex?: number;\n DigIndex?: number;\n StageLevel?: number;\n DigIndices?: number[];\n ChoiceID?: string;\n GroupID?: string;\n MilestoneID?: string;\n}\n```\n\nEvery mutating board call (`boardLoopRoll`, `boardLoopAttack`,\n`boardLoopRaid`, `boardLoopRaidFast`, `boardLoopBuild`, `boardSpecialChoose`,\n`boardSpecialApplyMultiplier`, `boardSpecialClaim`) attaches a client-generated\n`RelatedEntityID` idempotency-style key (`\"<verb>_<userID>_<uuid>\"`) — this is\ninternal plumbing, not something you construct yourself, but it explains why\ntwo rapid duplicate calls are two distinct server operations rather than being\ndeduped (see Gotchas in SKILL.md).\n\n`GameLoopAction` is the string-enum of every action id\n(`GetGameLoops`, `GetBoardDefinition`, `GetBoardDefinitionForLevel`,\n`GetUserBoardState`, `BoardLoopRoll`, `BoardLoopAttack`, `BoardLoopRaid`,\n`BoardLoopRaidFast`, `BoardLoopBuild`, `BoardSpecialChoose`,\n`BoardSpecialApplyMultiplier`, `BoardSpecialClaim`, `GetCommunityChestState`,\n`JoinOrCreateCommunityChest`, `ClaimCommunityChestMilestone`,\n`ClaimCommunityChestGrandPrize`, `LeaveCommunityChest`) — used internally for\nrouting; you won't need to reference it directly when calling\n`client.gameLoop.*` methods.\n\n---\n\n## Cache mutation rules\n\nWhat each method writes into `client.data.user.state?.GameLoop`, precisely\n(all board writes emit `user:gameLoopUpdated` + `user:anyUpdated`; Community\nChest writes emit the same two events):\n\n| Method | Cache effect |\n| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `getUserBoardState` | Full replace: `Board = data`. |\n| `boardLoopRoll` | Patches `Position`, `CyclesCompleted` (+= delta), `LastRollAtUtc`; sets `Pending` from `ActionRequired`/`ActionData` or `SpecialModeOffer` (or leaves it as-is if neither is set — it is **not** explicitly cleared on a plain-tile roll); applies `Operation` if present. |\n| `boardLoopAttack` | Clears `Pending` unconditionally. Applies `Operation` (bot target) or `DualResult.FromResult` (PvP). On failure, re-fetches `getUserBoardState()` instead of patching. |\n| `boardLoopRaid` | If `Status === \"CONTINUE\"`: patches `Pending.RaidLayout` + appends to `Pending.OpenedIndices`. Otherwise: clears `Pending`, applies `Operation`/`DualResult.FromResult`. On failure, re-fetches state. |\n| `boardLoopRaidFast` | Only acts if `Status !== \"CONTINUE\"`: clears `Pending`, applies reward. A `CONTINUE` result is a no-op on the cache. On failure, re-fetches state. |\n| `boardLoopBuild` | If `StageComplete`: `StageLevel += 1`, `Position = 0`, `Pending = null`, `BuildingStates = null`. Else: patches (creates if missing) the `BuildingStates` entry for `BuiltIndex` — sets `Level`, clears `IsDamaged`, sets `MaxLevelRewardClaimed` if granted. Applies `Operation` either way. |\n| `boardSpecialChoose` | Takes an optional `selectedOptionID` (`PriceOption.OptionID`; omitted = the first option available on this platform — a loop turn is never paid in a store). If `Mode` is Instant: clears `Pending`. Else (Timed): sets/creates `Pending.Special` (`ChoiceID`, `ChosenMode`, `StartedAtUtc`, `DurationSeconds`, resets `AdViewsUsed = 0`). Applies `Operation` if present (e.g. an entry-cost charge). |\n| `boardSpecialApplyMultiplier` | Patches `Pending.Special.AdViewsUsed` and `AccumulatedMultiplier`. |\n| `boardSpecialClaim` | Clears `Pending`. Applies `Operation`. |\n| `getCommunityChestState` | Full replace: `CommunityChest = data.UserState ?? {}`. |\n| `joinOrCreateCommunityChest` | If a group came back: sets `CommunityChest.ActiveGroupID`/`ActiveRoundIndex` (partial patch, not a full replace). |\n| `claimCommunityChestMilestone` | No `CommunityChest` state patch — only applies `Resources` to currency/item balances. (Milestone-claimed tracking lives server-side on the group document, not mirrored locally.) |\n| `claimCommunityChestGrandPrize` | Same as milestone claim: applies `Resources` only. |\n| `leaveCommunityChest` | If `Success`: clears `ActiveGroupID` (`undefined`) and sets `ActiveRoundIndex = -1`. |\n\nNote the asymmetry: `boardLoopRoll` never explicitly nulls out `Pending` for a\nplain (non-action, non-special) tile landing — in practice this is safe\nbecause a plain landing only happens when there was no prior unresolved\n`Pending` (the server won't let you roll again while one is open), so there is\nnothing stale to clear. If you're debugging a \"stale Pending\" UI bug, this is\nthe first place to look.\n"
9
9
  }
10
10
  ]
11
11
  }