@idosgames/mcp 0.1.7 → 0.1.9

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.
@@ -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
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Item data model — reference\r\n\r\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\r\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\r\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\r\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\r\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\r\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\r\n`.passthrough()`, so a field the backend adds later still round-trips. Field\r\nnames are PascalCase (straight from the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\r\n- [ItemCatalog](#itemcatalog)\r\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\r\n- [ItemDefinition](#itemdefinition)\r\n- [ItemStats](#itemstats)\r\n- [ItemEquipment](#itemequipment)\r\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\r\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\r\n- [ItemMetadata](#itemmetadata)\r\n- [NFTModel](#nftmodel)\r\n- [Player state: InventoryV2](#player-state-inventoryv2)\r\n- [Requests, responses, and actions](#requests-responses-and-actions)\r\n\r\n---\r\n\r\n## Config: ItemDefinitions\r\n\r\nRoot container for every item catalog in the title.\r\n\r\n```ts\r\ninterface ItemDefinitions {\r\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\r\n}\r\n```\r\n\r\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\r\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\r\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\r\nthe exported type is pinned to a hand-written interface instead.\r\n\r\n## ItemCatalog\r\n\r\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\r\n\r\n```ts\r\ninterface ItemCatalog {\r\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\r\n}\r\n```\r\n\r\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\r\nguaranteed unique **within** a catalog — the same `ItemID` string can\r\nlegitimately appear in more than one catalog, which is exactly what the\r\nresolution rule below has to handle.\r\n\r\n## Catalog resolution rule\r\n\r\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\r\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\r\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\r\ncall this yourself, but its behavior explains error messages and a\r\nself-healing field you'll see on upgrade responses:\r\n\r\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\r\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\r\n unambiguous by construction.\r\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\r\n (the item was moved to a different catalog since the instance was granted),\r\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\r\n that's the resolved definition.\r\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\r\n more** catalogs, the resolver refuses to guess and returns nothing (the\r\n caller reports \"item definition not found\").\r\n\r\n**Self-heal:** when resolution succeeds via the fallback path with a\r\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\r\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\r\none as part of the same atomic write — silently, no separate event. That's why\r\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\r\nthe instance before calling upgrade: read it back off the response / refreshed\r\ncache, don't assume it's unchanged.\r\n\r\n---\r\n\r\n## ItemDefinition\r\n\r\nThe template from which player instances are created.\r\n\r\n```ts\r\ninterface ItemDefinition {\r\n ItemID: string;\r\n CatalogID: string;\r\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\r\n DisplayName?: string;\r\n Description?: string;\r\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\r\n CustomData?: string;\r\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\r\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\r\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\r\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\r\n NFT?: NFTModel; // blockchain binding, if any\r\n Stats?: ItemStats;\r\n Equipment?: ItemEquipment;\r\n Upgrade?: ItemUpgrade;\r\n Metadata?: ItemMetadata;\r\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\r\n}\r\n```\r\n\r\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\r\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\r\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\r\nconsumable can still have upgrade tiers, and an equippable item can be\r\nnon-upgradable.\r\n\r\n---\r\n\r\n## ItemStats\r\n\r\nStat modifiers/Power the item contributes when equipped. Applied in two\r\nlayers — flat bonuses added to the base stat first, then percent bonuses\r\nmultiply the (base + flat) total. Consumed by the Character module's Power\r\ncomputation (see character-system skill) — never recomputed client-side.\r\n\r\n```ts\r\ninterface ItemStats {\r\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\r\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\r\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\r\n}\r\n```\r\n\r\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\r\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\r\n\r\n---\r\n\r\n## ItemEquipment\r\n\r\nThe item-side half of the two-sided equip rule matrix (the character-side\r\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\r\nreference doc — both must pass for an equip to succeed).\r\n\r\n```ts\r\ninterface ItemEquipment {\r\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\r\n UseRequirements?: Record<string, number>; // statID -> required character stat level\r\n AllowedCharacterIDs?: string[]; // null/empty = any character\r\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\r\n}\r\n```\r\n\r\n---\r\n\r\n## ItemUpgrade + cost formula\r\n\r\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch`.\r\n\r\n```ts\r\ninterface ItemUpgrade {\r\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\r\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\r\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\r\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\r\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\r\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\r\n}\r\n```\r\n\r\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\r\nnot the step count):\r\n\r\n```\r\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\r\n// BaseCost = the Cost of the selected PriceOptions option\r\n```\r\n\r\n— identical semantics to the Character module's stat-cost scaling, and the same shared\r\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\r\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\r\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\r\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\r\nfrom `current + 1` through the resolved target — it is not a single jump priced\r\noff the destination level alone. If every scaled amount rounds to `0`, or the\r\nselected option is empty, the upgrade is rejected as misconfigured rather than\r\ntreated as free.\r\n\r\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\r\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\r\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\r\nfor the first option available on the caller's platform.\r\n\r\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\r\nthrough unchanged and resolved by the shared premium pipeline per level before\r\nthe per-level bundles are summed — see the currency-system skill for\r\n`ResourceConsume`'s premium fields.\r\n\r\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\r\nwith `firstStep = 1`, so level 1 is the plain base):\r\n\r\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\r\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\r\n before aggregation into the character's total gear-percent.\r\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\r\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\r\n balance any double-counting themselves via weights).\r\n\r\nAn **unset** curve means that quantity does not grow with level — only the base value\r\napplies at every level. There is no field here whose neutral value is `1`: empty is the\r\nneutral, always. These three are read-only\r\ninputs to server computations (Power, PvP stat calc); the SDK never\r\nrecomputes them for you.\r\n\r\n---\r\n\r\n## Fodder valuation + selection modes\r\n\r\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\r\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\r\nand picked when the upgrade's own cost is expressed in copies of itself. Any\r\nother cost entries (currencies, other items, event tokens) are charged\r\nnormally through the regular resource pipeline regardless of `Fodder` config.\r\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\r\n`ProtectLeveled` selection.\r\n\r\n```ts\r\ninterface ItemUpgradeFodder {\r\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\r\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\r\n Selection?: \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\";\r\n}\r\n```\r\n\r\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\r\n\r\nThe server expresses the self-item portion of the upgrade cost as a **target\r\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\r\nburns fodder copies until their summed `W(level)` meets or exceeds that\r\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\r\n\r\n| Mode | `W(L)` formula | Notes |\r\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\r\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\r\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\r\n\r\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\r\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\r\nclient-side for a cost preview, but the server is what actually enforces\r\ncoverage.\r\n\r\n### Selection — which instances get burned\r\n\r\nOnly relevant when the caller doesn't already specify exact fodder for every\r\nunit needed (or when supply must be chosen automatically):\r\n\r\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\r\n eligible; anything the player has already leveled up is never auto-selected\r\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\r\n in the sense that the pool is still filtered this way in `FlatCount` mode\r\n (where fodder isn't weighted at all — see below).\r\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\r\n same-item/same-catalog, not equipped, not expired, not already claimed by\r\n another item in the same batch) are sorted by `W(level)` ascending, then by\r\n acquisition time, then by ID, and burned cheapest-first until the target\r\n value is covered. Leveled copies are eligible here and burn last (they're\r\n worth more per unit, so they're a poor early pick under this greedy order).\r\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\r\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\r\n validated individually (must exist, must be the same item/catalog, must not\r\n be equipped or expired, must not already be claimed elsewhere in the same\r\n batch) and rejected by name if any check fails. If the combined `W(level)`\r\n of your supplied instances doesn't cover the target, the whole upgrade is\r\n rejected — nothing is partially burned.\r\n\r\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\r\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\r\nthat mode the self-item cost is settled by the _regular_ resource-consume\r\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\r\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\r\nrather than being burned), and the rest come from plain inventory count, with\r\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\r\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\r\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\r\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\r\nself-item cost entry, or fodder that's a different item, is rejected.\r\n\r\n---\r\n\r\n## ItemMetadata\r\n\r\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\r\nsubsystem and general UI.\r\n\r\n```ts\r\ninterface ItemMetadata {\r\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\r\n CollectionID?: string; // ties the item into a Collection set/album page\r\n AuthorID?: string; // e.g. UGC/creator attribution\r\n}\r\n```\r\n\r\n---\r\n\r\n## NFTModel\r\n\r\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\r\nitem mirrored on both an EVM chain and Solana).\r\n\r\n```ts\r\ninterface NFTModel {\r\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\r\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\r\n}\r\n\r\ninterface NFTNetworkBinding {\r\n ContractAddress?: string; // EVM contract or Solana mint address\r\n TokenID?: string;\r\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\r\n}\r\n```\r\n\r\nSee the blockchain-system skill for the wallet/mint/transfer flows that\r\npopulate and consume this binding.\r\n\r\n---\r\n\r\n## Player state: InventoryV2\r\n\r\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\r\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch` (and other inventory-affecting calls).\r\n\r\n```ts\r\ninterface UserInventoryState {\r\n Version?: number;\r\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\r\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\r\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\r\n ConversionDaily?: Record<string, ConversionDailyCounter>;\r\n}\r\n\r\ninterface ItemTotals {\r\n StackableAmount: number;\r\n UnstackableAmount: number;\r\n TotalAmount: number;\r\n}\r\n\r\ninterface UnstackableItemInstanceState {\r\n ItemInstanceID: string;\r\n ItemID: string;\r\n CatalogID?: string | null;\r\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\r\n RemainingUses?: number; // consumable-with-charges items\r\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\r\n AcquiredAt: string;\r\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\r\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\r\n CustomData?: string | null;\r\n}\r\n```\r\n\r\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\r\ncurrency-system skill; they ride along in the same inventory snapshot but\r\naren't item-related.\r\n\r\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\r\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\r\nwhich half of the inventory backs it.\r\n\r\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\r\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\r\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\r\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\r\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\r\none copy — e.g. leveling one copy of a stack of five identical swords — the\r\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\r\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\r\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\r\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\r\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\r\ncome back as a **different** id than the one you called with — always read\r\nthe instance id off the response (or the refreshed cache), don't assume it's\r\nunchanged. The backend also opportunistically re-merges pristine fragments of\r\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\r\nyou don't need to do anything to trigger or handle that.\r\n\r\n---\r\n\r\n## Requests, responses, and actions\r\n\r\n```ts\r\ninterface ItemRequest extends BaseRequest {\r\n ItemInstanceID?: string;\r\n Levels?: number;\r\n TargetLevel?: number;\r\n FodderInstanceIDs?: string[];\r\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\r\n Upgrades?: ItemUpgradeRef[];\r\n}\r\n```\r\n\r\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\r\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\r\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\r\n**not** expose them — it only ever raises by one level per call. To move\r\nseveral levels in one call (on one or many instances), use\r\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\r\n\r\n```ts\r\ninterface ItemUpgradeRef {\r\n ItemInstanceID?: string;\r\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\r\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\r\n FodderInstanceIDs?: string[];\r\n}\r\n\r\ninterface FodderConsumedEntry {\r\n ItemInstanceID: string;\r\n Units: number; // how many copies of this instance/pack were burned\r\n Level: number; // the fodder instance's level at time of consumption\r\n}\r\n\r\ninterface UpgradeItemLevelResponse {\r\n ServerTimeUtc: string;\r\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\r\n ItemID: string;\r\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\r\n Level: number; // new level after the upgrade\r\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\r\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\r\n}\r\n\r\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\r\n```\r\n\r\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\r\nuseful when reading logs/errors that echo the action):\r\n\r\n```ts\r\nconst ItemAction = {\r\n UpgradeLevel: \"UpgradeLevel\",\r\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\r\n} as const;\r\n```\r\n\r\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\r\nshape used across the whole SDK — see the currency-system skill for the full\r\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\r\n`PremiumDiscounts` can reduce a displayed base cost.\r\n\r\n### Server-side limits (verified against the backend)\r\n\r\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\r\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\r\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\r\n appear in the result array at all. Chunk larger sets yourself.\r\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\r\n id in the same call only processes once.\r\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\r\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\r\n (single call fails outright; batch reports it as a failed item).\r\n- **Atomicity**: both the single and batch charge/patch happen inside one\r\n Mongo transaction — either the whole thing (cost + level + any fodder burns\r\n - owner Power recompute) applies, or none of it does.\r\n- **Idempotency**: the backend replays the same result for a repeated call\r\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\r\n server-side reason key, so re-running the _same target level_ twice is safe\r\n to retry). The TS `upgradeLevel` method, however, mints a fresh\r\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\r\n from the SDK's side, two separate calls are always two separate operations;\r\n see the Gotchas section in SKILL.md.\r\n"
8
+ "content": "# Item data model — reference\n\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\n- [ItemCatalog](#itemcatalog)\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\n- [ItemDefinition](#itemdefinition)\n- [ItemStats](#itemstats)\n- [ItemEquipment](#itemequipment)\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\n- [ItemMetadata](#itemmetadata)\n- [NFTModel](#nftmodel)\n- [Player state: InventoryV2](#player-state-inventoryv2)\n- [Requests, responses, and actions](#requests-responses-and-actions)\n\n---\n\n## Config: ItemDefinitions\n\nRoot container for every item catalog in the title.\n\n```ts\ninterface ItemDefinitions {\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\n}\n```\n\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\nthe exported type is pinned to a hand-written interface instead.\n\n## ItemCatalog\n\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\n\n```ts\ninterface ItemCatalog {\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\n}\n```\n\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\nguaranteed unique **within** a catalog — the same `ItemID` string can\nlegitimately appear in more than one catalog, which is exactly what the\nresolution rule below has to handle.\n\n## Catalog resolution rule\n\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\ncall this yourself, but its behavior explains error messages and a\nself-healing field you'll see on upgrade responses:\n\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\n unambiguous by construction.\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\n (the item was moved to a different catalog since the instance was granted),\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\n that's the resolved definition.\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\n more** catalogs, the resolver refuses to guess and returns nothing (the\n caller reports \"item definition not found\").\n\n**Self-heal:** when resolution succeeds via the fallback path with a\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\none as part of the same atomic write — silently, no separate event. That's why\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\nthe instance before calling upgrade: read it back off the response / refreshed\ncache, don't assume it's unchanged.\n\n---\n\n## ItemDefinition\n\nThe template from which player instances are created.\n\n```ts\ninterface ItemDefinition {\n ItemID: string;\n CatalogID: string;\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\n DisplayName?: string;\n Description?: string;\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\n CustomData?: string;\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\n NFT?: NFTModel; // blockchain binding, if any\n Stats?: ItemStats;\n Equipment?: ItemEquipment;\n Upgrade?: ItemUpgrade;\n Metadata?: ItemMetadata;\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\n}\n```\n\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\nconsumable can still have upgrade tiers, and an equippable item can be\nnon-upgradable.\n\n---\n\n## ItemStats\n\nStat modifiers/Power the item contributes when equipped. Applied in two\nlayers — flat bonuses added to the base stat first, then percent bonuses\nmultiply the (base + flat) total. Consumed by the Character module's Power\ncomputation (see character-system skill) — never recomputed client-side.\n\n```ts\ninterface ItemStats {\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\n}\n```\n\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\n\n---\n\n## ItemEquipment\n\nThe item-side half of the two-sided equip rule matrix (the character-side\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\nreference doc — both must pass for an equip to succeed).\n\n```ts\ninterface ItemEquipment {\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\n UseRequirements?: Record<string, number>; // statID -> required character stat level\n AllowedCharacterIDs?: string[]; // null/empty = any character\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\n}\n```\n\n---\n\n## ItemUpgrade + cost formula\n\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\n`upgradeLevelsBatch`.\n\n```ts\ninterface ItemUpgrade {\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\n// BaseCost = the Cost of the selected PriceOptions option\n```\n\n— identical semantics to the Character module's stat-cost scaling, and the same shared\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\nfrom `current + 1` through the resolved target — it is not a single jump priced\noff the destination level alone. If every scaled amount rounds to `0`, or the\nselected option is empty, the upgrade is rejected as misconfigured rather than\ntreated as free.\n\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\nfor the first option available on the caller's platform.\n\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\nwith `firstStep = 1`, so level 1 is the plain base):\n\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\n before aggregation into the character's total gear-percent.\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\n balance any double-counting themselves via weights).\n\nAn **unset** curve means that quantity does not grow with level — only the base value\napplies at every level. There is no field here whose neutral value is `1`: empty is the\nneutral, always. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\n Selection?:\n \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\" | \"SameLevelOnly\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\n and so on. This is the classic tier merge, and it is the mode you want when\n the design reads \"two of the same tier make one of the next\".\n\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\n _order_, not a restriction: once the cheap copies run out it will burn a\n leveled one, and it burns it **whole** (an instance cannot be partially\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\n the upgrade is refused instead, with an error naming the level that is\n short. Overshoot is impossible whenever the weight curve is integral,\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\n upgrade.\n\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: string | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
9
9
  }
10
10
  ]
11
11
  }