@idosgames/mcp 0.1.7 → 0.1.8

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?:\n | \"Item\"\n | \"VirtualCurrency\"\n | \"CryptoCurrency\"\n | \"Purchase\"\n | \"RewardedVideoCredit\"; // ResourceEntryType\n CurrencyID?: string; // set when Type is a currency kind\n Amount?: number; // integer amount; C# `long` on the wire, parsed via zVcAmount (exact up to 2^53-1)\n CatalogID?: string; // set when Type is \"Item\": which catalog\n ItemID?: string; // set when Type is \"Item\": which item definition\n ProductID?: string; // set when Type is \"Purchase\": the IAP product that pays for this entry\n}\n```\n\nOnly the fields relevant to `Type` are populated — e.g. a `VirtualCurrency`\nentry sets `CurrencyID` + `Amount` and leaves `CatalogID`/`ItemID` unset; an\n`Item` entry sets `CatalogID`/`ItemID` (+ `Amount` for stackable quantity) and\nleaves `CurrencyID` unset.\n\n### ResourceBundle\n\nA flat list of entries, plus optional event-token deltas:\n\n```ts\ninterface ResourceBundle {\n Entries?: ResourceEntry[] | null;\n EventTokens?: EventTokenOperation[] | null;\n}\n```\n\n### PremiumTierBundle\n\nAn alternate bundle that only applies if the player holds a qualifying\npremium tier — used inside `ResourceGrant`/`ResourceConsume` to express\n\"VIPs get a better grant / a cheaper cost.\"\n\n```ts\ninterface PremiumTierBundle {\n MinPremiumTier?: number;\n RequiredPremiumID?: string;\n Resources?: ResourceBundle | null;\n}\n```\n\n### ResourceGrant\n\nWhat a player receives.\n\n```ts\ninterface ResourceGrant {\n Standard?: ResourceBundle | null; // baseline grant, always applies\n PremiumBonuses?: unknown[] | null; // reserved/opaque bonus list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated additional/alternate grants\n}\n```\n\n### ResourceConsume\n\nWhat a player is charged. Mirror-shaped to `ResourceGrant`, but the\ntier-based array is a **discount** mechanism rather than a bonus one — see\nGotchas below.\n\n```ts\ninterface ResourceConsume {\n Standard?: ResourceBundle | null; // baseline cost\n PremiumDiscounts?: unknown[] | null; // reserved/opaque discount list\n PremiumTiers?: PremiumTierBundle[] | null; // tier-gated reduced/alternate cost\n}\n```\n\n### ResourceOperation\n\nThe full manifest for one action: what's granted and what's consumed. This is\nthe shape every \"did an action succeed\" response embeds under a `Resources`\nfield (e.g. `DepositNFTResponse.Resources`, `NFTWithdrawalResponse.Resources`\nin Blockchain).\n\n```ts\ninterface ResourceOperation {\n Grant?: ResourceGrant | null;\n Consume?: ResourceConsume | null;\n}\n```\n\nEither side can be `null`/absent — a pure grant (no cost) sets only `Grant`;\na pure charge (no payout) sets only `Consume`.\n\n### EventTokenOperation / EventTokenAddress\n\nEvent tokens are a lighter-weight counter mechanic (e.g. season/event\ncurrency) addressed by an entity rather than a flat `CurrencyID`:\n\n```ts\ninterface EventTokenAddress {\n Type?: string; // EventTokenType — which kind of entity owns the token bucket\n EntityID: string;\n}\n\ninterface EventTokenOperation {\n Address?: EventTokenAddress | null;\n Amount?: number;\n Source?: string; // free-form provenance tag\n}\n```\n\n### ResourceDualPartyResult / ResourceTransferResult\n\nUsed by PvP/transfer-style features where two accounts are affected by one\naction:\n\n```ts\n// Each side gets its own independent grant/consume manifest.\ninterface ResourceDualPartyResult {\n FromUserID?: string;\n ToUserID?: string;\n FromResult?: ResourceOperation | null;\n ToResult?: ResourceOperation | null;\n}\n\n// A straight transfer: one bundle moves from one account to another.\ninterface ResourceTransferResult {\n FromUserID?: string;\n ToUserID?: string;\n Transferred?: ResourceBundle | null;\n}\n```\n\n---\n\n## How the SDK applies a ResourceOperation\n\nEvery module that returns a `ResourceOperation` (directly, or via a\n`Resources` field) has already had it **applied server-side**; the SDK's job\nis only to mirror it into the local cache so balances/inventory read\ncorrectly without a re-fetch. Internally this goes through\n`UserData.applyResourceOperation(op, itemDefs)`, which:\n\n- walks `Consume.Standard.Entries` and `Grant.Standard.Entries` (the\n `PremiumDiscounts`/`PremiumTiers`/`PremiumBonuses` arrays describe _why_ the\n standard amount is what it is — the server has already resolved them into\n `Standard` before sending the response; the client does not re-apply tiers),\n- for `VirtualCurrency` entries, adjusts the integer balance and emits\n `user:virtualCurrencyUpdated`,\n- for `Item` entries, adjusts stackable counts / creates unstackable instances\n and emits `user:inventoryUpdated`,\n- for `EventTokens`, adjusts the addressed token bucket and emits\n `user:eventTokenUpdated`,\n- always emits the umbrella `user:anyUpdated` when anything changed.\n\n`CryptoCurrency` amounts do **not** flow through this integer pipeline —\nthey're decimal and go through a separate patch\n(`UserData.patchCryptoCurrencyDelta(currencyID, delta, serverTimeUtc)`), which\nis what `CurrencyService.cryptoConvert` and the Blockchain deposit/withdrawal\nmethods use directly instead of embedding crypto deltas in a\n`ResourceOperation`.\n\n**Practical takeaway when building UI in any module:** don't hand-roll cost\npreviews from `PremiumDiscounts`/`PremiumTiers` internals unless you're\nexplicitly building a \"your VIP tier saves you N%\" comparison — for \"what will\nthis cost me right now,\" prefer the value the server already resolved\n(`Standard`, or the flat response fields like `ConvertResponse.SourceSpent`).\nTreat `Amount` on VC entries as a signed integer conceptually (consume vs.\ngrant is which container it's in, not a negative number) and crypto strings as\nopaque decimal values to hand to `decimal.js`, not to parse with `Number()`\nonce you're near precision limits.\n"
8
+ "content": "# Currency data model — reference\n\nFull shape of the `CurrencyDefinitions` config, and — the canonical\ndocumentation for the whole SDK — the shared `ResourceConsume` /\n`ResourceGrant` / `ResourceOperation` / `ResourceEntry` cost-and-reward\nprimitives. All types are **strictly typed** and exported from\n`@idosgames/core`; every object schema keeps `.passthrough()`, so a field the\nbackend adds later still round-trips instead of being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: CurrencyDefinitions](#config-currencydefinitions)\n- [VirtualCurrencyDefinition](#virtualcurrencydefinition)\n- [CryptoCurrencyDefinition](#cryptocurrencydefinition)\n- [Shared conversion config](#shared-conversion-config)\n- [Backend conversion formulas](#backend-conversion-formulas) — transcribed from `ConversionService.cs`\n- [The shared resource primitives](#the-shared-resource-primitives) — canonical home\n - [ResourceEntry](#resourceentry)\n - [ResourceBundle](#resourcebundle)\n - [PremiumTierBundle](#premiumtierbundle)\n - [ResourceGrant](#resourcegrant)\n - [ResourceConsume](#resourceconsume)\n - [ResourceOperation](#resourceoperation)\n - [EventTokenOperation / EventTokenAddress](#eventtokenoperation--eventtokenaddress)\n - [ResourceDualPartyResult / ResourceTransferResult](#resourcedualpartyresult--resourcetransferresult)\n- [How the SDK applies a ResourceOperation](#how-the-sdk-applies-a-resourceoperation)\n\n---\n\n## Config: CurrencyDefinitions\n\n```ts\ninterface CurrencyDefinitions {\n VirtualCurrencies?: Record<string, VirtualCurrencyDefinition> | null;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition> | null;\n}\n```\n\nKey in both maps is the `CurrencyID`. A currency is \"known\" iff it has an\nentry in one of these maps under its `CurrencyType` (`Virtual` or `Crypto`).\n\n---\n\n## VirtualCurrencyDefinition\n\n```ts\ninterface VirtualCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>; // \"icon\", ...\n Economy?: {\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string; // ISO timestamp\n InitialDeposit?: number; // starting balance for new players\n MinBalance?: number;\n MaxBalance?: number;\n DailyEarnLimit?: number;\n DailySpendLimit?: number;\n };\n Recharge?: {\n // Energy-style auto-regen, credited in BATCHES: every FULL `Period` seconds the\n // player gets `Rate` units at once, up to `Max`. Rate=5/Period=60 means \"+5 once a\n // minute\", NOT \"+1 every 12 seconds\" — an incomplete period credits nothing.\n // The batch is clipped exactly at `Max` (if less than Rate is missing, only the\n // remainder is credited); at or above `Max` nothing is credited.\n // `Max` is the auto-recharge cap only — explicit grants may exceed it, up to\n // Economy.MaxBalance.\n Rate?: number;\n Max?: number;\n Period?: number;\n };\n Conversion?: CurrencyConversion; // see below\n Permissions?: {\n IsTradable?: boolean;\n IsPurchasable?: boolean;\n IsRefundable?: boolean;\n };\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n```\n\n`Status` governs whether the currency is usable/visible; `\"Maintenance\"` /\n`\"Deprecated\"` currencies typically reject conversions server-side even if\n`Conversion.Enabled` is true.\n\n---\n\n## CryptoCurrencyDefinition\n\n```ts\ninterface CryptoCurrencyDefinition {\n CurrencyID: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n DisplayDecimals?: number; // UI rounding, not wire precision\n ValueInUSD?: string; // decimal string\n ValueInUSDUpdatedAt?: string;\n DeveloperDepositSharePercent?: string; // decimal string; see blockchain-system\n Networks?: CryptoNetworkBinding[]; // per-chain bindings\n Limits?: {\n DailyWithdrawUsd?: string;\n MonthlyWithdrawUsd?: string;\n KycRequiredAboveUsd?: string;\n };\n Permissions?: {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n SpendableInGame?: boolean;\n ConvertibleToVirtual?: boolean; // gates cryptoConvert eligibility\n };\n Conversion?: CurrencyConversion;\n Audit?: { CreatedAt?: string; UpdatedAt?: string };\n Status?: \"Active\" | \"Hidden\" | \"Deprecated\" | \"Maintenance\";\n}\n\ninterface CryptoNetworkBinding {\n NetworkID: string;\n ContractAddress?: string;\n Decimals?: number; // on-chain token decimals\n 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": "game-loop-system",
3
3
  "description": "Build a board-style core game loop on the iDosGames TypeScript SDK (@idosgames/core) via client.gameLoop (GameLoopService): roll dice around a board, attack/raid other players' or bots' cities, build up buildings, resolve Special (Instant/Timed) tile choices, and run the cooperative Community Chest group meter. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a dice/roll board loop, an attack or raid/heist mini-game, city building, survival/Special tile events, or a co-op group-progress feature — or otherwise touches client.gameLoop, GameLoopService, GameLoopModels, BoardLoopState, BoardLoopDefinition, or CommunityChest — even if they don't name the module explicitly. templates/board-game is built entirely on this module.",
4
- "content": "---\nname: game-loop-system\ndescription: >-\n Build a board-style core game loop on the iDosGames TypeScript SDK\n (@idosgames/core) via client.gameLoop (GameLoopService): roll dice around a\n board, attack/raid other players' or bots' cities, build up buildings,\n resolve Special (Instant/Timed) tile choices, and run the cooperative\n Community Chest group meter. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n dice/roll board loop, an attack or raid/heist mini-game, city building,\n survival/Special tile events, or a co-op group-progress feature — or\n otherwise touches client.gameLoop, GameLoopService, GameLoopModels,\n BoardLoopState, BoardLoopDefinition, or CommunityChest — even if they don't\n name the module explicitly. templates/board-game is built entirely on this\n module.\n---\n\n# Game loop system (iDosGames TS SDK)\n\nThe GameLoop module ships a board-style core loop: a player rolls dice, moves\naround a ring of tiles, and lands on tiles that trigger attacking another\nplayer's (or a bot's) city, raiding a heist grid for bonuses, building up their\nown buildings, or a special timed/instant reward choice. A separate, related\nfeature bundled in the same module — **Community Chest** — is a cooperative\ngroup meter: players join a small group, every board roll contributes points to\none shared progress bar, and the group claims milestone/grand-prize rewards\ntogether. They share one config root and one player-state root\n(`GameLoop.Board` / `GameLoop.CommunityChest`) but otherwise don't interact.\n\nEverything is **server-authoritative**: the client asks the backend to\nroll/attack/raid/build/choose/claim, the backend validates and resolves it, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever compute outcomes (dice results, bot behavior, raid layouts, rewards)\nyourself — you call a method, check the result, and render from the cache.\n\nThis skill is for **using** the production `GameLoopService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(no pending interaction, wrong stage, cooldown, insufficient funds) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Mental model\n\n**Board loop** — one state machine per player:\n\n1. `boardLoopRoll` moves the player's token and returns what tile they landed\n on. The response may carry `ActionRequired` (`\"ATTACK\"` or `\"RAID\"`) or a\n `SpecialModeOffer` — when present, the SDK stashes it as `Board.Pending` and\n the UI must resolve it before the player can roll again.\n2. Depending on `Pending.Type`, the UI drives one of: `boardLoopAttack`,\n `boardLoopRaid` / `boardLoopRaidFast`, or `boardSpecialChoose` →\n (`boardSpecialApplyMultiplier`) → `boardSpecialClaim`.\n3. `boardLoopBuild` is independent of rolling — any time a building slot exists\n and the player can afford the upgrade, they can build. Reaching every\n building's `MaxLevel` on the current stage returns `StageComplete`, which\n resets `Position` to 0 and bumps `StageLevel`. The board is **infinite** —\n stages past the title's authored content are procedurally synthesized\n server-side (visuals cycle, economy scales via the stage's `Unit(N)`), so\n there's no \"last stage\" a player can actually reach; don't build UI around\n an end state (see Gotchas).\n\n**Community chest** — a small, mostly independent co-op side-feature:\n\n1. `getCommunityChestState` tells the player if they're in an active/forming\n group and how much time is left in the round.\n2. `joinOrCreateCommunityChest` puts them in a group (existing or new).\n3. Regular `boardLoopRoll` calls (not a separate action) contribute points to\n the group's shared meter — read `BoardRollResponse.CommunityChestContribution`\n for the delta/unlocked milestones on each roll.\n4. `claimCommunityChestMilestone` / `claimCommunityChestGrandPrize` pull\n rewards once thresholds are hit; `leaveCommunityChest` exits early.\n\nFor the full field-by-field shape of both sub-systems (stage/tile config,\nheist raid variants, Special gradation tiers, Community Chest group document),\nread [references/data-model.md](references/data-model.md). You do **not** need\nit to call the methods — only to drive richer UI off the config (tile art,\nbuilding names, milestone thresholds).\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 gameLoop = client.gameLoop; // the GameLoopService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods — board loop\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. \"No pending interaction\", \"Raid already in\nprogress\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------- |\n| `getGameLoops()` | Load the root GameLoop config (Board + CommunityChest configs). | `GameLoopDefinitions` |\n| `getBoardDefinition(silent?)` | Load the current-stage board config (tiles, buildings, economy). | `BoardLoopDefinition` |\n| `getBoardDefinitionForLevel(stageLevel, silent?)` | Load the board config for a specific stage (e.g. to preview). | `BoardLoopDefinition` |\n| `getUserBoardState()` | Load this player's board state (position, buildings, pending). | `BoardLoopState` |\n| `boardLoopRoll(rollMultiplier?)` | Roll dice and move the token (default multiplier 1). | `BoardRollResponse` (`NewPosition`, `Steps`) |\n| `boardLoopAttack(buildingIndex?)` | Resolve a pending ATTACK (pass `-1` or omit for auto-target). | `AttackResponse` (`Outcome`, `Operation`) |\n| `boardLoopRaid(digIndex, existingRelatedEntityID?)` | Dig one heist cell (Sequential raid mode), `digIndex` 0-11. | `RaidResponse` (`Status`, `FoundBonus`) |\n| `boardLoopRaidFast(digIndices)` | Submit a batch of opened cell indices (Fast raid mode). | `RaidResponse` |\n| `boardLoopBuild(buildingIndex)` | Upgrade one building a level. | `BuildResponse` (`NewLevel`, `StageComplete`) |\n| `boardSpecialChoose(choiceID)` | Pick a Special-tile choice (Instant or Timed). | `SpecialChooseResponse` (`Mode`) |\n| `boardSpecialApplyMultiplier(existingRelatedEntityID?)` | Apply one ad-view multiplier to a pending Timed Special. | `SpecialApplyMultiplierResponse` |\n| `boardSpecialClaim()` | Claim the pending Special reward (early or after the window). | `SpecialClaimResponse` (`FinalMultiplier`) |\n\n## Methods — community chest\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------- |\n| `getCommunityChestState()` | This player's chest state (active group ref + seconds remaining). | `CommunityChestUserStateResponse` |\n| `joinOrCreateCommunityChest()` | Join an open group, or create one if none is forming. | `CommunityChestGroupStateResponse` |\n| `claimCommunityChestMilestone(milestoneID, groupID?)` | Claim one reached-but-unclaimed milestone reward. | `CommunityChestClaimResponse` |\n| `claimCommunityChestGrandPrize(groupID?)` | Claim the Grand Prize once the shared meter is filled. | `CommunityChestClaimResponse` |\n| `leaveCommunityChest(groupID?)` | Leave the currently-active group. | `CommunityChestLeaveResponse` (`Success`) |\n\n`groupID` is optional on the claim/leave calls — omit it to target the\nplayer's current active group.\n\nOn success, every method above mirrors the confirmed change into the cache and\nemits an event — you don't apply anything by hand. Granted/consumed resources\nride along in `data.Operation` (board methods) or `data.Resources` (chest\nclaims) and are already applied to the cached currency/item balances.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Board state (only present after getUserBoardState() or a board action):\nconst board = client.data.user.state?.GameLoop?.Board;\nboard?.StageLevel; // current stage\nboard?.Position; // tile index on the ring\nboard?.BuildingStates; // [{ SlotIndex, Level, IsDamaged, MaxLevelRewardClaimed }]\nboard?.Pending; // non-null while an ATTACK/RAID/SPECIAL is unresolved\nboard?.CyclesCompleted; // full loops of the ring\nboard?.SpecialStats; // lifetime Special-mode counters\n\n// Community Chest state:\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\nchest?.ActiveGroupID;\nchest?.ActiveRoundIndex;\nchest?.History; // recent completed rounds\n\n// Config (two separate cache sections):\nimport type { GameLoopDefinitions, BoardLoopDefinition } from \"@idosgames/core\";\nconst gameLoopCfg =\n client.data.config.getSection<GameLoopDefinitions>(\"GameLoop\"); // CommunityChest config lives here\nconst boardCfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\"); // current-stage board config\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `gameLoop:gameLoopsLoaded` → `GameLoopDefinitions`\n- `gameLoop:boardDefinitionLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardDefinitionForLevelLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardStateLoaded` → `BoardLoopState`\n- `gameLoop:boardRolled` → `BoardRollResponse`\n- `gameLoop:boardAttacked` → `AttackResponse`\n- `gameLoop:boardRaided` → `RaidResponse`\n- `gameLoop:boardRaidedFast` → `RaidResponse`\n- `gameLoop:boardBuilt` → `BuildResponse`\n- `gameLoop:boardSpecialChose` → `SpecialChooseResponse`\n- `gameLoop:boardSpecialApplyMultiplier` → `SpecialApplyMultiplierResponse`\n- `gameLoop:boardSpecialClaimed` → `SpecialClaimResponse`\n- `gameLoop:communityChestStateLoaded` → `CommunityChestUserStateResponse`\n- `gameLoop:communityChestJoined` → `CommunityChestGroupStateResponse`\n- `gameLoop:communityChestMilestoneClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestGrandPrizeClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestLeft` → `CommunityChestLeaveResponse`\n\nThe coarse `user:gameLoopUpdated` (+ umbrella `user:anyUpdated`) also fires on\nevery board or chest cache write — handy for a \"re-render everything\" hook,\nand what `templates/board-game` actually uses (`useBoardState()` subscribes to\n`user:anyUpdated` and reads `client.data.user.state?.GameLoop?.Board`).\n\n```ts\nconst off = client.on(\"gameLoop:boardRolled\", (r) => {\n console.log(`landed on ${r.NewPosition} (${r.LandedTileType})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the board and render it\n\n```ts\nawait client.gameLoop.getBoardDefinition();\nawait client.gameLoop.getUserBoardState();\n\nconst cfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\");\nconst board = client.data.user.state?.GameLoop?.Board;\n\nconst stage = board?.StageLevel ?? 1;\nconst stageDef = cfg?.StagesByLevel?.[String(stage)];\nconst template = cfg?.BoardTemplatesByID?.[stageDef?.BoardTemplateID ?? \"\"];\n// walk template.Tiles (keyed by string index) to lay out the ring;\n// render token at board.Position, buildings from board.BuildingStates.\n```\n\n`getBoardDefinition()` returns the config for the player's **current** stage;\npass `silent: true` to suppress whatever loading UI/telemetry the platform\nadapter would otherwise trigger for a background refresh. Use\n`getBoardDefinitionForLevel(n)` to preview a different stage's config (e.g. a\n\"next stage preview\" screen) without touching the player's actual stage.\n\n### Roll, then resolve whatever comes up\n\n```ts\nconst roll = await client.gameLoop.boardLoopRoll(1);\nif (!roll.ok) return showError(roll.error);\n\nconst board = client.data.user.state?.GameLoop?.Board;\nswitch (board?.Pending?.Type) {\n case \"ATTACK\":\n // render AttackPanel from board.Pending.TargetUserID / TargetBuildingStates\n break;\n case \"RAID\":\n // render RaidPanel from board.Pending.RaidLayout\n break;\n case \"SPECIAL\":\n // render SpecialPanel from board.Pending.Special\n break;\n default:\n // no pending interaction — landed on a plain/chance/economy tile,\n // roll.data.Operation already applied to cached balances.\n break;\n}\n```\n\n`rollMultiplier` does **not** change how far the token moves — the dice roll\n(`Steps`) is always the sum of the configured dice regardless of multiplier.\nThe multiplier only scales (a) the dice cost — one unit of `RollCurrencyID`\nper multiplier step, so `x3` costs 3 dice in one call — and (b) the size of\nwhatever reward the landed tile/pass-start grants. It must be one of\n`BoardLoopDefinition.AllowedRollMultipliers`, and must not exceed the stage's\n`MaxRollMultiplier` (further capped by a per-player override the backend may\napply) — violating either is rejected with a specific message (\"Multiplier\nx{N} is not allowed…\" / \"…exceeds the maximum x{M} for this stage\") rather\nthan silently clamped or rounded down. Read `AllowedRollMultipliers` to build\na multiplier picker; \"Not enough dice\" if the balance can't cover the\nrequested multiplier.\n\n`Pending` has a server TTL (`ExpiresAtUtc`). For ATTACK/RAID this is a fixed\n15 minutes from the roll (mirrored client-side with the same default). For\nSPECIAL it is **not** 15 minutes — it's `max(OfferExpireSeconds, longest\nchoice DurationSeconds + ClaimExpireSeconds)`, i.e. long enough to cover\nchoosing, playing out the longest Timed window, and claiming afterward. Either\nway, an expired pending interaction is rejected by the backend on the next\naction call (\"No active ATTACK\"/\"No active RAID\"/\"No active SPECIAL pending\"),\nso re-fetch `getUserBoardState()` on a stale-pending error rather than\ntrusting the local clock alone.\n\n### Resolve an ATTACK\n\n```ts\nconst targets = board?.Pending?.TargetBuildingStates ?? [];\n// -1 (or omit) lets the server auto-pick a target building.\nconst atk = await client.gameLoop.boardLoopAttack(targets[0]?.SlotIndex ?? -1);\nif (!atk.ok) return showError(atk.error);\natk.data.Outcome; // \"Hit\" | \"Blocked\" (target had a shield)\natk.data.IsBotTarget; // bot fights settle Operation locally\n// PvP fights settle via atk.data.DualResult (both sides' resource deltas) —\n// only the caller's own side (FromResult) is applied to this client's cache.\n```\n\nAttacking always clears `Pending`. If the call fails (`reason: \"server\"` or\n`\"connection\"`), the SDK automatically re-fetches `getUserBoardState()` to\nreconcile — don't also call it yourself in the error branch.\n\n### Resolve a RAID (both modes)\n\nSequential (`RaidMode: \"Sequential\"` — one dig per call):\n\n```ts\nconst dig = await client.gameLoop.boardLoopRaid(digIndex); // 0-11\nif (!dig.ok) return showError(dig.error);\nif (dig.data.Status === \"CONTINUE\") {\n // board.Pending.RaidLayout / OpenedIndices updated in cache; dig again.\n} else {\n // Finished — Status is \"FINISHED_SMALL\" | \"FINISHED_MEDIUM\" | \"FINISHED_BIG\"\n // | \"FINISHED_JACKPOT\" (never the literal \"Complete\"). Pending cleared,\n // reward already applied. dig.data.Outcome carries the same tier as an enum\n // string (\"Small\"/\"Medium\"/\"Big\"/\"Jackpot\").\n}\n```\n\nFast (`RaidMode: \"Fast\"` — client reveals cells from the pre-sent layout\nlocally, then submits the full opened set once a match is found):\n\n```ts\nconst openedSoFar = [...(board?.Pending?.OpenedIndices ?? []), newIndex];\nconst res = await client.gameLoop.boardLoopRaidFast(openedSoFar);\n```\n\n`digIndex` must be 0-11 (a fixed 12-cell grid, always shuffled 4×Small/4×Medium/\n4×Big unless a jackpot variant overrides the symbol mix); `boardLoopRaidFast`\nrejects an empty or duplicate-containing `digIndices` array client-side. Both\nraid methods reject if the title's `RaidMode` doesn't match (calling\n`boardLoopRaid` on a `\"Fast\"`-configured board fails with \"Use\nBoardLoopRaidFast for this board\", and vice versa \"Use BoardLoopRaid for this\nboard\") — read `BoardLoopDefinition.RaidMode` once and call the matching\nmethod, don't let the UI offer both. On a non-`\"CONTINUE\"` `Status` both raid\ncalls clear `Pending` and apply the reward the same way (`Operation`, falling\nback to `DualResult.FromResult` for PvP-style raids). Matching 3 of a kind\nbefore all 12 cells are opened ends the raid immediately — remaining cells are\nsimply never revealed.\n\n### Special tile: choose, optionally boost with an ad, claim\n\n`ChoiceID` is a config-defined id from the offer (`SpecialModeOffer.Choices[].ChoiceID`,\ne.g. `\"SmallCash\"`/`\"BigCashTimed\"`) — **not** the literal string `\"Instant\"`/\n`\"Timed\"`. Render the offer's choices and pass whichever `ChoiceID` the player\npicked:\n\n```ts\nconst board = client.data.user.state?.GameLoop?.Board;\nconst offerChoices = board?.Pending?.Special?.Choices ?? []; // stashed from the roll response\nconst picked = offerChoices[0]; // whatever the player tapped\n\nconst choice = await client.gameLoop.boardSpecialChoose(picked.ChoiceID);\nif (!choice.ok) return showError(choice.error);\n\nif (choice.data.Mode === \"Instant\" || choice.data.Mode === 0) {\n // reward already granted and Pending cleared — nothing else to do.\n} else {\n // Timed: a countdown window is now open (board.Pending.Special.DurationSeconds).\n // Optionally boost the payout with rewarded ads before the window closes:\n const boosted = await client.gameLoop.boardSpecialApplyMultiplier();\n if (boosted.ok) console.log(boosted.data.AccumulatedMultiplier);\n\n // Claim any time — early claim may forgo the ad multiplier and any\n // gradation tier not yet reached:\n const claim = await client.gameLoop.boardSpecialClaim();\n claim.data?.IsEarlyClaim; // true if claimed before the first gradation tier's threshold\n}\n```\n\n`Mode` can come back as either the string (`\"Instant\"`/`\"Timed\"`) or its\nnumeric enum value (`0`/`1`) — check both, as the templates do\n(`data.Mode === \"Timed\" || data.Mode === 1`). A choice can only be committed\nonce per pending SPECIAL (\"Choice already committed\" on a repeat call), and a\nTimed choice is rejected server-side unless its config sets a non-empty\ngradation ladder (\"Timed choice requires a non-empty Gradation ladder\") — this\nis a content-authoring constraint, not something the client can work around.\n\n`boardSpecialApplyMultiplier` can be called multiple times up to the choice's\n`Multipliers.MaxAdViews` (\"Max ad views reached\" past the cap) and only while\nthe Timed window is still open (\"Play window already closed\"); each call rolls\none multiplier step uniformly from `PerAdMultiplierRange` and folds it into\n`AccumulatedMultiplier` per `FormulaKind` — `\"Additive\"` (default) sums the\nrolled steps (final reward multiplier = `1 + AccumulatedMultiplier`),\n`\"Multiplicative\"` multiplies them together (final multiplier =\n`AccumulatedMultiplier` itself, floored at 1.0). Read\n`AccumulatedMultiplier`/`RemainingAdViews` to gate the \"watch another ad\"\nbutton. The reward itself is only computed and paid at `boardSpecialClaim`\ntime — `boardSpecialApplyMultiplier` never grants anything by itself, it just\nrecords the roll.\n\n### Build\n\n```ts\nconst build = await client.gameLoop.boardLoopBuild(slotIndex);\nif (!build.ok) return showError(build.error); // e.g. can't afford, already maxed\nif (build.data.StageComplete) {\n // board.StageLevel bumped, Position reset to 0, BuildingStates cleared —\n // re-fetch getBoardDefinition() for the new stage's config.\n} else {\n // board.BuildingStates[slotIndex].Level bumped in cache already.\n}\n```\n\nBuilding is independent of the roll/pending flow — it can be done any time a\nbuildable slot exists, whether or not `Pending` is set.\n\n### Community Chest: join, contribute via rolling, claim\n\n```ts\nawait client.gameLoop.getCommunityChestState();\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\n\nif (!chest?.ActiveGroupID) {\n const joined = await client.gameLoop.joinOrCreateCommunityChest();\n if (!joined.ok) return showError(joined.error);\n}\n\n// Contribution happens as a side effect of normal rolling — not a separate call:\nconst roll = await client.gameLoop.boardLoopRoll(1);\nconst contribution = roll.data?.CommunityChestContribution;\nif (contribution?.UnlockedMilestoneIDs?.length) {\n // show \"milestone unlocked\" toast(s) for each id\n}\nif (contribution?.Completed) {\n // shared meter hit MaxProgress — Grand Prize is now claimable for the group.\n}\n\nfor (const milestoneID of contribution?.UnlockedMilestoneIDs ?? []) {\n const claim = await client.gameLoop.claimCommunityChestMilestone(milestoneID);\n if (!claim.ok) console.warn(claim.error); // e.g. already claimed by a race\n}\n```\n\n`joinOrCreateCommunityChest` is idempotent from the caller's perspective — if\nthe player is already in a group in the current round it just returns that\ngroup rather than erroring or creating a duplicate. `claimCommunityChestGrandPrize()`\nonly succeeds once the group's `Status` is `\"Completed\"` (meter filled to\n`MaxProgress`) — checking `contribution.Completed` client-side is a UI\nshortcut, the server re-checks `Status` itself; each member claims\nindividually via their own `GrandPrizeClaimed` flag, so one member's claim\nnever claims it for the whole group. If matchmaking can't find or fill an open\ngroup after a few retries, `joinOrCreateCommunityChest` fails with\n\"Matchmaking failed after retries. Please try again.\" — a plain retry from the\nUI is the right recovery, not a special code path.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Roll\"/\"Attack\" can duplicate. Disable the control while a\n call is in flight. (Firing the same endpoint again within the throttle\n window, default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **`boardLoopRaidFast` only updates the cache on completion.** Unlike\n sequential `boardLoopRaid` (which patches `Pending.RaidLayout`/\n `OpenedIndices` on every `CONTINUE`), a `CONTINUE` response from\n `boardLoopRaidFast` does **not** get mirrored into the cache at all — Fast\n mode is designed so the client already knows the full layout locally and\n only calls the server once, on the winning submission. Don't expect\n `Pending.OpenedIndices` to reflect fast-mode digs mid-game; track opened\n cells in local component state instead (see `templates/board-game`'s\n `RaidPanel`).\n- **On a failed board action, the SDK self-heals by re-fetching state** —\n `boardLoopAttack`, `boardLoopRaid`, and `boardLoopRaidFast` all call\n `getUserBoardState()` automatically when their result is not `ok`. Don't\n duplicate that call in your error handler; just show `result.error`.\n- **Two separate config cache sections.** `getGameLoops()` caches under\n `\"GameLoop\"` (root `GameLoopDefinitions`, including the `CommunityChest`\n config); `getBoardDefinition()`/`getBoardDefinitionForLevel()` cache under\n `\"BoardDefinition\"` (a `BoardLoopDefinition`, i.e. just the board half). If\n you only need Community Chest config, `getGameLoops()` alone is enough — you\n don't need to also load the board.\n- **PvP resource deltas are two-sided.** Attack/raid against a real player\n return `DualResult` with `FromResult`/`ToResult`; only `FromResult` (this\n caller's own delta) is ever applied to the local cache — you cannot see or\n apply the opponent's side from this client, nor should you.\n- **`Mode`/`ChosenMode` on Special responses can be string or numeric enum.**\n Compare against both the string literal and its ordinal (`0`/`1`) as shown\n in the recipes — the wire format isn't fully normalized to strings.\n- **The board has no real end state.** Stages past whatever the title\n authored in `StagesByLevel` are synthesized server-side on demand (visuals\n cycle through `ProceduralEconomy.VisualTemplateCycle`, economy scales via the\n stage's `Unit(N)`) — a player can never actually run out of stages to build\n through. `BoardLoopState.AllStagesCompleted` is defined in the SDK's types\n but the backend never sets it; don't build a \"you beat the game\" screen\n around it.\n- **Attack shields are consumed, not just checked.** A `Blocked` outcome costs\n the defender exactly one unit of `ShieldCurrencyID` (server-side, dual-party\n transaction) — it isn't a passive flag. A bot target's shield is a\n per-roll coin flip (`Bots.ShieldChance`) that only affects that one\n interaction, not a persisted balance.\n- **`SpecialClaimResponse` carries a `ReachedTierIndex` the SDK doesn't type\n yet.** The backend returns which gradation tier was actually paid out\n (`-1` = the below-first-tier reward, `>=0` = index into the choice's\n `Gradation.Tiers`), but the current `@idosgames/core` response type doesn't\n declare that field — it still round-trips (schemas keep `.passthrough()`)\n but reading it requires an `as any`/loose cast until the SDK catches up.\n- **`CommunityChestDefinition.MemberGracePeriodMinutes` is config-only today.**\n Nothing in the backend currently reads it — a departed member's slot is\n _not_ auto-backfilled with a bot; only a still-`\"Forming\"` group gets\n bot-filled, and only after `MatchmakingTimeoutMinutes` elapses. Don't build\n UI that promises a grace-period replacement.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield for both the board loop and Community Chest: stage/tile/building config,\nprocedural economy knobs, heist raid variants, Special gradation tiers, and\nthe Community Chest group document. Read it when building config-driven UI\n(tile art, reward previews, milestone bars) or when an error message points at\na config rule you need to understand.\n",
4
+ "content": "---\nname: game-loop-system\ndescription: >-\n Build a board-style core game loop on the iDosGames TypeScript SDK\n (@idosgames/core) via client.gameLoop (GameLoopService): roll dice around a\n board, attack/raid other players' or bots' cities, build up buildings,\n resolve Special (Instant/Timed) tile choices, and run the cooperative\n Community Chest group meter. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n dice/roll board loop, an attack or raid/heist mini-game, city building,\n survival/Special tile events, or a co-op group-progress feature — or\n otherwise touches client.gameLoop, GameLoopService, GameLoopModels,\n BoardLoopState, BoardLoopDefinition, or CommunityChest — even if they don't\n name the module explicitly. templates/board-game is built entirely on this\n module.\n---\n\n# Game loop system (iDosGames TS SDK)\n\nThe GameLoop module ships a board-style core loop: a player rolls dice, moves\naround a ring of tiles, and lands on tiles that trigger attacking another\nplayer's (or a bot's) city, raiding a heist grid for bonuses, building up their\nown buildings, or a special timed/instant reward choice. A separate, related\nfeature bundled in the same module — **Community Chest** — is a cooperative\ngroup meter: players join a small group, every board roll contributes points to\none shared progress bar, and the group claims milestone/grand-prize rewards\ntogether. They share one config root and one player-state root\n(`GameLoop.Board` / `GameLoop.CommunityChest`) but otherwise don't interact.\n\nEverything is **server-authoritative**: the client asks the backend to\nroll/attack/raid/build/choose/claim, the backend validates and resolves it, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever compute outcomes (dice results, bot behavior, raid layouts, rewards)\nyourself — you call a method, check the result, and render from the cache.\n\nThis skill is for **using** the production `GameLoopService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(no pending interaction, wrong stage, cooldown, insufficient funds) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Mental model\n\n**Board loop** — one state machine per player:\n\n1. `boardLoopRoll` moves the player's token and returns what tile they landed\n on. The response may carry `ActionRequired` (`\"ATTACK\"` or `\"RAID\"`) or a\n `SpecialModeOffer` — when present, the SDK stashes it as `Board.Pending` and\n the UI must resolve it before the player can roll again.\n2. Depending on `Pending.Type`, the UI drives one of: `boardLoopAttack`,\n `boardLoopRaid` / `boardLoopRaidFast`, or `boardSpecialChoose` →\n (`boardSpecialApplyMultiplier`) → `boardSpecialClaim`.\n3. `boardLoopBuild` is independent of rolling — any time a building slot exists\n and the player can afford the upgrade, they can build. Reaching every\n building's `MaxLevel` on the current stage returns `StageComplete`, which\n resets `Position` to 0 and bumps `StageLevel`. The board is **infinite** —\n stages past the title's authored content are procedurally synthesized\n server-side (visuals cycle, economy scales via the stage's `Unit(N)`), so\n there's no \"last stage\" a player can actually reach; don't build UI around\n an end state (see Gotchas).\n\n**Community chest** — a small, mostly independent co-op side-feature:\n\n1. `getCommunityChestState` tells the player if they're in an active/forming\n group and how much time is left in the round.\n2. `joinOrCreateCommunityChest` puts them in a group (existing or new).\n3. Regular `boardLoopRoll` calls (not a separate action) contribute points to\n the group's shared meter — read `BoardRollResponse.CommunityChestContribution`\n for the delta/unlocked milestones on each roll.\n4. `claimCommunityChestMilestone` / `claimCommunityChestGrandPrize` pull\n rewards once thresholds are hit; `leaveCommunityChest` exits early.\n\nFor the full field-by-field shape of both sub-systems (stage/tile config,\nheist raid variants, Special gradation tiers, Community Chest group document),\nread [references/data-model.md](references/data-model.md). You do **not** need\nit to call the methods — only to drive richer UI off the config (tile art,\nbuilding names, milestone thresholds).\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 gameLoop = client.gameLoop; // the GameLoopService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods — board loop\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. \"No pending interaction\", \"Raid already in\nprogress\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------- |\n| `getGameLoops()` | Load the root GameLoop config (Board + CommunityChest configs). | `GameLoopDefinitions` |\n| `getBoardDefinition(silent?)` | Load the current-stage board config (tiles, buildings, economy). | `BoardLoopDefinition` |\n| `getBoardDefinitionForLevel(stageLevel, silent?)` | Load the board config for a specific stage (e.g. to preview). | `BoardLoopDefinition` |\n| `getUserBoardState()` | Load this player's board state (position, buildings, pending). | `BoardLoopState` |\n| `boardLoopRoll(rollMultiplier?)` | Roll dice and move the token (default multiplier 1). | `BoardRollResponse` (`NewPosition`, `Steps`, `DiceValues`) |\n| `boardLoopAttack(buildingIndex?)` | Resolve a pending ATTACK (pass `-1` or omit for auto-target). | `AttackResponse` (`Outcome`, `Operation`) |\n| `boardLoopRaid(digIndex, existingRelatedEntityID?)` | Dig one heist cell (Sequential raid mode), `digIndex` 0-11. | `RaidResponse` (`Status`, `FoundBonus`) |\n| `boardLoopRaidFast(digIndices)` | Submit a batch of opened cell indices (Fast raid mode). | `RaidResponse` |\n| `boardLoopBuild(buildingIndex)` | Upgrade one building a level. | `BuildResponse` (`NewLevel`, `StageComplete`) |\n| `boardSpecialChoose(choiceID)` | Pick a Special-tile choice (Instant or Timed). | `SpecialChooseResponse` (`Mode`) |\n| `boardSpecialApplyMultiplier(existingRelatedEntityID?)` | Apply one ad-view multiplier to a pending Timed Special. | `SpecialApplyMultiplierResponse` |\n| `boardSpecialClaim()` | Claim the pending Special reward (early or after the window). | `SpecialClaimResponse` (`FinalMultiplier`) |\n\n## Methods — community chest\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------- |\n| `getCommunityChestState()` | This player's chest state (active group ref + seconds remaining). | `CommunityChestUserStateResponse` |\n| `joinOrCreateCommunityChest()` | Join an open group, or create one if none is forming. | `CommunityChestGroupStateResponse` |\n| `claimCommunityChestMilestone(milestoneID, groupID?)` | Claim one reached-but-unclaimed milestone reward. | `CommunityChestClaimResponse` |\n| `claimCommunityChestGrandPrize(groupID?)` | Claim the Grand Prize once the shared meter is filled. | `CommunityChestClaimResponse` |\n| `leaveCommunityChest(groupID?)` | Leave the currently-active group. | `CommunityChestLeaveResponse` (`Success`) |\n\n`groupID` is optional on the claim/leave calls — omit it to target the\nplayer's current active group.\n\nOn success, every method above mirrors the confirmed change into the cache and\nemits an event — you don't apply anything by hand. Granted/consumed resources\nride along in `data.Operation` (board methods) or `data.Resources` (chest\nclaims) and are already applied to the cached currency/item balances.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Board state (only present after getUserBoardState() or a board action):\nconst board = client.data.user.state?.GameLoop?.Board;\nboard?.StageLevel; // current stage\nboard?.Position; // tile index on the ring\nboard?.BuildingStates; // [{ SlotIndex, Level, IsDamaged, MaxLevelRewardClaimed }]\nboard?.Pending; // non-null while an ATTACK/RAID/SPECIAL is unresolved\nboard?.CyclesCompleted; // full loops of the ring\nboard?.SpecialStats; // lifetime Special-mode counters\n\n// Community Chest state:\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\nchest?.ActiveGroupID;\nchest?.ActiveRoundIndex;\nchest?.History; // recent completed rounds\n\n// Config (two separate cache sections):\nimport type { GameLoopDefinitions, BoardLoopDefinition } from \"@idosgames/core\";\nconst gameLoopCfg =\n client.data.config.getSection<GameLoopDefinitions>(\"GameLoop\"); // CommunityChest config lives here\nconst boardCfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\"); // current-stage board config\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `gameLoop:gameLoopsLoaded` → `GameLoopDefinitions`\n- `gameLoop:boardDefinitionLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardDefinitionForLevelLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardStateLoaded` → `BoardLoopState`\n- `gameLoop:boardRolled` → `BoardRollResponse`\n- `gameLoop:boardAttacked` → `AttackResponse`\n- `gameLoop:boardRaided` → `RaidResponse`\n- `gameLoop:boardRaidedFast` → `RaidResponse`\n- `gameLoop:boardBuilt` → `BuildResponse`\n- `gameLoop:boardSpecialChose` → `SpecialChooseResponse`\n- `gameLoop:boardSpecialApplyMultiplier` → `SpecialApplyMultiplierResponse`\n- `gameLoop:boardSpecialClaimed` → `SpecialClaimResponse`\n- `gameLoop:communityChestStateLoaded` → `CommunityChestUserStateResponse`\n- `gameLoop:communityChestJoined` → `CommunityChestGroupStateResponse`\n- `gameLoop:communityChestMilestoneClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestGrandPrizeClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestLeft` → `CommunityChestLeaveResponse`\n\nThe coarse `user:gameLoopUpdated` (+ umbrella `user:anyUpdated`) also fires on\nevery board or chest cache write — handy for a \"re-render everything\" hook,\nand what `templates/board-game` actually uses (`useBoardState()` subscribes to\n`user:anyUpdated` and reads `client.data.user.state?.GameLoop?.Board`).\n\n```ts\nconst off = client.on(\"gameLoop:boardRolled\", (r) => {\n console.log(`landed on ${r.NewPosition} (${r.LandedTileType})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the board and render it\n\n```ts\nawait client.gameLoop.getBoardDefinition();\nawait client.gameLoop.getUserBoardState();\n\nconst cfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\");\nconst board = client.data.user.state?.GameLoop?.Board;\n\nconst stage = board?.StageLevel ?? 1;\nconst stageDef = cfg?.StagesByLevel?.[String(stage)];\nconst template = cfg?.BoardTemplatesByID?.[stageDef?.BoardTemplateID ?? \"\"];\n// walk template.Tiles (keyed by string index) to lay out the ring;\n// render token at board.Position, buildings from board.BuildingStates.\n```\n\n`getBoardDefinition()` returns the config for the player's **current** stage;\npass `silent: true` to suppress whatever loading UI/telemetry the platform\nadapter would otherwise trigger for a background refresh. Use\n`getBoardDefinitionForLevel(n)` to preview a different stage's config (e.g. a\n\"next stage preview\" screen) without touching the player's actual stage.\n\n### Roll, then resolve whatever comes up\n\n```ts\nconst roll = await client.gameLoop.boardLoopRoll(1);\nif (!roll.ok) return showError(roll.error);\n\nconst board = client.data.user.state?.GameLoop?.Board;\nswitch (board?.Pending?.Type) {\n case \"ATTACK\":\n // render AttackPanel from board.Pending.TargetUserID / TargetBuildingStates\n break;\n case \"RAID\":\n // render RaidPanel from board.Pending.RaidLayout\n break;\n case \"SPECIAL\":\n // render SpecialPanel from board.Pending.Special\n break;\n default:\n // no pending interaction — landed on a plain/chance/economy tile,\n // roll.data.Operation already applied to cached balances.\n break;\n}\n```\n\nAnimate the dice from `DiceValues` — the per-die faces the server actually\nrolled, one entry per `Dice.Count`, each `1..Dice.Sides`, summing to `Steps`.\nDo not split `Steps` into faces yourself: the split is ambiguous, and a\ntutorial-scripted step may not be expressible as dice at all (it can be any\nvalue up to a full lap). `DiceValues` is `null` exactly in that scripted case —\nmove the token by `Steps` and skip the dice animation.\n\n`rollMultiplier` does **not** change how far the token moves — the dice roll\n(`Steps`) is always the sum of the configured dice regardless of multiplier.\nThe multiplier only scales (a) the dice cost — one unit of `RollCurrencyID`\nper multiplier step, so `x3` costs 3 dice in one call — and (b) the size of\nwhatever reward the landed tile/pass-start grants. It must be one of\n`BoardLoopDefinition.AllowedRollMultipliers`, and must not exceed the stage's\n`MaxRollMultiplier` (further capped by a per-player override the backend may\napply) — violating either is rejected with a specific message (\"Multiplier\nx{N} is not allowed…\" / \"…exceeds the maximum x{M} for this stage\") rather\nthan silently clamped or rounded down. Read `AllowedRollMultipliers` to build\na multiplier picker; \"Not enough dice\" if the balance can't cover the\nrequested multiplier.\n\n`Pending` has a server TTL (`ExpiresAtUtc`). For ATTACK/RAID this is a fixed\n15 minutes from the roll (mirrored client-side with the same default). For\nSPECIAL it is **not** 15 minutes — it's `max(OfferExpireSeconds, longest\nchoice DurationSeconds + ClaimExpireSeconds)`, i.e. long enough to cover\nchoosing, playing out the longest Timed window, and claiming afterward. Either\nway, an expired pending interaction is rejected by the backend on the next\naction call (\"No active ATTACK\"/\"No active RAID\"/\"No active SPECIAL pending\"),\nso re-fetch `getUserBoardState()` on a stale-pending error rather than\ntrusting the local clock alone.\n\n### Resolve an ATTACK\n\n```ts\nconst targets = board?.Pending?.TargetBuildingStates ?? [];\n// -1 (or omit) lets the server auto-pick a target building.\nconst atk = await client.gameLoop.boardLoopAttack(targets[0]?.SlotIndex ?? -1);\nif (!atk.ok) return showError(atk.error);\natk.data.Outcome; // \"Hit\" | \"Blocked\" (target had a shield)\natk.data.IsBotTarget; // bot fights settle Operation locally\n// PvP fights settle via atk.data.DualResult (both sides' resource deltas) —\n// only the caller's own side (FromResult) is applied to this client's cache.\n```\n\nAttacking always clears `Pending`. If the call fails (`reason: \"server\"` or\n`\"connection\"`), the SDK automatically re-fetches `getUserBoardState()` to\nreconcile — don't also call it yourself in the error branch.\n\n### Resolve a RAID (both modes)\n\nSequential (`RaidMode: \"Sequential\"` — one dig per call):\n\n```ts\nconst dig = await client.gameLoop.boardLoopRaid(digIndex); // 0-11\nif (!dig.ok) return showError(dig.error);\nif (dig.data.Status === \"CONTINUE\") {\n // board.Pending.RaidLayout / OpenedIndices updated in cache; dig again.\n} else {\n // Finished — Status is \"FINISHED_SMALL\" | \"FINISHED_MEDIUM\" | \"FINISHED_BIG\"\n // | \"FINISHED_JACKPOT\" (never the literal \"Complete\"). Pending cleared,\n // reward already applied. dig.data.Outcome carries the same tier as an enum\n // string (\"Small\"/\"Medium\"/\"Big\"/\"Jackpot\").\n}\n```\n\nFast (`RaidMode: \"Fast\"` — client reveals cells from the pre-sent layout\nlocally, then submits the full opened set once a match is found):\n\n```ts\nconst openedSoFar = [...(board?.Pending?.OpenedIndices ?? []), newIndex];\nconst res = await client.gameLoop.boardLoopRaidFast(openedSoFar);\n```\n\n`digIndex` must be 0-11 (a fixed 12-cell grid, always shuffled 4×Small/4×Medium/\n4×Big unless a jackpot variant overrides the symbol mix); `boardLoopRaidFast`\nrejects an empty or duplicate-containing `digIndices` array client-side. Both\nraid methods reject if the title's `RaidMode` doesn't match (calling\n`boardLoopRaid` on a `\"Fast\"`-configured board fails with \"Use\nBoardLoopRaidFast for this board\", and vice versa \"Use BoardLoopRaid for this\nboard\") — read `BoardLoopDefinition.RaidMode` once and call the matching\nmethod, don't let the UI offer both. On a non-`\"CONTINUE\"` `Status` both raid\ncalls clear `Pending` and apply the reward the same way (`Operation`, falling\nback to `DualResult.FromResult` for PvP-style raids). Matching 3 of a kind\nbefore all 12 cells are opened ends the raid immediately — remaining cells are\nsimply never revealed.\n\n### Special tile: choose, optionally boost with an ad, claim\n\n`ChoiceID` is a config-defined id from the offer (`SpecialModeOffer.Choices[].ChoiceID`,\ne.g. `\"SmallCash\"`/`\"BigCashTimed\"`) — **not** the literal string `\"Instant\"`/\n`\"Timed\"`. Render the offer's choices and pass whichever `ChoiceID` the player\npicked:\n\n```ts\nconst board = client.data.user.state?.GameLoop?.Board;\nconst offerChoices = board?.Pending?.Special?.Choices ?? []; // stashed from the roll response\nconst picked = offerChoices[0]; // whatever the player tapped\n\nconst choice = await client.gameLoop.boardSpecialChoose(picked.ChoiceID);\nif (!choice.ok) return showError(choice.error);\n\nif (choice.data.Mode === \"Instant\" || choice.data.Mode === 0) {\n // reward already granted and Pending cleared — nothing else to do.\n} else {\n // Timed: a countdown window is now open (board.Pending.Special.DurationSeconds).\n // Optionally boost the payout with rewarded ads before the window closes:\n const boosted = await client.gameLoop.boardSpecialApplyMultiplier();\n if (boosted.ok) console.log(boosted.data.AccumulatedMultiplier);\n\n // Claim any time — early claim may forgo the ad multiplier and any\n // gradation tier not yet reached:\n const claim = await client.gameLoop.boardSpecialClaim();\n claim.data?.IsEarlyClaim; // true if claimed before the first gradation tier's threshold\n}\n```\n\n`Mode` can come back as either the string (`\"Instant\"`/`\"Timed\"`) or its\nnumeric enum value (`0`/`1`) — check both, as the templates do\n(`data.Mode === \"Timed\" || data.Mode === 1`). A choice can only be committed\nonce per pending SPECIAL (\"Choice already committed\" on a repeat call), and a\nTimed choice is rejected server-side unless its config sets a non-empty\ngradation ladder (\"Timed choice requires a non-empty Gradation ladder\") — this\nis a content-authoring constraint, not something the client can work around.\n\n`boardSpecialApplyMultiplier` can be called multiple times up to the choice's\n`Multipliers.MaxAdViews` (\"Max ad views reached\" past the cap) and only while\nthe Timed window is still open (\"Play window already closed\"); each call rolls\none multiplier step uniformly from `PerAdMultiplierRange` and folds it into\n`AccumulatedMultiplier` per `FormulaKind` — `\"Additive\"` (default) sums the\nrolled steps (final reward multiplier = `1 + AccumulatedMultiplier`),\n`\"Multiplicative\"` multiplies them together (final multiplier =\n`AccumulatedMultiplier` itself, floored at 1.0). Read\n`AccumulatedMultiplier`/`RemainingAdViews` to gate the \"watch another ad\"\nbutton. The reward itself is only computed and paid at `boardSpecialClaim`\ntime — `boardSpecialApplyMultiplier` never grants anything by itself, it just\nrecords the roll.\n\n### Build\n\n```ts\nconst build = await client.gameLoop.boardLoopBuild(slotIndex);\nif (!build.ok) return showError(build.error); // e.g. can't afford, already maxed\nif (build.data.StageComplete) {\n // board.StageLevel bumped, Position reset to 0, BuildingStates cleared —\n // re-fetch getBoardDefinition() for the new stage's config.\n} else {\n // board.BuildingStates[slotIndex].Level bumped in cache already.\n}\n```\n\nBuilding is independent of the roll/pending flow — it can be done any time a\nbuildable slot exists, whether or not `Pending` is set.\n\n### Community Chest: join, contribute via rolling, claim\n\n```ts\nawait client.gameLoop.getCommunityChestState();\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\n\nif (!chest?.ActiveGroupID) {\n const joined = await client.gameLoop.joinOrCreateCommunityChest();\n if (!joined.ok) return showError(joined.error);\n}\n\n// Contribution happens as a side effect of normal rolling — not a separate call:\nconst roll = await client.gameLoop.boardLoopRoll(1);\nconst contribution = roll.data?.CommunityChestContribution;\nif (contribution?.UnlockedMilestoneIDs?.length) {\n // show \"milestone unlocked\" toast(s) for each id\n}\nif (contribution?.Completed) {\n // shared meter hit MaxProgress — Grand Prize is now claimable for the group.\n}\n\nfor (const milestoneID of contribution?.UnlockedMilestoneIDs ?? []) {\n const claim = await client.gameLoop.claimCommunityChestMilestone(milestoneID);\n if (!claim.ok) console.warn(claim.error); // e.g. already claimed by a race\n}\n```\n\n`joinOrCreateCommunityChest` is idempotent from the caller's perspective — if\nthe player is already in a group in the current round it just returns that\ngroup rather than erroring or creating a duplicate. `claimCommunityChestGrandPrize()`\nonly succeeds once the group's `Status` is `\"Completed\"` (meter filled to\n`MaxProgress`) — checking `contribution.Completed` client-side is a UI\nshortcut, the server re-checks `Status` itself; each member claims\nindividually via their own `GrandPrizeClaimed` flag, so one member's claim\nnever claims it for the whole group. If matchmaking can't find or fill an open\ngroup after a few retries, `joinOrCreateCommunityChest` fails with\n\"Matchmaking failed after retries. Please try again.\" — a plain retry from the\nUI is the right recovery, not a special code path.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Roll\"/\"Attack\" can duplicate. Disable the control while a\n call is in flight. (Firing the same endpoint again within the throttle\n window, default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **`boardLoopRaidFast` only updates the cache on completion.** Unlike\n sequential `boardLoopRaid` (which patches `Pending.RaidLayout`/\n `OpenedIndices` on every `CONTINUE`), a `CONTINUE` response from\n `boardLoopRaidFast` does **not** get mirrored into the cache at all — Fast\n mode is designed so the client already knows the full layout locally and\n only calls the server once, on the winning submission. Don't expect\n `Pending.OpenedIndices` to reflect fast-mode digs mid-game; track opened\n cells in local component state instead (see `templates/board-game`'s\n `RaidPanel`).\n- **On a failed board action, the SDK self-heals by re-fetching state** —\n `boardLoopAttack`, `boardLoopRaid`, and `boardLoopRaidFast` all call\n `getUserBoardState()` automatically when their result is not `ok`. Don't\n duplicate that call in your error handler; just show `result.error`.\n- **Two separate config cache sections.** `getGameLoops()` caches under\n `\"GameLoop\"` (root `GameLoopDefinitions`, including the `CommunityChest`\n config); `getBoardDefinition()`/`getBoardDefinitionForLevel()` cache under\n `\"BoardDefinition\"` (a `BoardLoopDefinition`, i.e. just the board half). If\n you only need Community Chest config, `getGameLoops()` alone is enough — you\n don't need to also load the board.\n- **PvP resource deltas are two-sided.** Attack/raid against a real player\n return `DualResult` with `FromResult`/`ToResult`; only `FromResult` (this\n caller's own delta) is ever applied to the local cache — you cannot see or\n apply the opponent's side from this client, nor should you.\n- **`Mode`/`ChosenMode` on Special responses can be string or numeric enum.**\n Compare against both the string literal and its ordinal (`0`/`1`) as shown\n in the recipes — the wire format isn't fully normalized to strings.\n- **The board has no real end state.** Stages past whatever the title\n authored in `StagesByLevel` are synthesized server-side on demand (visuals\n cycle through `ProceduralEconomy.VisualTemplateCycle`, economy scales via the\n stage's `Unit(N)`) — a player can never actually run out of stages to build\n through. `BoardLoopState.AllStagesCompleted` is defined in the SDK's types\n but the backend never sets it; don't build a \"you beat the game\" screen\n around it.\n- **Attack shields are consumed, not just checked.** A `Blocked` outcome costs\n the defender exactly one unit of `ShieldCurrencyID` (server-side, dual-party\n transaction) — it isn't a passive flag. A bot target's shield is a\n per-roll coin flip (`Bots.ShieldChance`) that only affects that one\n interaction, not a persisted balance.\n- **`SpecialClaimResponse` carries a `ReachedTierIndex` the SDK doesn't type\n yet.** The backend returns which gradation tier was actually paid out\n (`-1` = the below-first-tier reward, `>=0` = index into the choice's\n `Gradation.Tiers`), but the current `@idosgames/core` response type doesn't\n declare that field — it still round-trips (schemas keep `.passthrough()`)\n but reading it requires an `as any`/loose cast until the SDK catches up.\n- **`CommunityChestDefinition.MemberGracePeriodMinutes` is config-only today.**\n Nothing in the backend currently reads it — a departed member's slot is\n _not_ auto-backfilled with a bot; only a still-`\"Forming\"` group gets\n bot-filled, and only after `MatchmakingTimeoutMinutes` elapses. Don't build\n UI that promises a grace-period replacement.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield for both the board loop and Community Chest: stage/tile/building config,\nprocedural economy knobs, heist raid variants, Special gradation tiers, and\nthe Community Chest group document. Read it when building config-driven UI\n(tile art, reward previews, milestone bars) or when an error message points at\na config rule you need to understand.\n",
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 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"
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 (sum of DiceValues)\n DiceValues?: number[] | null; // per-die faces, one per Dice.Count, each 1..Dice.Sides;\n // null = tutorial-scripted step, no dice breakdown exists\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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-getting-started",
3
3
  "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
4
- "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them; a shared HUD panel (`activeOnly:false`) stays visible across all modes.\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`, authenticated with an `X-MCP-API-Key` header (the\n publisher issues the key per Title on platform.idosgames.com); every tool call takes a `title_id`\n argument. Connect it as an HTTP MCP server and keep the key out of committed config via env\n expansion:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\",\n \"headers\": { \"X-MCP-API-Key\": \"${IDOS_MCP_API_KEY}\" }\n }\n }\n }\n ```\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
4
+ "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them; a shared HUD panel (`activeOnly:false`) stays visible across all modes.\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
5
5
  "references": []
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-title-bootstrap",
3
3
  "description": "Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with starting balances, then the game-loop board config, then verify with a real login. Use this when a newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or whenever you scaffold a project for a Title that was just created and has no config yet. All writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect it).",
4
- "content": "---\nname: idosgames-title-bootstrap\ndescription: >-\n Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with\n starting balances, then the game-loop board config, then verify with a real login. Use this when a\n newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not\n configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or\n whenever you scaffold a project for a Title that was just created and has no config yet. All\n writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect\n it).\n---\n\n# Bootstrap an empty Title\n\nA freshly created Title has an **empty `TitlePublicConfiguration`** — the game client will log in\nfine, but every feature that reads config fails until its section exists. Configure it over the\nTitle-configuration MCP (`POST https://site.idosgames.com/api/v2/mcp`, `X-MCP-API-Key` header,\nevery tool takes `title_id`). Tools are `get_<section>` / `save_<section>` — snake_case of the\nconfig model's property names (`Currency` → `save_currency`, `GameLoop` → `save_game_loop`).\n\n**Always `get_` a section before `save_` — save replaces the whole section**, so build on what is\nthere rather than authoring blind.\n\n## Error → missing config\n\n| Server error | What's missing |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |\n| `Board not found` / `Board not enabled` | `GameLoop.Board` — the whole board definition |\n| `Stage not found` / `BoardTemplate not found for stage` / `StageTemplate not found for stage` | `StagesByLevel[\"1\"]` or the template it references by id |\n| `Bots config is not configured (Bots.RankMultiplierMin/Max ...)` | `Board.Bots` — required as soon as any tile can trigger Attack/Raid |\n| `SpecialMode '<id>' OfferExpireSeconds must be configured (> 0)` (same for `ClaimExpireSeconds`) | that mode in `Board.SpecialModesByID` |\n| Player starts with zero of everything | `Currency` entries' `InitialDeposit` |\n\n## Order of operations\n\n### 1. Currencies (`save_currency`)\n\nDefine every currency the game references **before** the game loop that spends them. For the\nboard-game module that is three roles: a roll currency (dice), a shield currency, and a soft\ncurrency (building costs / rewards). Give each an `InitialDeposit` for the starting balance.\n\n`InitialDeposit` applies when a **user is created** — an account that logged in before the deposit\nwas configured stays at 0. When verifying, log in as a **fresh guest**, don't reuse the session.\n\n### 2. Game loop (`save_game_loop`)\n\nThe `Board` object wires everything together. Minimum viable shape:\n\n- `RollCurrencyID` / `ShieldCurrencyID` / `SoftCurrencyID` — ids from step 1.\n- `BoardTemplatesByID` — at least one template with the tile ring (`Reward`, `Chance`, `Attack`,\n `Raid`, `Special`, `Shield`, `Empty`, `RandomAction`).\n- `StageTemplatesByID` — at least one economy template (`StageOperations`: `OnBuild`,\n `OnTileLanding`, `OnStageComplete`, `SpecialModesByID`, …).\n- `StagesByLevel` — `{\"1\": {...}}` referencing a `BoardTemplateID` + `StageTemplateID` that exist\n in the two maps above (dangling ids are a runtime error, not a save error).\n- `AllowedRollMultipliers`, `Dice`.\n- `Bots` — **required** if any tile can resolve to Attack or Raid: `RankMultiplierMin`/`Max` with\n `Max >= Min > 0`.\n- `RaidMode` — `Sequential` (server reveals cell by cell) or `Fast` (client reveals locally from\n the pre-dealt layout, submits once). Pick one; the client adapts.\n- Any `SpecialModesByID` mode needs `OfferExpireSeconds > 0` and `ClaimExpireSeconds > 0`.\n\n### 3. Verify against the live backend\n\n1. Fresh guest login → starting balances match the `InitialDeposit`s.\n2. `client.gameLoop.getUserBoardState()` → no `Board not enabled`.\n3. Roll until each tile type triggers once — Reward, Chance, Attack, Raid, Special — and confirm\n the granted/spent currencies match the configured economy.\n\n## Scope\n\nThis checklist covers the board-game loop because it is the config-heaviest module. Other config\nsections (store, quests, lootboxes, …) follow the same pattern — `get_<section>`, fill, `save_`,\nverify with the matching `@idosgames/core` service — and each service's own skill documents the\nshape it reads.\n",
4
+ "content": "---\nname: idosgames-title-bootstrap\ndescription: >-\n Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with\n starting balances, then the game-loop board config, then verify with a real login. Use this when a\n newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not\n configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or\n whenever you scaffold a project for a Title that was just created and has no config yet. All\n writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect\n it).\n---\n\n# Bootstrap an empty Title\n\nA freshly created Title has an **empty `TitlePublicConfiguration`** — the game client will log in\nfine, but every feature that reads config fails until its section exists. Configure it over the\nTitle-configuration MCP (`POST https://site.idosgames.com/api/v2/mcp`, OAuth 2.1 — no header\nand no API key; every tool takes `title_id`). Tools are `get_<section>` / `save_<section>` — snake_case of the\nconfig model's property names (`Currency` → `save_currency`, `GameLoop` → `save_game_loop`).\n\n**Always `get_` a section before `save_` — save replaces the whole section**, so build on what is\nthere rather than authoring blind.\n\n## Error → missing config\n\n| Server error | What's missing |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |\n| `Board not found` / `Board not enabled` | `GameLoop.Board` — the whole board definition |\n| `Stage not found` / `BoardTemplate not found for stage` / `StageTemplate not found for stage` | `StagesByLevel[\"1\"]` or the template it references by id |\n| `Bots config is not configured (Bots.RankMultiplierMin/Max ...)` | `Board.Bots` — required as soon as any tile can trigger Attack/Raid |\n| `SpecialMode '<id>' OfferExpireSeconds must be configured (> 0)` (same for `ClaimExpireSeconds`) | that mode in `Board.SpecialModesByID` |\n| Player starts with zero of everything | `Currency` entries' `InitialDeposit` |\n\n## Order of operations\n\n### 1. Currencies (`save_currency`)\n\nDefine every currency the game references **before** the game loop that spends them. For the\nboard-game module that is three roles: a roll currency (dice), a shield currency, and a soft\ncurrency (building costs / rewards). Give each an `InitialDeposit` for the starting balance.\n\n`InitialDeposit` applies when a **user is created** — an account that logged in before the deposit\nwas configured stays at 0. When verifying, log in as a **fresh guest**, don't reuse the session.\n\n### 2. Game loop (`save_game_loop`)\n\nThe `Board` object wires everything together. Minimum viable shape:\n\n- `RollCurrencyID` / `ShieldCurrencyID` / `SoftCurrencyID` — ids from step 1.\n- `BoardTemplatesByID` — at least one template with the tile ring (`Reward`, `Chance`, `Attack`,\n `Raid`, `Special`, `Shield`, `Empty`, `RandomAction`).\n- `StageTemplatesByID` — at least one economy template (`StageOperations`: `OnBuild`,\n `OnTileLanding`, `OnStageComplete`, `SpecialModesByID`, …).\n- `StagesByLevel` — `{\"1\": {...}}` referencing a `BoardTemplateID` + `StageTemplateID` that exist\n in the two maps above (dangling ids are a runtime error, not a save error).\n- `AllowedRollMultipliers`, `Dice`.\n- `Bots` — **required** if any tile can resolve to Attack or Raid: `RankMultiplierMin`/`Max` with\n `Max >= Min > 0`.\n- `RaidMode` — `Sequential` (server reveals cell by cell) or `Fast` (client reveals locally from\n the pre-dealt layout, submits once). Pick one; the client adapts.\n- Any `SpecialModesByID` mode needs `OfferExpireSeconds > 0` and `ClaimExpireSeconds > 0`.\n\n### 3. Verify against the live backend\n\n1. Fresh guest login → starting balances match the `InitialDeposit`s.\n2. `client.gameLoop.getUserBoardState()` → no `Board not enabled`.\n3. Roll until each tile type triggers once — Reward, Chance, Attack, Raid, Special — and confirm\n the granted/spent currencies match the configured economy.\n\n## Scope\n\nThis checklist covers the board-game loop because it is the config-heaviest module. Other config\nsections (store, quests, lootboxes, …) follow the same pattern — `get_<section>`, fill, `save_`,\nverify with the matching `@idosgames/core` service — and each service's own skill documents the\nshape it reads.\n",
5
5
  "references": []
6
6
  }