@idosgames/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/cli.js +204 -0
  4. package/package.json +46 -0
  5. package/registry/host.json +41 -0
  6. package/registry/index.json +215 -0
  7. package/registry/modules/board-game.json +121 -0
  8. package/registry/modules/currency-hud.json +28 -0
  9. package/registry/modules/idle-rpg.json +89 -0
  10. package/registry/modules/voxelcraft.json +163 -0
  11. package/registry/skills/authentication.json +11 -0
  12. package/registry/skills/blockchain-system.json +11 -0
  13. package/registry/skills/character-system.json +11 -0
  14. package/registry/skills/cloud-code.json +6 -0
  15. package/registry/skills/collection-system.json +11 -0
  16. package/registry/skills/coop-event-system.json +11 -0
  17. package/registry/skills/craft-system.json +11 -0
  18. package/registry/skills/currency-system.json +11 -0
  19. package/registry/skills/deal-offer-system.json +11 -0
  20. package/registry/skills/dev-test-loop.json +6 -0
  21. package/registry/skills/game-loop-system.json +11 -0
  22. package/registry/skills/idosgames-compose-modules.json +6 -0
  23. package/registry/skills/idosgames-getting-started.json +6 -0
  24. package/registry/skills/idosgames-module-contract.json +6 -0
  25. package/registry/skills/item-system.json +11 -0
  26. package/registry/skills/leaderboard-system.json +11 -0
  27. package/registry/skills/lootbox-system.json +11 -0
  28. package/registry/skills/marketplace-system.json +11 -0
  29. package/registry/skills/match-system.json +11 -0
  30. package/registry/skills/multiplayer-realtime.json +11 -0
  31. package/registry/skills/premium-system.json +11 -0
  32. package/registry/skills/quest-system.json +11 -0
  33. package/registry/skills/referral-system.json +11 -0
  34. package/registry/skills/reward-system.json +11 -0
  35. package/registry/skills/season-system.json +11 -0
  36. package/registry/skills/social-system.json +6 -0
  37. package/registry/skills/store-system.json +11 -0
  38. package/registry/skills/timed-boost-system.json +11 -0
  39. package/registry/skills/timed-event-system.json +11 -0
  40. package/registry/skills/title-system.json +6 -0
  41. package/registry/skills/user-custom-data.json +11 -0
  42. package/registry/skills/user-profile.json +11 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "game-loop-system",
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",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
8
+ "content": "# Game loop data model — reference\n\nFull shape of the config (Definitions) and player state for both the board\nloop and Community Chest, the request/response payloads, and the cache\nmutation rules. All of these are **strictly typed in the SDK** —\n`GameLoopDefinitions`, `BoardLoopDefinition`, `BoardLoopState`, every nested\nblock, and all Community Chest types are exported from `@idosgames/core`, so\n`getGameLoops()`, `getBoardDefinition()`, and `getSection<T>(...)` give you\nconcrete types, not `unknown`. The schemas keep `.passthrough()`, so a field\nthe backend adds later still round-trips. Field names are PascalCase (straight\nfrom the backend JSON).\n\n## Contents\n\n- [Player state: BoardLoopState](#player-state-boardloopstate)\n- [Pending interaction](#pending-interaction)\n- [Special pending state](#special-pending-state)\n- [Config root: GameLoopDefinitions](#config-root-gameloopdefinitions)\n- [Config: BoardLoopDefinition](#config-boardloopdefinition)\n- [Stages, templates, buildings, tiles](#stages-templates-buildings-tiles)\n- [Procedural economy](#procedural-economy)\n- [Bank Heist (raid) config](#bank-heist-raid-config)\n- [Chance tables](#chance-tables)\n- [Special mode config](#special-mode-config)\n- [Community Chest: config](#community-chest-config)\n- [Community Chest: player + group state](#community-chest-player--group-state)\n- [Responses](#responses)\n- [Request shape + action ids](#request-shape--action-ids)\n- [Cache mutation rules](#cache-mutation-rules)\n\n---\n\n## Player state: BoardLoopState\n\nCached at `client.data.user.state?.GameLoop?.Board`, loaded via\n`getUserBoardState()` (full replace) and patched in place by every board\naction.\n\n```ts\ninterface BoardLoopState {\n StageLevel?: number;\n Position?: number; // tile index on the ring\n AvailableRollMultipliers?: number[];\n BuildingStates?: BuildingState[] | null;\n Pending?: BoardPendingInteraction | null; // non-null while an action is unresolved\n CyclesCompleted?: number; // full loops of the ring\n SpecialStats?: SpecialModeStats | null; // lifetime Special-mode counters\n LastRollAtUtc?: string;\n AllStagesCompleted?: boolean;\n [key: string]: unknown; // extra server fields pass through\n}\n\ninterface BuildingState {\n SlotIndex: number;\n Level?: number | null;\n IsDamaged?: boolean | null; // true after being attacked/hit, until rebuilt\n MaxLevelRewardClaimed?: boolean | null; // one-time reward at MaxLevel, already granted\n}\n\ninterface SpecialModeStats {\n PlayedCount?: number | null;\n RewardsClaimedCount?: number | null;\n InstantClaimsCount?: number | null;\n EarlyClaimsCount?: number | null;\n LateClaimsCount?: number | null;\n AdViewsTotal?: number | null;\n}\n```\n\n---\n\n## Pending interaction\n\n`BoardLoopState.Pending` — set by `boardLoopRoll` when the landed tile\nrequires a follow-up action; cleared by resolving it (attack/raid-completion/\nspecial-claim).\n\n```ts\ninterface BoardPendingInteraction {\n Type: string; // \"ATTACK\" | \"RAID\" | \"SPECIAL\" (server-defined strings)\n TargetUserID?: string | null; // ATTACK/RAID vs a real player\n TargetPublicData?: UserPublicDataModel | null; // target's public profile snapshot\n TargetBuildingStates?: BuildingState[]; // ATTACK: target's buildings to hit\n TargetHasShield?: boolean;\n RollMultiplier?: number; // multiplier in effect when this was triggered\n ExpiresAtUtc?: string; // client-side TTL (15 min from the roll), mirrors server expiry\n RaidLayout?: HeistCell[]; // RAID: the 12-cell heist grid\n OpenedIndices?: number[]; // RAID: cells already dug (Sequential mode)\n HeistVariantTag?: string | null;\n IsJackpotRaid?: boolean;\n JackpotFinalMultiplier?: number;\n GuaranteedBonus?: ResourceGrant | null;\n Special?: SpecialPendingState | null; // SPECIAL: chosen-choice tracking\n}\n\ninterface HeistCell {\n Symbol?: string | number | null; // revealed only once dug; match 3-of-a-kind\n OnOpenBonus?: ResourceGrant | null; // already-scaled reward snapshot for this cell\n BonusTag?: string | null;\n}\n```\n\n`RaidLayout` cells' `Symbol`/`OnOpenBonus` are populated by the server as cells\nare dug (Sequential) or all at once up-front (Fast mode sends the full layout\nso the client can reveal locally before submitting).\n\n---\n\n## Special pending state\n\n`BoardPendingInteraction.Special` — tracks a chosen Special-tile choice through\nits Instant/Timed lifecycle.\n\n```ts\ninterface SpecialPendingState {\n ModeID?: string;\n ChoiceID?: string | null;\n ChosenMode?: string | number | null; // \"Instant\"/\"Timed\" or 0/1 — check both\n StartedAtUtc?: string | null;\n DurationSeconds?: number | null;\n AdViewsUsed?: number;\n AccumulatedMultiplier?: number;\n GradationTiers?: SpecialGradationTierSnapshot[] | null; // already-scaled reward-per-elapsed-time snapshot\n BelowFirstTierReward?: ResourceGrant | null;\n Multipliers?: SpecialClaimMultipliers | null;\n /** Offer choices stashed from the roll's SpecialModeOffer so the UI can render\n * them in-session (the server's GetUserBoardState pending only carries ModeID). */\n Choices?: SpecialModeChoice[] | null;\n}\n\ninterface SpecialGradationTierSnapshot {\n ElapsedSeconds?: number | null;\n Reward?: ResourceGrant | null; // already scaled — read directly, don't reapply formulas\n}\n```\n\n`Choices` is why the SDK stashes the roll's `SpecialModeOffer.Choices` into\n`Pending.Special` at choose-time — if the app reloads mid-flow,\n`getUserBoardState()` alone would only return `ModeID`, not the original\nchoice list/rewards, so the client-stashed copy is the only way to re-render\nthe original offer.\n\n---\n\n## Config root: GameLoopDefinitions\n\nCached via `client.data.config.getSection<GameLoopDefinitions>(\"GameLoop\")`,\nloaded by `getGameLoops()`.\n\n```ts\ninterface GameLoopDefinitions {\n Board?: BoardLoopDefinition | null;\n CommunityChest?: CommunityChestDefinition | null;\n [key: string]: unknown;\n}\n```\n\nNote this is a **separate cache section** from what `getBoardDefinition()`\ncaches (see below) — `getGameLoops()` is the only call that also gives you\n`CommunityChest` config.\n\n---\n\n## Config: BoardLoopDefinition\n\nCached via `client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\")`,\nloaded by `getBoardDefinition()` / `getBoardDefinitionForLevel(level)`.\n\n```ts\ninterface BoardLoopDefinition {\n RollCurrencyID?: string | null; // currency spent to roll (if any)\n ShieldCurrencyID?: string | null; // currency spent on defensive shields\n RaidMode?: string | null; // \"Fast\" | \"Sequential\"\n AllowedRollMultipliers?: number[] | null; // valid values for boardLoopRoll(mult)\n BoardTemplatesByID?: Record<string, BoardTemplateDefinition> | null;\n StageTemplatesByID?: Record<string, BoardStageTemplate> | null;\n StagesByLevel?: Record<string, BoardStageDefinition> | null; // key = level as string\n SoftCurrencyID?: string | null;\n ProceduralEconomy?: ProceduralEconomyConfig | null;\n Dice?: BoardDiceConfig | null;\n Bots?: BoardBotConfig | null;\n}\n\ninterface BoardDiceConfig {\n Count?: number | null; // dice rolled per turn\n Sides?: number | null; // faces per die; step = sum of Count dice each 1..Sides\n}\n\ninterface BoardBotConfig {\n ShieldChance?: number | null; // probability a bot target has a shield\n DamagedBuildingChance?: number | null;\n RankMultiplierMin?: number | null; // bot power scaling vs player, sampled range\n RankMultiplierMax?: number | null;\n RankOffsetMin?: number | null;\n RankOffsetMax?: number | null;\n}\n```\n\n`RaidMode` is a **global** setting (not per-stage) — it decides whether the\nwhole title uses `boardLoopRaid` (Sequential) or `boardLoopRaidFast` (Fast) for\nevery raid; read it once via `readBoardConfig(client).RaidMode` (as\n`templates/board-game` does) to pick which raid method your RaidPanel calls.\n\n---\n\n## Stages, templates, buildings, tiles\n\n```ts\ninterface BoardTemplateDefinition {\n Tiles?: Record<string, BoardTileDefinition> | null; // key = tile index as string\n}\n\ninterface BoardTileDefinition {\n Index?: number | null;\n Type?: string | null; // \"Attack\" | \"Raid\" | \"Chance\" | \"Special\" | ... (server-defined)\n CustomTypeID?: string | null;\n RandomActionAttackWeight?: number | null; // for tiles that randomly pick Attack vs Raid\n RandomActionRaidWeight?: number | null;\n ChanceTableID?: string | null; // routes to StageOperations.ChanceTablesByID\n SpecialModeID?: string | null; // routes to StageOperations.SpecialModesByID\n Params?: Record<string, string> | null;\n}\n\ninterface BuildingDefinition {\n SlotIndex?: number | null;\n Name?: string | null;\n AssetPaths?: Record<string, string> | null;\n MaxLevel?: number | null;\n MaxLevelReward?: ResourceGrant | null; // one-time reward on hitting MaxLevel\n}\n\ninterface BoardStageDefinition {\n Name?: string | null;\n AssetPaths?: Record<string, string> | null;\n BoardTemplateID?: string | null; // which tile ring this stage uses\n Buildings?: BuildingDefinition[] | null;\n StageTemplateID?: string | null; // which economy template this stage uses\n CostMatrixTemplateID?: string | null;\n UnitValue?: number | null;\n Override?: BoardStageTemplate | null; // per-stage economy override (see below)\n}\n\ninterface BoardStageTemplate {\n BaseAttackReward?: ResourceGrant | null;\n BaseRaidReward?: ResourceGrant | null;\n MaxRollMultiplier?: number | null;\n MaxShields?: number | null;\n Buildings?: BuildingDefinition[] | null;\n HeistGridBonusConfig?: HeistGridBonusConfig | null;\n StageOperations?: StageOperations | null;\n TileLandingMultiplier?: RewardMultiplierRange | null;\n EconomyOverride?: StageEconomyOverride | null;\n}\n```\n\n`BoardStageTemplate` is reused both as the shared template referenced by\n`StageTemplateID` (in `StageTemplatesByID`) and as the shape of a per-stage\n`Override` block — an `Override` field, when present, wins over the\ntemplate's value for that stage.\n\n```ts\ninterface RewardMultiplierRange {\n Min?: number | null;\n Max?: number | null; // reward multiplier sampled uniformly in [Min, Max]\n}\n\ninterface StageOperations {\n OnPassStart?: ScaledResourceOperation | null; // granted on passing the start tile\n OnTileLanding?: Record<string, ScaledResourceOperation> | null; // keyed by tile type\n OnAttack?: Record<string, ScaledResourceOperation> | null; // keyed by outcome (\"Hit\"/\"Blocked\")\n OnRaid?: Record<string, ScaledResourceOperation> | null;\n OnBuild?: ScaledResourceOperation | null;\n OnStageComplete?: ScaledResourceOperation | null;\n OnAttackBonusDrops?: AttackBonusDrop[] | null; // independent probabilistic bonus drops\n ChanceTablesByID?: Record<string, ChanceTable> | null;\n SpecialModesByID?: Record<string, SpecialModeDefinition> | null;\n}\n\ninterface ScaledResourceOperation {\n Operation?: ResourceOperation | null;\n ScaleWithRollMultiplier?: boolean | null; // if true, Operation amounts scale by the roll's multiplier\n}\n\ninterface AttackBonusDrop {\n Chance?: number | null;\n RequiredOutcome?: string | null; // only rolls if the attack's Outcome matches\n Reward?: ScaledResourceOperation | null;\n Tag?: string | null;\n}\n```\n\n---\n\n## Procedural economy\n\nDrives cost/reward scaling as stages progress, independent of hand-authored\nper-stage numbers. **All of this is server-computed** — the client never\nderives attack/raid/build values itself; read the resolved amounts off each\nresponse's `Operation`/`Reward` fields. The formulas below (transcribed from\n`EconomyMath.cs` and `GameLoop.cs`) are for building cost/reward _previews_ in\nUI, not for computing anything that gets charged.\n\n```ts\ninterface ProceduralEconomyConfig {\n CostMatrixTemplatesByID?: Record<string, number[][]> | null; // named normalized cost matrices\n PerBoardGrowth?: number | null; // tail growth rate applied per stage past the last authored anchor\n VisualTemplateCycle?: string[] | null; // board-template ids cycled for synthesized (unauthored) stages\n EconomyScaledResources?: ResourceBundle | null; // which currencies/items get EconomyScale applied\n ScaleRaidStealWithEconomy?: boolean | null;\n ScaleAttackRewardWithEconomy?: boolean | null;\n AttackValueFromCostMatrix?: boolean | null;\n AttackBlockedRewardFactor?: number | null;\n ScaleVictimLossWithEconomy?: boolean | null;\n ScaleVictimLossWithTier?: boolean | null;\n SynthesizedBuildingSlots?: number | null; // building slot count for stages past authored content\n SynthesizedBuildingMaxLevel?: number | null;\n}\n\ninterface StageEconomyOverride {\n CostMatrix?: number[][] | null; // replaces the whole matrix for this stage\n PerBoardGrowth?: number | null;\n EconomyScale?: number | null; // manual override; wins over the computed Unit(N)/Unit(1) ratio\n ScaledResources?: ResourceBundle | null;\n}\n```\n\n### The board is infinite: `Unit(N)` and stage synthesis\n\n`BoardStageDefinition.UnitValue` marks a stage as an \"anchor\" — the intended\nsoft-currency cost of that stage's slot-0/level-0 building. `EconomyMath.ResolveUnitValue(N)`\n(`IDosGamesSDK/API/Client/v2/GameLoop/Services/EconomyMath.cs:50-87`) derives a\nunit price for **any** stage level `N`, authored or not:\n\n- Exact anchor match → that anchor's `UnitValue`.\n- `N` below the lowest anchor → clamped to the lowest anchor's value (no\n extrapolation backward).\n- `N` between two anchors → **geometric interpolation**:\n `lower.Unit * (upper.Unit / lower.Unit) ^ t`, where\n `t = (N - lower.Level) / (upper.Level - lower.Level)`.\n- `N` above the highest anchor → **geometric extrapolation** using a tail\n growth rate `g`: `last.Unit * g ^ (N - last.Level)`. `g` is the last\n authored stage's `Override.EconomyOverride.PerBoardGrowth` if set (> 0),\n otherwise the global `ProceduralEconomy.PerBoardGrowth` (`EconomyMath.cs:125-142`).\n\n`EconomyScale(N) = Unit(N) / Unit(1)` (`EconomyMath.cs:93-101`), i.e. the\nboard's overall reward/cost magnitude relative to stage 1 — unless a stage's\n`EconomyOverride.EconomyScale` is set (> 0), which wins outright.\n\nBecause of this, **a title's board never runs out of stages**: once a player's\n`StageLevel` exceeds the highest key in `StagesByLevel`, the server\nsynthesizes a stage on the fly (`GameLoop.cs:2899-2932`) — visuals cycle\nthrough `VisualTemplateCycle`, the economy _shape_ (which `StageTemplateID`,\ni.e. which reward/attack/raid rules apply) is inherited from the nearest\nauthored stage below it, and building slots come from `SynthesizedBuildingSlots`/\n`SynthesizedBuildingMaxLevel`. `BoardLoopState.AllStagesCompleted` exists in\nthe SDK's types but the backend never sets it — don't build UI around a \"final\nstage.\"\n\n### Build cost\n\n`EconomyMath.CalcBuildCost(matrix, unit, slot, level)` (`EconomyMath.cs:107-118`):\n\n```\nrawCost = ceil(Unit(N) × CostMatrix[slot][level])\n```\n\n`level` is the building's **current** level (0-based) before the upgrade —\ni.e. the cost to go from `level` to `level + 1`. The result then passes\nthrough the player's `EconomyTuning` cost multiplier and any active\n`BoardBuildCost`-targeted `TimedBoost` (`GameLoop.cs:1480-1485`), floored at 1.\n`EconomyScale` is **not** applied to build cost — `Unit(N)` already encodes\nthe board's cost progression on its own (`GameLoop.cs:1475-1476` comment).\n`CostMatrix` is resolved per stage: an inline `EconomyOverride.CostMatrix` on\nthe stage wins outright; otherwise it's looked up by\n`CostMatrixTemplateID`/`CostMatrixTemplatesByID` (default template id\n`\"Universal\"`, case-insensitive) — see `BoardStageResolver.cs:142-160`.\n\n### Attack reward\n\nBase attacker reward is `BaseAttackReward` scaled by the current\n`Pending.RollMultiplier`, then optionally by a \"building value weight\" and/or\n`EconomyScale`, then halved (or whatever factor) if blocked\n(`GameLoop.cs:900-918`):\n\n```\nattackWeight = AttackValueFromCostMatrix\n ? CostMatrix[targetSlot][targetBuildingLevel - 1] // value of the building actually hit\n : 1.0\nreward = BaseAttackReward × RollMultiplier (roll-scale, ScaleBundleForReward)\nreward *= attackWeight (building-value weight, if enabled)\nreward *= EconomyScale (if ScaleAttackRewardWithEconomy)\nreward *= AttackBlockedRewardFactor (only if Outcome == Blocked)\n```\n\nThe `OnAttack[outcome]` stage-hook reward (separate from `BaseAttackReward`)\nis scaled by `RollMultiplier` only, then also by `EconomyScale` if\n`ScaleAttackRewardWithEconomy` is set — it does **not** get the building-value\nweight. For a bot target, the \"building\" is a random slot/level sampled the\nsame way bot buildings are generated; for a real victim it's the actual\nbuilding about to be hit (level read **before** the hit decrements it).\n\n### Raid (Bank Heist) reward — attacker gain vs. victim loss are decoupled\n\nThese are two independent numbers (`GameLoop.cs:2370-2383` doc comment, math\nat `2521-2578`) — the attacker's gain is **not** derived from what the victim\nactually loses:\n\n```\nattackerGain = BaseRaidReward × RollMultiplier × heistMultiplier (heistMultiplier: 1/2/5, see below)\nattackerGain *= EconomyScale(attacker's own board) (if ScaleRaidStealWithEconomy)\nattackerGain *= JackpotFinalMultiplier (only if the raid variant IsJackpot)\n\nvictimLossRequested = BaseRaidReward × (heistMultiplier if ScaleVictimLossWithTier else 1)\nvictimLossRequested *= EconomyScale(victim's own board) (if ScaleVictimLossWithEconomy)\nvictimLoss = min(victimLossRequested, victim's actual balance) (clipped per-entry, never overdraws)\n```\n\n`heistMultiplier` is 1/2/5 for Small/Medium/Big (3-of-a-kind), and jackpot\nraids apply `JackpotFinalMultiplier` **only to `attackerGain`** — the victim's\nloss never carries the jackpot multiplier. If the victim's balance for a\nrequested resource is 0, that resource is simply excluded from what's taken\n(the attacker still receives their full system-side `attackerGain`\nregardless — a raid against an empty bank never fails, it just steals\nnothing). Bot targets have no real balance to clip, so a raid against a bot\nalways \"steals\" the full unclipped amount.\n\nCell bonuses (`HeistCell.OnOpenBonus`) and any `GuaranteedBonus` are\nindependent system-side grants to the attacker, scaled once at raid-creation\ntime by `RollMultiplier` and (if `ScaleRaidStealWithEconomy`) `EconomyScale` —\nthey are never clipped by the victim's balance either.\n\n---\n\n## Bank Heist (raid) config\n\n```ts\ninterface HeistGridBonusConfig {\n Variants?: HeistRaidVariant[] | null;\n}\n\ninterface HeistRaidVariant {\n Weight?: number | null; // relative chance this variant is picked for a given raid\n Tag?: string | null;\n MinBonusCells?: number | null;\n MaxBonusCells?: number | null;\n BonusPool?: WeightedHeistCellBonus[] | null;\n GuaranteedBonus?: ScaledResourceOperation | null;\n IsJackpot?: boolean | null;\n JackpotFinalMultiplier?: number | null;\n JackpotSymbolDistribution?: Record<string, number> | null; // symbol name -> cell count\n}\n\ninterface WeightedHeistCellBonus {\n Weight?: number | null;\n Bonus?: ScaledResourceOperation | null;\n Tag?: string | null;\n}\n```\n\nThe grid is always a fixed 12 cells (`digIndex` 0-11 in `boardLoopRaid`,\nmatching `zHeistCell` array length conventions used elsewhere in the module).\nA raid's specific layout (symbols, bonuses, jackpot-ness) is generated\nserver-side from a weighted `HeistRaidVariant` pick and sent down as\n`Pending.RaidLayout` — the client never generates or validates the layout.\n\n### Layout generation (server-side, `GameLoop.cs:3240-3339`)\n\n1. **Variant pick**: standard weighted selection over `Variants` — cumulative\n sum of positive `Weight`s, uniform roll in `[0, total)`. No `Variants`\n configured (or none with positive weight) → the classic fallback layout: a\n shuffled 4×Small / 4×Medium / 4×Big.\n2. **Symbols**: if the picked variant `IsJackpot` and its\n `JackpotSymbolDistribution` values sum to exactly 12, that exact symbol mix\n is used (shuffled); any other case (non-jackpot, or a distribution that\n doesn't sum to 12) falls back to the classic 4/4/4 shuffle.\n3. **Bonus cells**: if `MaxBonusCells > 0` and `BonusPool` is non-empty, a\n random count in `[MinBonusCells, MaxBonusCells]` (clamped to `[0, 12]`) of\n distinct cell indices are chosen, and each gets an independently\n weighted-picked bonus from `BonusPool` (same cumulative-weight algorithm as\n the variant pick). Each bonus is scaled once at generation time\n (`RollMultiplier` + boosts) and frozen into `HeistCell.OnOpenBonus` — it is\n not re-scaled at reveal or claim time.\n4. **`GuaranteedBonus`**, if set on the variant, is scaled the same way and\n returned separately as `Pending.GuaranteedBonus` (folded into the\n attacker's system-side gain at raid finalization, not per-cell).\n\nWin condition: **3 of the same symbol among opened cells** ends the raid,\nchecked after every dig — in `Sequential` mode this means exactly 3 opened\ncells share a symbol (checked with `==` since a 4th identical symbol can't be\ndug after the raid already ended); in `Fast` mode the whole submitted batch is\ncounted at once (checked with `>=` since a client could submit more than 3\nmatching indices in one call). `RaidOutcome`/`Status` mapping: 3×Small →\n`\"Small\"` outcome / `heistMultiplier` 1, 3×Medium → `\"Medium\"` / 2, 3×Big →\n`\"Big\"` / 5; if the raid's variant `IsJackpot`, the outcome is forced to\n`\"Jackpot\"` regardless of which symbol matched, and `JackpotFinalMultiplier`\nis applied to the attacker's gain only (see the raid reward formula above).\n`RaidResponse.Status` on the terminal call is `\"FINISHED_\" + tier` in\nupper-case (`\"FINISHED_SMALL\"`, `\"FINISHED_MEDIUM\"`, `\"FINISHED_BIG\"`,\n`\"FINISHED_JACKPOT\"`) — never a bare `\"Complete\"`.\n\n---\n\n## Chance tables\n\n```ts\ninterface ChanceTable {\n Outcomes?: ChanceOutcome[] | null;\n}\n\ninterface ChanceOutcome {\n Weight?: number | null;\n OutcomeID?: string | null;\n Reward?: ScaledResourceOperation | null;\n ForceAction?: string | null; // can force the tile to also trigger Attack/Raid/Special\n SpecialModeID?: string | null;\n}\n```\n\nA `Chance`-type tile resolves one weighted `ChanceOutcome` server-side on\nlanding; the outcome's `Reward` (and possibly `ForceAction`) is what shows up\nin the `boardLoopRoll` response — there's no separate \"resolve chance\" method.\n\n---\n\n## Special mode config\n\n```ts\ninterface SpecialModeDefinition {\n Choices?: SpecialModeChoice[] | null;\n OfferExpireSeconds?: number | null; // how long the offer stays choosable\n ClaimExpireSeconds?: number | null; // how long a claim stays valid after the window\n}\n\ninterface SpecialModeChoice {\n ChoiceID?: string | null;\n Mode?: string | null; // \"Instant\" | \"Timed\"\n Reward?: ScaledResourceOperation | null; // used directly for Instant\n DurationSeconds?: number | null; // Timed window length\n EntryCost?: ResourceConsume | null; // charged on boardSpecialChoose, if set\n Multipliers?: SpecialClaimMultipliers | null;\n Gradation?: SpecialGradationConfig | null;\n}\n\ninterface SpecialClaimMultipliers {\n PerAdMultiplierRange?: RewardMultiplierRange | null; // range each ad-view multiplier is sampled from\n MaxAdViews?: number | null; // cap on boardSpecialApplyMultiplier calls (enforced as-configured; no hidden hard ceiling)\n AdCreditCost?: number | null;\n FormulaKind?: string | null; // \"Additive\" (default) | \"Multiplicative\" — the only two values the backend defines\n ApplyMultiplierOnEarlyClaim?: boolean | null; // default false\n}\n\ninterface SpecialGradationConfig {\n Tiers?: SpecialGradationTier[] | null; // reward grows the longer the player waits\n BelowFirstTierReward?: ScaledResourceOperation | null; // paid if claimed before the first tier\n}\n\ninterface SpecialGradationTier {\n ElapsedSeconds?: number | null;\n Reward?: ScaledResourceOperation | null;\n}\n```\n\n### Multiplier accumulation and claim formulas (`GameLoop.cs:1981-2254`)\n\nEach `boardSpecialApplyMultiplier` call rolls one step uniformly from\n`PerAdMultiplierRange` (`[Min, Max]`, or the fixed value if `Min == Max`) and\nfolds it into `AccumulatedMultiplier`:\n\n```\nFormulaKind \"Additive\" (default): AccumulatedMultiplier += rolled (starts at 0.0)\nFormulaKind \"Multiplicative\": AccumulatedMultiplier *= rolled (starts at 1.0)\n```\n\n`MaxAdViews` is enforced exactly as configured — there is no separate\nhardcoded safety ceiling beyond it.\n\nAt `boardSpecialClaim`, the gradation tier reached determines the _base_\nreward snapshot, and a separate `finalMultiplier` is applied on top of it:\n\n- **Tier selection**: walk the pre-sorted (ascending `ElapsedSeconds`) tier\n list and take the **highest** tier whose `ElapsedSeconds` threshold has\n already elapsed. If none has elapsed yet, `reachedTierIndex = -1` and the\n claim uses `BelowFirstTierReward` — but only if that field is configured;\n otherwise the claim is rejected with `\"Play window not closed yet\"` (there's\n no way to claim nothing).\n- **`IsEarlyClaim`** is `reachedTierIndex < 0` specifically — i.e., true only\n for the below-first-tier case. Reaching tier 0 (the very first configured\n tier) already counts as a real claim, `IsEarlyClaim = false`.\n- **`finalMultiplier`**: if early **and** `ApplyMultiplierOnEarlyClaim` is\n `false` (the default), `finalMultiplier = 1.0` — every ad-view multiplier\n rolled is discarded. Otherwise: `\"Additive\"` → `1 + AccumulatedMultiplier`;\n `\"Multiplicative\"` → `AccumulatedMultiplier` itself (floored to 1.0 if it\n somehow ended up ≤ 0).\n- The tier's frozen `Reward` snapshot (or `BelowFirstTierReward`) is cloned and\n every amount multiplied by `finalMultiplier`, then granted.\n\nResponse-side preview types mirror the config but strip fields the client\nshouldn't see ahead of time:\n\n```ts\ninterface SpecialModeChoicePreview {\n ChoiceID?: string | null;\n Mode?: string | null;\n Reward?: ScaledResourceOperation | null;\n DurationSeconds?: number | null;\n EntryCost?: ResourceConsume | null;\n Multipliers?: SpecialClaimMultipliers | null;\n // (no Gradation — gradation tiers are resolved and applied server-side at claim time)\n}\n\ninterface SpecialModeOfferData {\n ModeID?: string | null;\n Choices?: SpecialModeChoicePreview[] | null;\n}\n```\n\n---\n\n## Community Chest: config\n\nPart of `GameLoopDefinitions.CommunityChest` (loaded via `getGameLoops()`,\ncached under section `\"GameLoop\"` — not `\"BoardDefinition\"`).\n\n```ts\ninterface CommunityChestDefinition {\n IsActive?: boolean | null; // feature kill-switch\n DisplayName?: string | null;\n AssetPaths?: Record<string, string> | null;\n AnchorUtc?: string | null; // reference time rounds are scheduled from\n RoundDurationSec?: number | null;\n PauseBetweenRoundsSec?: number | null;\n MaxRounds?: number | null; // lifetime cap on rounds per group/player, if any\n PartnerCount?: number | null; // target group size\n MatchmakingTimeoutMinutes?: number | null; // how long a \"Forming\" group waits before bot-fill\n MemberGracePeriodMinutes?: number | null; // declared but currently unused by the backend (see note below)\n ContributionMin?: number | null; // per-roll contribution range\n ContributionMax?: number | null;\n ScaleContributionWithRollMultiplier?: boolean | null;\n MaxProgress?: number | null; // shared meter's completion threshold\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID; shared Core/Milestone block\n GrandPrize?: ResourceGrant | null;\n}\n```\n\n`Milestones` uses the same shared `MilestoneDefinition` type as other modules\nin the SDK (imported from `../_shared/MilestoneModels`) — it is not a\nGameLoop-specific shape, so if you've already read that reference elsewhere,\nit applies unchanged here.\n\n**`MemberGracePeriodMinutes` has no reader anywhere in `CommunityChestService.cs`\n/ `CommunityChestDBService.cs`** — it's a declared-but-dead config field today.\n`LeaveAsync` marks the leaving member `\"Left\"` immediately and, if that empties\nout a still-`\"Forming\"` group, flips it straight to `\"Failed\"` with no grace\nwindow. Don't build UI implying a departed slot gets a grace period before\nbackfill.\n\n### Round scheduling and matchmaking (`CommunityChestService.cs`)\n\nRounds are a **title-wide schedule**, not per-player: `ComputeRound` derives\nthe current round purely from wall-clock time (`CommunityChestService.cs:628-653`):\n\n```\ncycle = RoundDurationSec + max(0, PauseBetweenRoundsSec)\nelapsed = now - AnchorUtc\nroundIndex = floor(elapsed / cycle)\nposInCycle = elapsed - roundIndex * cycle\nround is active only if: now >= AnchorUtc, posInCycle < RoundDurationSec,\n and (MaxRounds <= 0 || roundIndex < MaxRounds)\n```\n\nSo `MaxRounds` caps the number of rounds ever scheduled title-wide (once\nexhausted, the feature goes permanently inactive for everyone) — it is not a\nper-player attempt limit. During the `PauseBetweenRoundsSec` gap between two\nrounds, `joinOrCreateCommunityChest` fails with `\"No active Community Chest\nround.\"`.\n\n`joinOrCreateCommunityChest` (`JoinOrCreateAsync`): group size is\n`1 + max(0, PartnerCount)`. It looks for an existing `\"Forming\"` group for the\ncurrent round with free slots; if none exists it creates one; if the found\ngroup is full or a race loses the OCC-guarded join, it retries up to 5 times\nbefore failing with `\"Matchmaking failed after retries. Please try again.\"`\nBot-filling is **lazy, not proactive** — `TryFillWithBotsIfNeeded` only runs\nwhen a player's state is actually read (`getCommunityChestState` or another\n`joinOrCreateCommunityChest` call) and checks\n`now >= group.CreatedAtUtc + MatchmakingTimeoutMinutes`; if so, it fills every\nremaining slot with bots and flips the group to `\"Active\"` in one shot (no\npartial/gradual backfill).\n\nContribution per landing on the `CommunityChest` tile (`TryContributeAsync`):\n\n```\ndelta = uniform_random(ContributionMin, ContributionMax) // inclusive; min if Max<=Min\nif ScaleContributionWithRollMultiplier and usedMultiplier > 1: delta *= usedMultiplier\nnewProgress = min(oldProgress + delta, MaxProgress) // clipped, never overshoots\ngroup.Status becomes \"Completed\" the instant newProgress >= MaxProgress\n```\n\n---\n\n## Community Chest: player + group state\n\n```ts\ninterface UserCommunityChestState {\n ActiveGroupID?: string | null;\n ActiveRoundIndex?: number | null;\n History?: CommunityChestHistoryEntry[] | null; // recent completed rounds\n}\n\ninterface CommunityChestHistoryEntry {\n GroupID?: string | null;\n RoundIndex?: number | null;\n FinalStatus?: CommunityChestStatus | null;\n GrandPrizeReceived?: boolean | null;\n FinishedAtUtc?: string | null;\n}\n\n// enum CommunityChestStatus\ntype CommunityChestStatus =\n \"Forming\" | \"Active\" | \"Completed\" | \"Failed\" | \"Expired\";\n\n// enum CommunityChestMemberStatus\ntype CommunityChestMemberStatus = \"Active\" | \"Left\" | \"Replaced\";\n\ninterface CommunityChestGroupDocument {\n GroupID?: string | null;\n TitleID?: string | null;\n RoundIndex?: number | null;\n Members?: CommunityChestMember[] | null;\n SharedProgress?: CommunityChestSharedState | null;\n Status?: CommunityChestStatus | null;\n CreatedAtUtc?: string | null;\n ExpiresAtUtc?: string | null;\n Version?: number | null;\n}\n\ninterface CommunityChestMember {\n UserID?: string | null;\n PublicData?: UserPublicDataModel | null;\n IsBot?: boolean | null; // groups may be filled out with bots if matchmaking times out\n ContributionPoints?: number | null;\n ClaimedMilestoneIDs?: string[] | null;\n GrandPrizeClaimed?: boolean | null;\n MemberStatus?: CommunityChestMemberStatus | null;\n JoinedAtUtc?: string | null;\n LeftAtUtc?: string | null;\n}\n\ninterface CommunityChestSharedState {\n CurrentProgress?: number | null;\n MaxProgress?: number | null;\n}\n```\n\n`UserCommunityChestState` (the small per-player pointer) is what's cached at\n`client.data.user.state?.GameLoop?.CommunityChest`. The richer\n`CommunityChestGroupDocument` (full member list + shared meter) is **not**\nseparately cached — it only comes back inline in\n`CommunityChestUserStateResponse.ActiveGroup` and\n`CommunityChestGroupStateResponse.Group`; hold onto the response if you need\nto render the roster/leaderboard, or re-call `getCommunityChestState()`.\n\n---\n\n## Responses\n\n```ts\ninterface RollActionData {\n TargetUserID?: string | null;\n IsBot?: boolean | null;\n PublicData?: UserPublicDataModel | null;\n TargetBuildingStates?: BuildingState[] | null;\n TargetHasShield?: boolean | null;\n}\n\ninterface CommunityChestContributionResult {\n GroupID?: string | null;\n Delta?: number | null; // points added by this roll\n NewProgress?: number | null;\n MaxProgress?: number | null;\n UnlockedMilestoneIDs?: string[] | null; // newly crossed this roll\n Completed?: boolean | null; // meter hit MaxProgress\n}\n\ninterface BoardRollResponse {\n UsedMultiplier?: number | null;\n Steps?: number | null; // dice total this roll\n OldPosition?: number | null;\n NewPosition: number;\n CyclesCompletedDelta?: number | null;\n LandedTileType?: string | null;\n Operation?: ResourceOperation | null; // e.g. OnTileLanding / OnPassStart reward\n ActionRequired?: string | null; // \"ATTACK\" | \"RAID\" — mirrors Pending.Type when set\n ActionData?: RollActionData | null;\n SpecialModeOffer?: SpecialModeOfferData | null;\n CommunityChestContribution?: CommunityChestContributionResult | null;\n}\n\ninterface AttackResponse {\n Outcome?: string | null; // \"Hit\" | \"Blocked\"\n IsBotTarget?: boolean | null;\n BuildingIndexHit?: number | null;\n Operation?: ResourceOperation | null; // bot fights: full resource delta\n DualResult?: ResourceDualPartyResult | null; // PvP fights: FromResult/ToResult split\n}\n\ninterface RaidResponse {\n Status?: string | null; // \"CONTINUE\" | (a terminal status, e.g. \"Complete\")\n Outcome?: string | null;\n FoundSymbol?: string | number | null;\n FoundBonus?: ResourceGrant | null; // already-scaled snapshot for the dug cell\n OpenedIndex?: number | null;\n AttemptsLeft?: number | null;\n RaidLayout?: HeistCell[] | null;\n HeistVariantTag?: string | null;\n IsJackpot?: boolean | null;\n Operation?: ResourceOperation | null;\n DualResult?: ResourceDualPartyResult | null;\n}\n\ninterface BuildResponse {\n BuiltIndex: number;\n NewLevel?: number | null;\n StageComplete?: boolean | null;\n MaxLevelRewardClaimed?: boolean | null;\n Operation?: ResourceOperation | null;\n}\n\ninterface SpecialChooseResponse {\n Mode?: string | number | null; // \"Instant\"/\"Timed\" or 0/1\n Operation?: ResourceOperation | null; // Instant reward, granted immediately\n DurationSeconds?: number | null;\n StartedAtUtc?: string | null;\n ExpiresAtUtc?: string | null;\n}\n\ninterface SpecialApplyMultiplierResponse {\n AdViewsUsed?: number | null;\n RolledMultiplier?: number | null; // this call's sampled multiplier step\n AccumulatedMultiplier?: number | null; // running total across all ad views\n RemainingAdViews?: number | null;\n}\n\ninterface SpecialClaimResponse {\n FinalMultiplier?: number | null;\n AdViewsUsed?: number | null;\n AccumulatedMultiplier?: number | null;\n IsEarlyClaim?: boolean | null;\n Operation?: ResourceOperation | null;\n}\n\ninterface CommunityChestUserStateResponse {\n ServerTimeUtc?: string | null;\n UserState?: UserCommunityChestState | null;\n ActiveGroup?: CommunityChestGroupDocument | null;\n SecondsRemaining?: number | null;\n}\n\ninterface CommunityChestGroupStateResponse {\n ServerTimeUtc?: string | null;\n Group?: CommunityChestGroupDocument | null;\n SecondsRemaining?: number | null;\n}\n\ninterface CommunityChestClaimResponse {\n ServerTimeUtc?: string | null;\n GroupID?: string | null;\n RewardType?: string | null; // \"Milestone\" | \"GrandPrize\"\n MilestoneID?: string | null;\n Resources?: ResourceOperation | null;\n}\n\ninterface CommunityChestLeaveResponse {\n ServerTimeUtc?: string | null;\n GroupID?: string | null;\n Success?: boolean | null;\n}\n```\n\n`ResourceGrant`, `ResourceConsume`, `ResourceOperation`, `ResourceBundle`, and\n`ResourceDualPartyResult` are the shared resource types used across the whole\nSDK (`../_shared/ResourceModels`) — same shapes as in other modules' rewards\nand costs.\n\n---\n\n## Request shape + action ids\n\n```ts\ninterface GameLoopRequest extends BaseRequest {\n RollMultiplier?: number;\n BuildingIndex?: number;\n DigIndex?: number;\n StageLevel?: number;\n DigIndices?: number[];\n ChoiceID?: string;\n GroupID?: string;\n MilestoneID?: string;\n}\n```\n\nEvery mutating board call (`boardLoopRoll`, `boardLoopAttack`,\n`boardLoopRaid`, `boardLoopRaidFast`, `boardLoopBuild`, `boardSpecialChoose`,\n`boardSpecialApplyMultiplier`, `boardSpecialClaim`) attaches a client-generated\n`RelatedEntityID` idempotency-style key (`\"<verb>_<userID>_<uuid>\"`) — this is\ninternal plumbing, not something you construct yourself, but it explains why\ntwo rapid duplicate calls are two distinct server operations rather than being\ndeduped (see Gotchas in SKILL.md).\n\n`GameLoopAction` is the string-enum of every action id\n(`GetGameLoops`, `GetBoardDefinition`, `GetBoardDefinitionForLevel`,\n`GetUserBoardState`, `BoardLoopRoll`, `BoardLoopAttack`, `BoardLoopRaid`,\n`BoardLoopRaidFast`, `BoardLoopBuild`, `BoardSpecialChoose`,\n`BoardSpecialApplyMultiplier`, `BoardSpecialClaim`, `GetCommunityChestState`,\n`JoinOrCreateCommunityChest`, `ClaimCommunityChestMilestone`,\n`ClaimCommunityChestGrandPrize`, `LeaveCommunityChest`) — used internally for\nrouting; you won't need to reference it directly when calling\n`client.gameLoop.*` methods.\n\n---\n\n## Cache mutation rules\n\nWhat each method writes into `client.data.user.state?.GameLoop`, precisely\n(all board writes emit `user:gameLoopUpdated` + `user:anyUpdated`; Community\nChest writes emit the same two events):\n\n| Method | Cache effect |\n| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `getUserBoardState` | Full replace: `Board = data`. |\n| `boardLoopRoll` | Patches `Position`, `CyclesCompleted` (+= delta), `LastRollAtUtc`; sets `Pending` from `ActionRequired`/`ActionData` or `SpecialModeOffer` (or leaves it as-is if neither is set — it is **not** explicitly cleared on a plain-tile roll); applies `Operation` if present. |\n| `boardLoopAttack` | Clears `Pending` unconditionally. Applies `Operation` (bot target) or `DualResult.FromResult` (PvP). On failure, re-fetches `getUserBoardState()` instead of patching. |\n| `boardLoopRaid` | If `Status === \"CONTINUE\"`: patches `Pending.RaidLayout` + appends to `Pending.OpenedIndices`. Otherwise: clears `Pending`, applies `Operation`/`DualResult.FromResult`. On failure, re-fetches state. |\n| `boardLoopRaidFast` | Only acts if `Status !== \"CONTINUE\"`: clears `Pending`, applies reward. A `CONTINUE` result is a no-op on the cache. On failure, re-fetches state. |\n| `boardLoopBuild` | If `StageComplete`: `StageLevel += 1`, `Position = 0`, `Pending = null`, `BuildingStates = null`. Else: patches (creates if missing) the `BuildingStates` entry for `BuiltIndex` — sets `Level`, clears `IsDamaged`, sets `MaxLevelRewardClaimed` if granted. Applies `Operation` either way. |\n| `boardSpecialChoose` | If `Mode` is Instant: clears `Pending`. Else (Timed): sets/creates `Pending.Special` (`ChoiceID`, `ChosenMode`, `StartedAtUtc`, `DurationSeconds`, resets `AdViewsUsed = 0`). Applies `Operation` if present (e.g. an `EntryCost` charge). |\n| `boardSpecialApplyMultiplier` | Patches `Pending.Special.AdViewsUsed` and `AccumulatedMultiplier`. |\n| `boardSpecialClaim` | Clears `Pending`. Applies `Operation`. |\n| `getCommunityChestState` | Full replace: `CommunityChest = data.UserState ?? {}`. |\n| `joinOrCreateCommunityChest` | If a group came back: sets `CommunityChest.ActiveGroupID`/`ActiveRoundIndex` (partial patch, not a full replace). |\n| `claimCommunityChestMilestone` | No `CommunityChest` state patch — only applies `Resources` to currency/item balances. (Milestone-claimed tracking lives server-side on the group document, not mirrored locally.) |\n| `claimCommunityChestGrandPrize` | Same as milestone claim: applies `Resources` only. |\n| `leaveCommunityChest` | If `Success`: clears `ActiveGroupID` (`undefined`) and sets `ActiveRoundIndex = -1`. |\n\nNote the asymmetry: `boardLoopRoll` never explicitly nulls out `Pending` for a\nplain (non-action, non-special) tile landing — in practice this is safe\nbecause a plain landing only happens when there was no prior unresolved\n`Pending` (the server won't let you roll again while one is open), so there is\nnothing stale to clear. If you're debugging a \"stale Pending\" UI bug, this is\nthe first place to look.\n"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "idosgames-compose-modules",
3
+ "description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the cross-module event bus, or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
4
+ "content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n cross-module event bus, or host-level shared state work. Builds on idosgames-getting-started\n (scaffolding) and idosgames-module-contract (a single module).\n---\n\n# Composing modules into one game\n\nThe whole point of the architecture: a developer plugs in several modules and merges them. The host\nhandles coexistence — you just register the modules and (optionally) wire shared state.\n\n## Register several modules\n\n```ts\n// src/modules.ts\nexport const modules: Module[] = [\n currencyHudModule, // shell chrome: a host-level wallet HUD (no scene, no route)\n boardGameModule, // Three tycoon\n idleRpgModule, // Phaser idle\n];\n```\n\nEach game module registers a route → the host renders a **nav-bar** (`🎲 Board | ⚔️ Idle RPG`) and\n**mode-switches**: only the active mode's scene is mounted and ticking; the rest are suspended\n(their RAF stops). This is why two different engines (Three + Phaser) can live in one project — they\nnever render at the same time. Modules of the same engine family may later share a renderer\n(composition), but the module code doesn't change either way.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode — same `client.currency`, same\n cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` — e.g. an idle module emits\n `events.emit(\"gold:granted\", …)` and another module subscribes with `events.on(...)`.\n\n## Host-level shared chrome\n\nTo show something in EVERY mode (a wallet balance, a global menu), register a panel with\n`activeOnly: false`. A \"shell\" module (`type: \"app\"`, `engine: \"dom\"`, no scene, no route) is the\nclean way to do it:\n\n```ts\nsetup(ctx) {\n ctx.registerPanel({ id: \"wallet\", slot: \"hud\", activeOnly: false, component: WalletHud });\n}\n```\n\n`WalletHud` reads `useUserState()` from `@idosgames/react`, so it shows the same balance in Board, in\nIdle RPG, and even over a module that doesn't use the SDK at all (voxelcraft) — proof that all modes\nshare one client and cache.\n\n## Reference merge\n\n`host-starter` + `currency-hud` + `board-game` + `idle-rpg`:\n\n- nav-bar switches modes; one engine scene mounted at a time;\n- the wallet HUD is pinned across all modes;\n- currency changed in one mode is immediately visible in the others.\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
5
+ "references": []
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "idosgames-getting-started",
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, currency-hud, …). 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, currency-hud, …). 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, logs in, 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 and logs in:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\nawait client.auth.loginWithDeviceID();\nmountHost({ container: app, client, modules }); // modules from ./modules\n```\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. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n3. **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`).\n4. **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.\n5. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them; a shared HUD module (currency-hud, `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",
5
+ "references": []
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "idosgames-module-contract",
3
+ "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, currency-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.",
4
+ "content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, currency-hud), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (cross-module bus) · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module, e.g. currency-hud). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a wallet HUD).\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
5
+ "references": []
6
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "item-system",
3
+ "description": "Work with items on the iDosGames TypeScript SDK (@idosgames/core) via client.item (ItemService) and the shared Item data model: upgrade an item instance's level (single or batch, optionally consuming fodder instances), and understand ItemDefinition / item catalogs / stackable vs unstackable item instances / equipment rules — the vocabulary Character (equipment), Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store (purchase grants) all build on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item upgrade/leveling UIs, inventory screens, item definitions/catalogs, stackable/unstackable item instances, item rarity/tags, NFT-bound items, or otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog, UnstackableItemInstanceState, or InventoryV2 — even if they don't name the module explicitly.",
4
+ "content": "---\nname: item-system\ndescription: >-\n Work with items on the iDosGames TypeScript SDK (@idosgames/core) via\n client.item (ItemService) and the shared Item data model: upgrade an item\n instance's level (single or batch, optionally consuming fodder instances),\n and understand ItemDefinition / item catalogs / stackable vs unstackable\n item instances / equipment rules — the vocabulary Character (equipment),\n Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store\n (purchase grants) all build on. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n item upgrade/leveling UIs, inventory screens, item definitions/catalogs,\n stackable/unstackable item instances, item rarity/tags, NFT-bound items, or\n otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog,\n UnstackableItemInstanceState, or InventoryV2 — even if they don't name the\n module explicitly.\n---\n\n# Item system (iDosGames TS SDK)\n\nThe Item module has two very different halves. `ItemService` itself is small\n— it only upgrades an item instance's level (single or batch, optionally\nburning fodder instances). But `ItemDefinition` — the config shape for what an\nitem _is_ — is the shared vocabulary every other module builds on: Character\nequips item instances into slots, Marketplace lists/auctions/trades them,\nCraft burns them as recipe inputs and mints them as outputs, Lootbox grants\nthem as rewards, and Store sells them in offer bundles. This skill covers\nboth: the upgrade-level methods you call directly, and the item data model\nyou'll read constantly from every other module's config and responses.\n\nEverything is **server-authoritative**: the client asks the backend to\nupgrade, the backend validates cost/cap/fodder and applies the change, and the\nSDK mirrors the confirmed result into a local cache your UI reads. This skill\nis for **using** the production `ItemService` and reading the item model, not\nfor porting or extending it. If a call is rejected, that's the backend\nenforcing a rule (cost, level cap, fodder mismatch, stale catalog) — surface\nthe error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n item templates: `ItemDefinition` keyed by `ItemID`, grouped into\n `ItemCatalog`s keyed by `CatalogID`, all under the root `ItemDefinitions`.\n Fetched at the title level (see the title-system skill for\n `getItemDefinitions()`), not through `client.item`.\n2. **Item instances** (state, per player) — what a player actually owns, held\n in `client.data.user.state?.InventoryV2`. Two different shapes depending\n on whether the item stacks — see below. `client.item` reads and writes\n only unstackable instances (the ones with a per-instance `Level` to\n upgrade).\n\nA definition's full address is the pair `(CatalogID, ItemID)` — `ItemID` is\nonly unique **within** a catalog, so the same `ItemID` can appear in more than\none catalog. Whether an instance is stackable or not is fixed by\n`ItemDefinition.IsStackable`. See\n[references/data-model.md](references/data-model.md) for the full field\nreference, the catalog-resolution rule (strict-then-fallback with self-heal),\nand the exact upgrade-cost/fodder formulas.\n\n## Stackable vs unstackable item instances\n\n`InventoryV2` (the `UserInventoryState` cache, at\n`client.data.user.state?.InventoryV2`) carries both kinds side by side:\n\n```ts\ninterface UserInventoryState {\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}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n```\n\n- **Stackable items** (`ItemDefinition.IsStackable === true`, e.g. crafting\n materials, consumables) have no individual identity — the player just has a\n quantity. They live in `Items[itemID]` as a plain count (`ItemTotals`); there\n is no instance to level up, equip, or track expiry on.\n- **Unstackable items** (`IsStackable` false/absent, e.g. weapons, armor,\n collectibles) are each a distinct instance with its own id, level, and\n lifecycle. They live in `UnstackableItems[itemInstanceID]`:\n\n ```ts\n interface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size — see references/data-model.md\n RemainingUses?: number;\n Level?: number; // what upgradeLevel() raises; default 1\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for \"is this equipped\"\n CustomData?: string | null;\n }\n ```\n\n`EquippedSlot` is the **authoritative** record of whether/where an instance is\nequipped — the Character module's per-character `Equipment` map is just a\ncache view of the same fact. See the character-system skill (equip/unequip\nmethods, slot rules) for how items get placed into `EquippedSlot`; this skill\nowns the instance side (`Level`, `ExpiresAt`, `Quantity`).\n\n`ItemService.upgradeLevel` operates on `UnstackableItems` entries only — you\npass an `ItemInstanceID`, and its `Level` is what changes. A `Quantity > 1`\ninstance is a merged pack of identical untouched copies; upgrading one copy\nout of a pack causes the server to split off a fresh instance id for it — see\nreferences/data-model.md for exactly when that happens and why the response's\n`ItemInstanceID` can differ from what you called with.\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 items = client.item; // the ItemService\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\n\nBoth methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args, e.g. empty `ItemInstanceID`), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window,\ndefault 600ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Already at maximum level\n(3/3).\"`, `\"Item instance 'inst-1' not found.\"`, `\"Item instance 'inst-1' has\nexpired.\"`, `\"Item 'sword' is not upgradable.\"`, or a fodder-coverage\nshortfall).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `upgradeLevel(itemInstanceID, fodderInstanceIDs?)` | Raise one item instance's level by one, optionally burning fodder instances. | `UpgradeItemLevelResponse` (`Level`, `FodderConsumed`) |\n| `upgradeLevelsBatch(upgrades: ItemUpgradeRef[])` | Upgrade several item instances in one atomic call (each with its own multi-level/fodder options). | `UpgradeLevelsBatchResponse` = `BatchItemResult<UpgradeItemLevelResponse>[]` |\n\n`ItemUpgradeRef` (used only inside `upgradeLevelsBatch`):\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 the cap\n FodderInstanceIDs?: string[]; // instances burned to pay for this instance's upgrade\n}\n```\n\nOn success, both methods **re-fetch the server-authoritative inventory**\n(`client.userService.getUserInventory()` internally) and mirror it wholesale\ninto `client.data.user.state?.InventoryV2` — they don't hand-patch the one\ninstance you upgraded. Read the new `Level` off the refreshed cache, or off\n`result.data.Level` directly. Both also emit an event; the coarse\n`user:inventoryUpdated` (+ `user:anyUpdated`) fires as part of that inventory\nrefresh too.\n\n## Reading state and reacting to changes\n\n```ts\n// Current unstackable instances (only present after login/getUserInventory/upgradeLevel):\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\ninst?.Level; // current level\ninst?.EquippedSlot; // where it's equipped, if anywhere\n\n// Stackable item counts:\nconst totals = client.data.user.state?.InventoryV2?.Items?.[\"potion\"];\ntotals?.TotalAmount;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `item:levelUpgraded` → `UpgradeItemLevelResponse`\n- `item:levelsUpgradedBatch` → `UpgradeLevelsBatchResponse`\n\nThe coarse `user:inventoryUpdated` (and `user:anyUpdated`) also fire on the\ninventory refresh that follows every successful upgrade — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"item:levelUpgraded\", (r) => {\n console.log(`${r.ItemInstanceID} is now level ${r.Level}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Upgrade one item instance\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\");\nif (!res.ok) return showError(res.error); // e.g. \"Already at maximum level (3/3).\"\nres.data.Level; // new level\nres.data.ItemInstanceID; // may differ from \"inst-123\" if it split off a pristine pack — see below\nres.data.FodderConsumed; // [] unless a weighted (Merge/InvestmentRefund) fodder mode applied\n// client.data.user.state?.InventoryV2 has already been re-fetched.\n```\n\n### Upgrade with fodder (burn other instances to pay the cost)\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\", [\n \"inst-456\",\n \"inst-789\",\n]);\nif (!res.ok) return showError(res.error);\nfor (const f of res.data.FodderConsumed ?? []) {\n console.log(\n `burned ${f.ItemInstanceID} (was level ${f.Level}, ${f.Units} units)`,\n );\n}\n```\n\n`fodderInstanceIDs` is only meaningful when the item's config defines a fodder\nvaluation mode (`ItemDefinition.Upgrade.Fodder`) that requires client\nselection (`Selection: \"ClientSelected\"`) — otherwise the server auto-picks\nfodder itself (`ProtectLeveled`/`CheapestFirst`), or there's no self-item cost\nat all and any fodder you pass is simply rejected as a mismatch. Passing\nfodder that's a different item, already equipped, expired, or already claimed\nelsewhere in the same batch is rejected by instance id. See\nreferences/data-model.md for the exact valuation math (`W(level)` per mode)\nand selection rules.\n\n### Multi-level upgrade to an absolute target\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"inst-123\", TargetLevel: 10 },\n]);\nif (!res.ok) return showError(res.error); // outer call-level failure\nconst [item] = res.data;\nif (!item.Success) return showItemError(item.Id, item.Error);\n```\n\n`upgradeLevelsBatch` is the only way to pass `Levels`/`TargetLevel` from the\nSDK — the single `upgradeLevel` call only ever raises by one level per call\n(even though the underlying backend request shape supports a multi-level jump\non the single action too, the SDK doesn't expose it that way). To jump several\nlevels on a single instance in one shot, call the batch method with one entry.\nThe charge is the **sum** of each level's cost in the range, not a single\nlump price for the destination level — see references/data-model.md for the\nformula.\n\n### Batch-upgrade several instances at once\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"sword-1\", Levels: 2 },\n { ItemInstanceID: \"shield-1\" }, // Levels defaults to 1\n { ItemInstanceID: \"bow-1\", FodderInstanceIDs: [\"bow-2\", \"bow-3\"] },\n]);\nif (!res.ok) return showError(res.error);\nfor (const entry of res.data) {\n if (entry.Success) applyOk(entry.Id);\n else showItemError(entry.Id, entry.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that instance's\nupgrade applied. The resource charge across the batch is atomic/merged — if\nthe combined cost can't be paid, every included item comes back\n`Success: false`. Refs are deduped by `ItemInstanceID` server-side, and the\nserver processes at most **50 entries per call** — entries past 50 are\nsilently dropped and don't appear in the results at all, so chunk larger sets\ninto multiple calls yourself. An `ItemInstanceID` containing `.` or `$` is\nrejected per-entry rather than failing the whole batch.\n\n### Read the catalog for display (rarity, tags, upgrade cap)\n\n```ts\nimport type { ItemDefinitions } from \"@idosgames/core\";\n\nfunction findItemDef(defs: ItemDefinitions | undefined, itemID: string) {\n for (const catalog of Object.values(defs?.Catalogs ?? {})) {\n const def = catalog.Items?.[itemID];\n if (def) return def; // first match; see references/data-model.md if itemID isn't unique title-wide\n }\n return undefined;\n}\n\nconst defs = client.data.config.itemDefinitions; // ItemDefinitions | undefined\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\nconst def = inst && findItemDef(defs, inst.ItemID);\ndef?.Metadata?.RarityID; // \"Epic\", etc — drives UI framing\ndef?.Upgrade?.MaxLevel; // upgrade cap for the progress bar\n```\n\n`client.data.config.itemDefinitions` is a dedicated cache getter (not the\ngeneric `getSection` map other modules use) — it returns whichever came in\nlast: a standalone `client.title.getItemDefinitions()` call, or the `Item`\nblock embedded in the full title config from `getTitlePublicConfiguration()`\n(see the title-system skill for both). When you already know an instance's\n`CatalogID`, look it up directly (`defs.Catalogs?.[catalogID]?.Items?.[itemID]`)\ninstead of scanning — it's the same strict-first rule the backend applies, and\navoids the rare same-`ItemID`-in-two-catalogs ambiguity described in\nreferences/data-model.md.\n\n## Gotchas\n\n- **`upgradeLevel` only ever steps +1.** There's no `levels`/`targetLevel`\n option on the single call — use `upgradeLevelsBatch` with one ref for\n multi-level jumps.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`upgrade_item_{instanceID}_{uuid}`), so two separate calls are two real\n operations — a double-clicked \"Upgrade\" can charge twice. Disable the\n control while a call is in flight. (Firing the same endpoint again within\n the throttle window, default 600ms, is rejected with `reason: \"throttled\"`\n rather than duplicated, but don't rely on that for correctness.)\n- **Render from the refreshed inventory, not a locally patched copy.** The\n service re-fetches `GetUserInventory` on success rather than mutating just\n the one instance — treat `client.data.user.state?.InventoryV2` as the\n source of truth after any upgrade call. In particular, the upgraded\n instance's id in the response can differ from the id you called with (see\n the `Quantity`/pristine-pack split note in references/data-model.md).\n- **Fodder valuation is mode-specific, not \"any spare copy is worth 1.\"**\n `Merge` and `InvestmentRefund` value a fodder copy by its own level\n (`W(level)`, growing super-linearly for `Merge`) — a single high-level\n fodder instance can outweigh several low-level ones, or vice versa, and\n `FodderConsumed` only reports burns for these two weighted modes. Under the\n legacy/`FlatCount` mode, fodder isn't weighted at all: the self-item portion\n of the cost is paid through the ordinary resource-consume pipeline (one\n unit of it is implicitly the instance being upgraded), and `FodderConsumed`\n stays empty even if you pass `fodderInstanceIDs`. Don't build a \"fodder\n value\" UI assuming a flat count applies universally — read\n `ItemDefinition.Upgrade.Fodder.ValuationMode` first.\n- **Catalog IDs can self-heal underneath you.** If an item was moved to a\n different catalog after an instance was granted, the resolver falls back to\n a title-wide scan by `ItemID` and — if unambiguous — silently patches the\n instance's stored `CatalogID` to the resolved one as part of the upgrade.\n Read `CatalogID` off the response/refreshed cache, don't cache it\n separately. If the same `ItemID` now exists in two or more catalogs, the\n fallback refuses to guess and the upgrade fails with a \"not found\" error\n even though the instance still nominally exists — that's a config/data\n issue for the title owner, not something to route around client-side.\n- **Stackable items never appear in `UnstackableItems`.** If\n `ItemDefinition.IsStackable` is true, there is no per-instance `Level` to\n upgrade — `upgradeLevel`/`upgradeLevelsBatch` don't apply to it at all\n (attempting it is rejected as \"stackable; levels are only supported on\n unstackable items\").\n- **Equipment truth lives on the instance, not the character.** `EquippedSlot`\n on `UnstackableItemInstanceState` is authoritative; the Character module's\n per-character `Equipment` map is a synced cache view. Upgrading an equipped\n instance also recomputes and patches its owner's `Power` server-side in the\n same atomic write — see the character-system skill for equip/unequip calls\n and the two-sided character/item slot-rule matrix.\n- **Cost objects are the shared `ResourceConsume`/`ResourceGrant` types.**\n `ItemDefinition.Upgrade.BaseCostResource` and the response's `Resources`\n field use the same shared resource model as every other module — see the\n currency-system skill for the full breakdown; briefly, `Resources` on the\n response is a `ResourceOperation` (`{ Grant?, Consume? }`) that's already\n been applied to cached balances by the time you read it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every `ItemDefinition`\nfield (stats, equip rules, upgrade/fodder config, NFT binding, metadata), the\ncatalog/root shape and resolution rule, the upgrade cost formula, and the\nfodder valuation/selection formulas transcribed from the backend. Read it when\nbuilding config-driven UI (upgrade cost previews, fodder pickers, rarity\nbadges) or when an error message points at a config rule you need to\nunderstand.\n",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
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 BaseCostResource?: ResourceConsume; // cost of the step from level 1 to level 2\n CostScalingFactor?: number; // linear cost growth per level; 0 = flat cost at every level\n FlatScalingFactor?: number; // ItemStats.FlatBonuses growth per level\n PercentScalingFactor?: number; // ItemStats.PercentBonuses growth per level\n PowerScalingFactor?: number; // ItemStats.Power growth per 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) = round(BaseCostResource.Amount * (1 + CostScalingFactor * (N - 1)))\n```\n\n— identical semantics to the Character module's `StatDefinition.BaseCostResource`\nscaling. `N - 1` means the level-1→2 step costs exactly `BaseCostResource`,\nunscaled. A multi-level upgrade (`Levels` / `TargetLevel`) charges the **sum**\nof this formula for every level from `current + 1` through the resolved target\n— it is not a single jump priced off the destination level alone. If every\nscaled amount in `BaseCostResource` rounds to `0`, or `BaseCostResource` is\nempty, the upgrade is rejected as misconfigured rather than treated as free.\n\n`BaseCostResource`'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`** (all use the same `1 + factor *\n(L - 1)` shape, base level is 1):\n\n- Flat bonus effective multiplier: `1 + FlatScalingFactor * (L - 1)` applied to\n each `ItemStats.FlatBonuses` value.\n- Percent bonus effective multiplier: `1 + PercentScalingFactor * (L - 1)`\n applied to each `ItemStats.PercentBonuses` value, before aggregation into\n the character's total gear-percent.\n- Effective Power: `round(ItemStats.Power * (1 + PowerScalingFactor * (L - 1)))`,\n added into `CharacterModel.Power` alongside stat-based Power (the two are\n simple sums — designers balance any double-counting themselves via weights).\n\nA `Factor` of `0` on any of these means that quantity does not grow with\nlevel — only the base value applies at every level. 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 MergeRatio?: number; // used when ValuationMode === \"Merge\"; typical 2..5\n Selection?: \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\";\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) = round(MergeRatio ^ (L - 1))`, rounded away from zero | `MergeRatio <= 1` degenerates to `W(L) = 1` (flat). Reads as \"`MergeRatio` copies of level `L` ≈ one copy of level `L+1`\". |\n| `InvestmentRefund` | `W(L) = 1 + Σ_{k=2}^{L} round(BaseSelfAmount * (1 + CostScalingFactor * (k - 1)))`, each term rounded away from zero | `BaseSelfAmount` is the self-item `Amount` found inside `Upgrade.BaseCostResource` (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- **`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
+ }
10
+ ]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "leaderboard-system",
3
+ "description": "Build a leaderboard / ranking system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.leaderboard (LeaderboardService): load leaderboard definitions, load a leaderboard's top list (+ batch), load the player's own progress (+ batch), submit a score (direct or from a trigger source, + batch), claim the cycle/instance-end rank reward (+ batch), claim a milestone reward (+ batch), claim every reward across many leaderboards in one call, fetch a combined top-list+progress overview (batch), and load a friends-filtered leaderboard view. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants ranking screens, PvP/score leaderboards, seasonal or cyclic ladders, rank-reward or milestone-reward UIs, or otherwise touches client.leaderboard, LeaderboardService, LeaderboardDefinitions, UserLeaderboardProgress, GetMyProgressResponse, or leaderboard cycles/brackets — even if they don't name the module explicitly.",
4
+ "content": "---\nname: leaderboard-system\ndescription: >-\n Build a leaderboard / ranking system in a game on the iDosGames TypeScript\n SDK (@idosgames/core) via client.leaderboard (LeaderboardService): load\n leaderboard definitions, load a leaderboard's top list (+ batch), load the\n player's own progress (+ batch), submit a score (direct or from a trigger\n source, + batch), claim the cycle/instance-end rank reward (+ batch), claim\n a milestone reward (+ batch), claim every reward across many leaderboards in\n one call, fetch a combined top-list+progress overview (batch), and load a\n friends-filtered leaderboard view. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n ranking screens, PvP/score leaderboards, seasonal or cyclic ladders,\n rank-reward or milestone-reward UIs, or otherwise touches client.leaderboard,\n LeaderboardService, LeaderboardDefinitions, UserLeaderboardProgress,\n GetMyProgressResponse, or leaderboard cycles/brackets — even if they don't\n name the module explicitly.\n---\n\n# Leaderboard system (iDosGames TS SDK)\n\nThe Leaderboard module ranks players against each other on a numeric score,\ninside repeating **cycles** (Cyclic schedule) or one-off/chained **instances**\n(Scheduled/Chained schedule). Everything is **server-authoritative**: the\nclient submits a score or asks to claim a reward, the backend validates and\naggregates it, and the SDK mirrors the confirmed result into a local cache\nyour UI reads. You never rank players or compute cycle math yourself.\n\nThis skill is for **using** the production `LeaderboardService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (leaderboard disabled, gate not met, nothing to claim) — surface the\nerror, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n leaderboards: schedule mode, score aggregation rule, rank rewards,\n milestones, gate, bracket size. Fetched with `getDefinitions()`.\n2. **Player progress** (state, per player, per cycle/instance) — this\n player's current score, rank, earned-this-cycle amount, claimed\n milestones, and whether a rank reward is waiting. Fetched with\n `getMyProgress()` / `getProgressBatch()` / `getOverviewBatch()`.\n\nA leaderboard is identified by a string `LeaderboardID`. Progress is cached\nper **cycle/instance doc**, keyed internally as `` `${titleID}_${leaderboardID}` ``\nfor `Cyclic` leaderboards (with a `:{instanceKey}` suffix on the backend for\nScheduled/Chained instances) — you never build that key yourself, just read\n`client.data.user.state?.Leaderboard?.ProgressByCycle` (see\n[Reading state](#reading-state-and-reacting-to-changes)).\n\nTwo reward tracks per leaderboard, don't conflate them:\n\n- **Rank (cycle) reward** — one payout per cycle/instance based on where you\n land in `RankRewards` when it ends. Claimed with `claimCycleReward`.\n- **Milestones** — score-threshold rewards inside the current cycle/instance\n (e.g. \"earn 1000 points this week\"), the same `MilestoneDefinition`\n primitive used by Quest/TimedEvent/CoopEvent. Claimed with `claimMilestone`.\n\nFor the full field-by-field shape of Definitions and state (score aggregation\nmodes, schedule modes, rank-reward ranges, milestone claim modes, grace\ncycles/instances), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\n\nCost/reward objects use the SDK-wide shared `ResourceGrant` /\n`ResourceOperation` types — see `.claude/skills/currency-system/SKILL.md`.\n`Gate.MinPremiumTier` and other premium-tier gating are documented in\n`.claude/skills/premium-system/SKILL.md`.\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 leaderboard = client.leaderboard; // the LeaderboardService\n```\n\nEvery leaderboard method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Leaderboard '{id}' is\ndisabled\"`, `\"No unclaimed reward\"`, `\"Reward already claimed\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |\n| `getDefinitions()` | Load the title's leaderboard catalog (config). | `LeaderboardDefinitions` |\n| `getLeaderboard(leaderboardID)` | Load one leaderboard's current top list. | `GetLeaderboardResponse` (`TopUsers`, `CycleVersion`) |\n| `getMyProgress(leaderboardID)` | Load this player's progress in the current cycle/instance. | `GetMyProgressResponse` |\n| `submitScore(leaderboardID, score)` | Submit a raw score directly. | `SubmitScoreResponse` (`NewScore`, `CycleVersion`) |\n| `submitScoreFromSource(leaderboardID, sourceType, outcome?, params?, rollMultiplier?)` | Submit a score derived from a configured `ScoreSources` trigger. | `SubmitScoreResponse` |\n| `claimCycleReward(leaderboardID)` | Claim the ended cycle/instance's rank reward. | `ClaimCycleRewardResponse` (`Rank`, `Resources`) |\n| `claimMilestone(leaderboardID, milestoneID)` | Claim one reached milestone. | `ClaimLeaderboardMilestoneResponse` |\n| `getFriendsLeaderboard(leaderboardID)` | Load the top list filtered to the player's friends. | `GetLeaderboardResponse` |\n| `getLeaderboardsBatch(leaderboardIDs)` | Load top lists for several leaderboards in one call. | `BatchItemResult<GetLeaderboardResponse>[]` |\n| `getProgressBatch(leaderboardIDs)` | Load this player's progress on several leaderboards in one call. | `BatchItemResult<GetMyProgressResponse>[]` |\n| `claimCycleRewardsBatch(leaderboardIDs)` | Claim the rank reward on several leaderboards in one call. | `BatchItemResult<ClaimCycleRewardResponse>[]` |\n| `claimMilestonesBatch(milestones)` | Claim several explicit (leaderboard, milestone) pairs in one call. | `BatchItemResult<ClaimLeaderboardMilestoneResponse>[]` |\n| `submitScoresBatch(scores)` | Submit scores to several leaderboards in one call. | `BatchItemResult<SubmitScoreResponse>[]` |\n| `claimAllRewardsBatch(leaderboardIDs)` | Claim everything reachable (rank reward + all milestones) across several leaderboards. | `ClaimAllRewardsResult[]` |\n| `getOverviewBatch(leaderboardIDs)` | Load top list + progress together for several leaderboards. | `LeaderboardOverview[]` |\n\n`submitScoreFromSource` mirrors the confirmed score the same way `submitScore`\ndoes; use it when the score comes from a game event configured on the\nleaderboard's `ScoreSources` (a `TriggerSource`, same primitive used by\nTimedEvent/CoopEvent) rather than a value your client computed — pass the\n`sourceType`/`outcome`/`params` that identify which trigger fired, and\n`rollMultiplier` to scale it (defaults to 1). The backend only accepts\nclient-safe source types here — board/quest/store/marketplace/rank-reward\nsources are server-only and are rejected with `\"Source '{type}' cannot be\ncalled from client.\"`. It's also a **no-op that still returns `ok: true`**\n(`NewScore: 0, CycleVersion: 0`) when the leaderboard isn't `Sum`-aggregated,\nor no configured source matches — don't treat that as an error, and don't\nshow a \"+0\" toast without checking `NewScore > 0`.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Rewards ride along in\n`data.Resources` (single calls) and are already applied to cached balances;\nfor `claimCycleRewardsBatch`, `claimMilestonesBatch`, and\n`claimAllRewardsBatch`, each successful item's `Resources` is applied\nper-item as the batch result is processed.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LeaderboardDefinitions } from \"@idosgames/core\";\nconst defs =\n client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\");\n\n// Player progress, keyed by an internal `${titleID}_${leaderboardID}` cycle-doc id —\n// don't build the key yourself, look it up by matching LeaderboardID if you need to\n// scan, or just re-call getMyProgress/getProgressBatch and read the response directly.\nconst byCycle = client.data.user.state?.Leaderboard?.ProgressByCycle ?? {};\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `leaderboard:definitionsLoaded` → `LeaderboardDefinitions`\n- `leaderboard:loaded` → `GetLeaderboardResponse`\n- `leaderboard:loadedBatch` → `BatchItemResult<GetLeaderboardResponse>[]`\n- `leaderboard:myProgressLoaded` → `GetMyProgressResponse`\n- `leaderboard:myProgressLoadedBatch` → `BatchItemResult<GetMyProgressResponse>[]`\n- `leaderboard:scoreSubmitted` → `SubmitScoreResponse`\n- `leaderboard:scoresSubmittedBatch` → `BatchItemResult<SubmitScoreResponse>[]`\n- `leaderboard:cycleRewardClaimed` → `ClaimCycleRewardResponse`\n- `leaderboard:cycleRewardsClaimedBatch` → `BatchItemResult<ClaimCycleRewardResponse>[]`\n- `leaderboard:milestoneClaimed` → `ClaimLeaderboardMilestoneResponse`\n- `leaderboard:milestonesClaimedBatch` → `BatchItemResult<ClaimLeaderboardMilestoneResponse>[]`\n- `leaderboard:allRewardsClaimedBatch` → `ClaimAllRewardsBatchResponse`\n- `leaderboard:overviewLoadedBatch` → `GetOverviewBatchResponse`\n- `leaderboard:friendsLoaded` → `GetLeaderboardResponse`\n\nThe coarse `user:leaderboardUpdated` (and `user:anyUpdated`) also fire on any\nleaderboard cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"leaderboard:cycleRewardClaimed\", (r) => {\n console.log(`${r.LeaderboardID}: rank ${r.Rank} reward claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Golden path: submit a score, check progress, claim the rank reward\n\n```ts\nawait client.leaderboard.getDefinitions();\n\nconst sub = await client.leaderboard.submitScore(\"weekly_score\", 150);\nif (!sub.ok) return showError(sub.error);\nsub.data.NewScore; // score after aggregation (Sum/BestScore/BestTime/LastValue)\nsub.data.CycleVersion; // which cycle this landed in\n\nconst progress = await client.leaderboard.getMyProgress(\"weekly_score\");\nif (!progress.ok) return showError(progress.error);\nif (progress.data.HasUnclaimedReward) {\n const claim = await client.leaderboard.claimCycleReward(\"weekly_score\");\n if (!claim.ok) return showError(claim.error); // e.g. \"No unclaimed reward\"\n claim.data.Rank; // rank that earned the reward\n claim.data.Resources; // already applied to cached balances\n}\n```\n\n`submitScore`'s effect on `NewScore` depends on the leaderboard's\n`ScoreAggregation`: `Sum` adds to the running total, `BestScore`/`BestTime`\nonly replace if better, `LastValue` always overwrites. The SDK doesn't\nreplicate this math client-side — trust `NewScore` from the response. A\n`submitScore` call with `score <= 0` is rejected client-side with\n`reason: \"client\"` before any request is sent.\n\n### Show a top list with the player's own progress (overview batch)\n\n```ts\nconst res = await client.leaderboard.getOverviewBatch([\n \"weekly_score\",\n \"season_pvp\",\n]);\nif (!res.ok) return showError(res.error);\n\nfor (const entry of res.data) {\n if (entry.Leaderboard?.Success) {\n entry.Leaderboard.Data?.TopUsers; // ranked entries for this board\n }\n if (entry.Progress?.Success) {\n entry.Progress.Data?.CurrentScore;\n entry.Progress.Data?.LastKnownRank;\n entry.Progress.Data?.NextMilestone; // next unreached MilestoneDefinition\n }\n}\n```\n\nUse `getOverviewBatch` for a multi-board hub screen instead of pairing\n`getLeaderboardsBatch` + `getProgressBatch` yourself — it's one round trip and\neach entry already pairs the two results by `LeaderboardID`.\n\n### Claim everything at once across several leaderboards\n\n```ts\nconst res = await client.leaderboard.claimAllRewardsBatch([\n \"weekly_score\",\n \"season_pvp\",\n]);\nif (!res.ok) return showError(res.error);\n\nfor (const entry of res.data) {\n if (entry.CycleReward?.Success) applyCycleReward(entry.LeaderboardID);\n for (const m of entry.Milestones ?? []) {\n if (m.Success) applyMilestone(entry.LeaderboardID, m.Data?.MilestoneID);\n else showItemError(entry.LeaderboardID, m.Error);\n }\n}\n```\n\nThis is the one-tap \"claim all\" button: it claims each leaderboard's rank\nreward (if any) and every reachable milestone, per-leaderboard. A leaderboard\nwith nothing to claim simply reports `CycleReward: null`/absent and an empty\n`Milestones` list — not an error.\n\n### Edge case: milestones during a grace window after cycle end\n\n```ts\nconst progress = await client.leaderboard.getMyProgress(\"weekly_score\");\nif (progress.ok) {\n for (const grace of progress.data.ClaimableGraceCycles ?? []) {\n // A past cycle/instance still inside its MilestoneClaimGraceHours window.\n for (const milestoneID of grace.ClaimableMilestoneIDs ?? []) {\n const claim = await client.leaderboard.claimMilestone(\n \"weekly_score\",\n milestoneID,\n );\n if (!claim.ok) showError(claim.error); // e.g. window expired mid-request\n }\n }\n}\n```\n\nWhen a leaderboard's `MilestoneClaimMode` is `AfterEventEnd` (or\n`FeaturedAfterEnd` for a featured milestone), milestones reached during a\ncycle/instance can't be claimed until it closes, and only within\n`MilestoneClaimGraceHours` afterward. `GetMyProgressResponse\n.ClaimableGraceCycles` lists any such still-open past cycles/instances (see\n`LeaderboardGraceCycle` in the reference) — check it in addition to the\ncurrent cycle's own milestones, since the current cycle's `NextMilestone` only\ndescribes the next _unreached_ one, not ones already earned but unclaimed\nunder `AfterEventEnd`/`FeaturedAfterEnd`.\n\n### Batch score submission from multiple game systems\n\n```ts\nconst res = await client.leaderboard.submitScoresBatch([\n { LeaderboardID: \"weekly_score\", Score: 150 },\n { LeaderboardID: \"season_pvp\", Score: 20 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id, item.Data?.NewScore);\n else showItemError(item.Id, item.Error); // e.g. this board is disabled\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that submission\napplied. Use this when one game action (e.g. finishing a match) should post a\nscore to several leaderboards at once instead of firing N separate\n`submitScore` calls.\n\n## Gotchas\n\n- **Batch atomicity is conditional, not blanket.** Unlike some other modules'\n batch actions, Leaderboard batches are only a single all-or-nothing\n transaction for the subset of targeted leaderboards that are **Cyclic and\n not bracketed**. Any leaderboard using `BracketSize > 0` or a\n `Scheduled`/`Chained` schedule is processed as an independent per-item\n operation inside the same batch call — each such item can succeed or fail\n on its own, and a failure there does **not** roll back the atomic cyclic\n group (nor vice versa). Don't assume \"the whole batch failed\" or \"the whole\n batch is one transaction\" for mixed leaderboard sets.\n- **Batch input is deduped, clamped, and silently overflowed.** All\n `*Batch`/array-of-IDs methods (and `claimMilestonesBatch`'s pairs,\n `submitScoresBatch`'s score refs) trim whitespace, drop empty entries,\n dedupe (first occurrence wins), and cap at **50 items per call** —\n anything past 50 is silently dropped and never appears in the results\n array. The SDK does not pre-validate or chunk this for you; if you can\n exceed 50 in one screen action, split into multiple calls yourself.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — resubmitting the same score\n twice (e.g. a double-tapped \"Submit\" button) posts it twice under `Sum`\n aggregation. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 500 ms for this module,\n is rejected with `reason: \"throttled\"` rather than duplicated, but don't\n rely on that for correctness.)\n- **`NewScore` reflects aggregation, not addition.** Don't assume\n `NewScore === previous + submitted`; only `Sum` behaves that way.\n `BestScore`/`BestTime` may leave `NewScore` unchanged if the submission\n didn't beat the record. Always read `NewScore` from the response rather than\n computing it locally.\n- **Cache resets on a new cycle.** When a submitted score's `CycleVersion` is\n newer than the cached one, the SDK zeroes `ScoreEarnedThisCycle` and\n `ClaimedMilestoneIDs` and clears `BracketID` for that leaderboard — a stale\n UI holding old milestone-claimed state from the prior cycle will look wrong\n until you re-render from the cache after the bump.\n `UnclaimedRewardCycleVersion` is a separate flag driven by\n `HasUnclaimedReward` from `getMyProgress`, not by score submission.\n `claimCycleReward` only clears it, it doesn't reset the rest of the cycle.\n- **\"No unclaimed reward\" / \"Reward already claimed\" are normal outcomes, not\n bugs.** `claimCycleReward` and `claimMilestone` reject with\n `reason: \"server\"` if there's nothing to claim — always check\n `HasUnclaimedReward` (or `NextMilestone` / `HasUnclaimedMilestone`) from\n `getMyProgress` before showing a claim button.\n- **Friends view is a separate read, not a filter you apply.** Use\n `getFriendsLeaderboard`, not `getLeaderboard` — the backend does the\n friends-list filtering server-side; there's no client-side way to derive it\n from the global top list (a friend far outside `TopUsersLimit` wouldn't be\n in that list at all).\n- **Score submission and reward claiming both need auth; reads of\n Definitions/top-list/friends do too.** Every method in this service — reads\n included — requires `client.auth.context`; there's no anonymous leaderboard\n browsing in this SDK.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, schedule modes (Cyclic vs Scheduled vs Chained instance\nidentity), score aggregation modes, rank-reward ranges, milestone claim\nmodes and grace windows, brackets, and the exact batch atomicity/dedup rules.\n",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
8
+ "content": "# Leaderboard data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nmechanics, score aggregation, rank/milestone rewards, brackets, and batch\nsemantics. All of these are **strictly typed in the SDK** —\n`LeaderboardDefinitions` and every nested block (`LeaderboardDefinition`,\n`LeaderboardRankReward`, `LeaderboardChainedEvent`, the shared `ScheduleSpec`/\n`SegmentGate`/`MilestoneDefinition`/`TriggerSource`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LeaderboardDefinitions>(\"Leaderboard\")` give you concrete types,\nnot `unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what's cached from `getMyProgress()` / batch reads\n- [Config: LeaderboardDefinitions](#config-leaderboarddefinitions) — what `getDefinitions()` returns\n- [LeaderboardDefinition](#leaderboarddefinition)\n- [Score aggregation](#score-aggregation)\n- [Schedule modes: Cyclic vs Scheduled vs Chained](#schedule-modes-cyclic-vs-scheduled-vs-chained)\n- [Rank rewards](#rank-rewards)\n- [Milestones & claim modes](#milestones--claim-modes)\n- [Brackets](#brackets)\n- [Score sources (ScoreSources / submitScoreFromSource)](#score-sources-scoresources--submitscorefromsource)\n- [Responses](#responses)\n- [Batch semantics](#batch-semantics)\n\n---\n\n## Player state\n\nProgress is cached per cycle/instance doc at\n`client.data.user.state?.Leaderboard?.ProgressByCycle[cycleDocID]`. For a\n`Cyclic` leaderboard, `cycleDocID` is `` `${titleID}_${leaderboardID}` ``\n(built by the service — never construct it yourself). On the backend, a\n`Scheduled`/`Chained` leaderboard uses a per-instance doc ID\n(`{base}:{instanceKey}`) instead; the SDK's client-facing methods operate on\nthe _current_ instance implicitly, so you normally don't need to know that\nsuffix — read progress back via the response's `LeaderboardID` if you need to\nscan the map.\n\n```ts\ninterface UserLeaderboardProgress {\n CurrentScore?: number; // this player's current score in the active cycle\n ScoreEarnedThisCycle?: number; // sum of submissions since the cycle started\n ClaimedMilestoneIDs?: string[]; // milestone ids already claimed this cycle\n ScoreCycleVersion?: number; // cycle version this progress belongs to\n LastScoreSubmitUtc?: string; // ISO timestamp of the last submitScore\n UnclaimedRewardCycleVersion?: number; // >0 = a rank reward is waiting; 0 = none/claimed\n LastClaimedCycleVersion?: number;\n LastKnownRank?: number; // rank as of the last getMyProgress/getLeaderboard read\n BracketID?: string | null; // which bracket this player is in, if BracketSize is set\n}\n\ninterface UserLeaderboardsState {\n ProgressByCycle?: Record<string, UserLeaderboardProgress>;\n}\n```\n\nCache-write rules the SDK applies for you (`packages/core/src/cache/UserData.ts`,\nports of the backend's `PatchLeaderboard*` progress mutations):\n\n- **`submitScore` / `submitScoreFromSource`** → `patchLeaderboardScoreSubmitted`:\n if the response's `CycleVersion` is newer than the cached\n `ScoreCycleVersion`, the cache resets `ScoreEarnedThisCycle` to 0, clears\n `ClaimedMilestoneIDs`, and clears `BracketID` (new cycle, clean slate)\n _before_ applying the new submission. `CurrentScore` is then always set to\n the response's `NewScore`; `ScoreEarnedThisCycle` accumulates the submitted\n amount (regardless of aggregation mode — it tracks _submissions_, not the\n aggregated score); `ScoreCycleVersion` and `LastScoreSubmitUtc` are updated\n unconditionally. Note `submitScoreFromSource`'s benign no-op response\n (`NewScore: 0, CycleVersion: 0` when no source matched, or the leaderboard\n isn't `Sum`-aggregated) still runs through this patch — it sets\n `CurrentScore` to 0 and touches `LastScoreSubmitUtc`, but with\n `CycleVersion: 0` it will not trigger the newer-cycle reset branch on a\n leaderboard whose cached version is already `>= 0`.\n- **`getMyProgress` / `getProgressBatch` / `getOverviewBatch`** →\n `patchLeaderboardMyProgress`: overwrites `CurrentScore`, `LastKnownRank`,\n `ScoreEarnedThisCycle` from the response, and sets\n `UnclaimedRewardCycleVersion` to `max(1, previous)` if `HasUnclaimedReward`\n is true, else resets it to 0.\n- **`claimCycleReward` / `claimCycleRewardsBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardCycleRewardClaimed`: zeroes `UnclaimedRewardCycleVersion`.\n Requires an existing progress entry (a no-op if the cache never saw this\n leaderboard).\n- **`claimMilestone` / `claimMilestonesBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardMilestoneClaimed`: appends the milestone id to\n `ClaimedMilestoneIDs` (dedup'd).\n\n---\n\n## Config: LeaderboardDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\")`.\n\n```ts\ninterface LeaderboardDefinitions {\n Definitions?: Record<string, LeaderboardDefinition>; // key = LeaderboardID\n}\n```\n\n---\n\n## LeaderboardDefinition\n\nOne title leaderboard: identity, schedule, gate, brackets, rank rewards,\nmilestones, and what feeds it a score. Source: `LeaderboardDefinition.cs`\n(`IDosGamesSDK/API/Client/v2/Leaderboard/Models/LeaderboardDefinitions.cs`).\n\n```ts\ninterface LeaderboardDefinition {\n LeaderboardID: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null; // label for the score column, e.g. \"Kills\", \"Time\"\n AssetPaths?: Record<string, string> | null;\n IsEnabled?: boolean | null; // default true; false rejects reads/writes\n\n ScoreAggregation?: string | null; // Sum | BestScore | BestTime | LastValue; default Sum\n Schedule?: ScheduleSpec | null; // schedule/instance container (shared block); default Mode=Cyclic\n ChainEvents?: LeaderboardChainedEvent[] | null; // phases, when Schedule.Mode === \"Chained\"\n Gate?: SegmentGate | null; // who can see/participate (shared block)\n\n TopUsersLimit?: number | null; // max entries stored/returned in the top list; default 100\n MinScoreToEnterTop?: number | null; // score floor to appear in the top list; default 1\n BracketSize?: number | null; // players per bracket; default 0 = one global board\n\n LinkedSeasonChainID?: string | null; // ties rank/milestone rewards to a Season chain's tiers\n\n RankRewards?: LeaderboardRankReward[] | null;\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID\n\n MilestoneClaimMode?: string | null; // Instant | AfterEventEnd | FeaturedAfterEnd; default Instant\n MilestoneClaimGraceHours?: number | null; // claim window after cycle/instance end; default 0\n\n ScoreSources?: TriggerSource[] | null; // what `submitScoreFromSource` matches against\n}\n```\n\nA leaderboard with `IsEnabled: false` rejects reads/writes with\n`\"Leaderboard '{id}' is disabled\"` — check it before showing the screen.\n`Gate` is the same `SegmentGate` used across the SDK (segments, premium tier,\nlevel range, country, experiment variant — see `.claude/skills/premium-system/SKILL.md`\nfor how `MinPremiumTier` is evaluated) — all conditions are AND-ed,\nempty/absent gate = open to everyone. A player failing the gate gets\n`\"Leaderboard '{id}' is not available for you\"` from both `getLeaderboard`\nand `submitScore`.\n\n---\n\n## Score aggregation\n\n`ScoreAggregation` controls what `submitScore`'s `NewScore` becomes relative\nto the player's existing score in the current cycle\n(`LeaderboardV2.AggregateScore`):\n\n| Mode | Behavior |\n| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Sum` | Adds the submitted value to the running total. |\n| `BestScore` | Keeps the higher of the two (`Math.Max(current, incoming)`). |\n| `BestTime` | Keeps the lower of the two (`Math.Min`), except the very first submission (`current == 0`) always wins outright — a `0` current score never beats a real time. |\n| `LastValue` | Always overwrites with the latest submission. |\n\nThis math is backend-owned — the SDK never recomputes it; always read\n`NewScore` from `SubmitScoreResponse` rather than predicting it client-side.\n`ScoreEarnedThisCycle` in the cache tracks the sum of raw submissions\nregardless of aggregation mode (useful for milestone progress even under\n`BestScore`/`LastValue`, where `CurrentScore` itself may not move on every\nsubmit). Top-list sort order (`LeaderboardV2.SortTopUsers`) mirrors this:\n`BestTime` sorts ascending by score (lower = better), everything else sorts\ndescending; ties break by whoever reached that score first\n(`LastScoreUpdateUtc` ascending, both for `BestTime` and the other modes).\n\n`ScoreSources`-driven accrual (`submitScoreFromSource`, and all server-side\nboard/marketplace score grants) is accumulative by nature and is therefore\n**only wired up for `Sum`-aggregated leaderboards** — see\n[Score sources](#score-sources-scoresources--submitscorefromsource).\n\n---\n\n## Schedule modes: Cyclic vs Scheduled vs Chained\n\n`Schedule` is the shared `ScheduleSpec` block (same primitive used by\nTimedEvent, TimedBoost, CoopEvent, Season, DealOffer). For leaderboards only\nthree `Mode` values matter in practice:\n\n```ts\ninterface ScheduleSpec {\n Mode?: string; // Cyclic | Scheduled | Chained | AlwaysOn | Triggered | Relative\n IsActive?: boolean;\n Scheduled?: ScheduledWindow; // fixed StartUtc/EndUtc window (+ ClaimGraceHours, AllowEarningAfterEnd)\n Chain?: ScheduleChain; // recurring phase-chain timing (AnchorUtc, PauseBetweenPhasesSec, PauseBetweenCyclesSec)\n Cyclic?: CyclicSchedule; // calendar-aligned reset (Reset: Daily/Weekly/.../Never, + FixedInterval fields)\n ActivationTriggers?: TriggerSource[]; // unused by Leaderboard (Triggered mode not resolved here)\n Relative?: RelativeWindow; // unused by Leaderboard\n}\n```\n\nEach mode gives the leaderboard a different **instance identity** — this is\nthe single most important thing to get right when building UI around\n`CycleIndex`/`ChainedEventID`/grace windows:\n\n- **`Cyclic`** (default) — one leaderboard, an endless series of cycles keyed\n by a calendar-aligned `CyclicSchedule.Reset` (`Daily`/`Weekly`/`Monthly`/...\n /`Never`/`FixedInterval`). The doc ID is fixed\n (`{titleID}_{leaderboardID}`); what moves is `CycleVersion`, incremented\n each reset. `submitScore`, `getMyProgress`, `claimCycleReward`, and\n `claimMilestone` all operate on the _current_ cycle implicitly — you never\n pass a cycle selector for normal play. Past cycles are only reachable via\n `ClaimableGraceCycles` (see [Milestones](#milestones--claim-modes)) and via\n `LeaderboardRequest.CycleKey` on `claimMilestone` — the SDK's\n `claimMilestone(leaderboardID, milestoneID)` does not currently expose a\n `CycleKey` parameter, so client code can only claim grace-window milestones\n the SDK's typed method surfaces for the _current_ call shape; treat this as\n a backend capability documented for completeness rather than something the\n bundled recipes exercise beyond what's already reachable from\n `ClaimableGraceCycles`.\n- **`Scheduled`** — a single fixed window (`Schedule.Scheduled.StartUtc`/\n `EndUtc`). There is exactly one instance, ever, for that config. Before\n `StartUtc` or after `EndUtc + ClaimGraceHours` there is no active instance\n (`getLeaderboard`/`getOverviewBatch` return an empty top list;\n `submitScore` fails with `\"has no active instance right now\"`).\n- **`Chained`** — a repeating sequence of named phases (`ChainEvents`, each\n with `Order`/`DurationSec`/`ClaimGraceHours`), running back-to-back with\n `Schedule.Chain.PauseBetweenPhasesSec` between phases and\n `PauseBetweenCyclesSec` between full loops of the chain. Each\n `(CycleIndex, ChainedEventID)` pair is an **independent leaderboard\n instance** — its own top list, its own `RankRewards`/`Milestones` slate\n (shared config, independent progress), its own bracket set, its own token\n bucket. `LeaderboardRequest` (and hence some backend actions) can address a\n specific past phase via `CycleIndex`/`ChainedEventID`, but the SDK's\n everyday methods (`submitScore`, `getMyProgress`, `claimCycleReward`,\n `claimMilestone`) implicitly target the _current_ active phase; you\n encounter `ChainedEventID`/`CycleIndex` values mainly when reading them\n back off `LeaderboardGraceCycle` for a phase that just ended.\n\nFor all three modes, `CycleVersion` on `SubmitScoreResponse`/\n`GetLeaderboardResponse` is what the SDK cache compares against the cached\n`ScoreCycleVersion` to detect \"this is a new cycle, reset local per-cycle\nstate\" — for `Scheduled`/`Chained` instances this version is always `1`\n(instance identity lives in the doc ID, not the version number).\n\n`ScheduledWindow.ClaimGraceHours` / each `LeaderboardChainedEvent\n.ClaimGraceHours` set how long after that instance ends its rank reward and\nmilestones stay claimable — mirrors the `MilestoneClaimGraceHours` concept\ndescribed in [Milestones](#milestones--claim-modes) but is a _per-instance_\nwindow for Scheduled/Chained rather than the leaderboard-wide\n`MilestoneClaimGraceHours` used by Cyclic.\n\n---\n\n## Rank rewards\n\n```ts\ninterface LeaderboardRankReward {\n Rank?: string; // rank range: \"1\", \"2-5\", \"6-10\", \"11-50\", ...\n Rewards?: ResourceGrant; // grant-only — no cost side\n SeasonTierRewards?: SeasonTierRewardSet; // optional season-tier scaling overlay\n}\n```\n\n`Rank` is a string range matched against the player's final rank when the\ncycle/instance closes (`LeaderboardV2.IsRankInRange`: an exact integer like\n`\"1\"`, or an inclusive `\"start-end\"` range). `claimCycleReward` looks up\nwhich range the player's `Rank` (from `ClaimCycleRewardResponse.Rank`) falls\ninto and pays out that entry's `Rewards`. If `LinkedSeasonChainID` is set,\n`SeasonTierRewards` can scale or replace the base reward based on the\nplayer's tier in that season chain, resolved from a **snapshot**\n(`LeaderboardUserEntry.SeasonTierAtCycleEnd`) taken when the cycle/instance\nwas archived — not the player's current tier — so a season reset between\ncycle-end and claim doesn't change what a past cycle pays out. (`Bundles`\nreplace on match, `Multipliers` scale the base `Rewards.Amounts` — same\noverlay mechanic as `MilestoneDefinition.SeasonTierRewards`.)\n\nA player only has something to claim when `HasUnclaimedReward` (from\n`getMyProgress`) is true, mirrored into the cache as\n`UnclaimedRewardCycleVersion > 0`. If the player wasn't in the final top list,\nor their rank has no matching `RankRewards` entry, or the resolved grant is\nempty after premium filtering, the backend auto-marks the cycle as claimed\nwithout granting anything (`\"No unclaimed reward\"` on a later claim attempt,\nor a normal `claimCycleReward` success with an empty `Resources`) — don't\ntreat either as an error state to alarm the player over.\n\n---\n\n## Milestones & claim modes\n\n`Milestones` uses the SDK-wide `MilestoneDefinition` primitive (shared with\nQuest, TimedEvent, CoopEvent, DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // threshold checked against accumulated progress\n Rewards?: ResourceGrant;\n BonusRewards?: ResourceGrant; // paid during a bonus window, if applicable\n SeasonTierRewards?: SeasonTierRewardSet;\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nFor leaderboards, progress toward a milestone is tracked by a dedicated\n**leaderboard event token** (`EventTokenType.Leaderboard`, address\n`\"{leaderboardID}:{cycleKey}\"` for Cyclic or `\"{leaderboardID}:{instanceKey}\"`\nfor Scheduled/Chained) — its cumulative `Balance.TotalEarned` is the\nscore-earning activity in the current cycle/instance\n(`ScoreEarnedThisCycle` / the response's `MilestoneTokensEarned`), separate\nfrom the ranked `CurrentScore`. A `BestScore` leaderboard where you resubmit\na lower score still accrues milestone progress from the submission, because\nthe token accrues every submitted amount regardless of aggregation.\n\n`MilestoneClaimMode` controls _when_ a reached milestone can be claimed\n(`IDosGames.MilestoneClaimMode`, shared enum — **note the exact member\nnames**, they are not `\"AfterCycleEnd\"`):\n\n- **`Instant`** — claimable as soon as it's reached, any time during the\n cycle/instance, via `claimMilestone` / `claimMilestonesBatch`.\n- **`AfterEventEnd`** — every milestone must wait until the cycle/instance\n closes, and then only within `MilestoneClaimGraceHours` (Cyclic) or the\n instance's own `ClaimGraceHours` (Scheduled/Chained) of the close. Claiming\n early fails with `\"Milestone can only be claimed after cycle ends\"` (Cyclic)\n or `\"...after instance ends\"` (Scheduled/Chained).\n- **`FeaturedAfterEnd`** — hybrid: milestones with `IsFeatured: false` claim\n `Instant`-style any time; milestones with `IsFeatured: true` behave like\n `AfterEventEnd` (`\"Featured milestone can only be claimed after cycle\nends\"`).\n\n`GetMyProgressResponse.ClaimableGraceCycles` lists past cycles/instances\nstill inside the claim window:\n\n```ts\ninterface LeaderboardGraceCycle {\n CycleKey?: string;\n CycleEndUtc?: string;\n ClaimDeadlineUtc?: string; // CycleEndUtc + grace hours\n MilestoneTokensEarned?: number;\n ClaimableMilestoneIDs?: string[]; // milestones from that cycle/instance still claimable\n CycleIndex?: number; // Chained only\n ChainedEventID?: string; // Chained only\n}\n```\n\nOnce `ClaimDeadlineUtc` passes, that cycle/instance's milestones are no\nlonger claimable — check `ClaimableGraceCycles` (and its per-cycle deadline)\nrather than assuming a reached-but-unclaimed milestone stays claimable\nindefinitely. `MilestoneClaimGraceHours: 0` (the default) means Cyclic grace\nis disabled entirely — once a cycle rolls over, its milestones are gone for\ngood.\n\n---\n\n## Brackets\n\nWhen `BracketSize` is set (default `0` = disabled), the leaderboard splits\nplayers into fixed-size brackets rather than one global ranking (keeps ranks\nmeaningful at scale). A player is assigned to a bracket on their **first\nscore submission of the cycle**; resubmissions in the same cycle update their\nscore within that same bracket. A player's current bracket is `BracketID` on\nboth the cached progress and `GetLeaderboardResponse.BracketID`. `BracketID`\nresets to `null` whenever the cache detects a new cycle (see the score-submit\nreset rule above) — a new cycle reshuffles brackets. Bracket assignment\nretries up to 5 times server-side under contention (multiple players racing\nto fill the last slot of a bracket); in the rare case all retries are\nexhausted the player is left without a bracket for that cycle and sees an\nempty top list until their next submission.\n\n---\n\n## Score sources (ScoreSources / submitScoreFromSource)\n\n`ScoreSources: TriggerSource[]` lets a leaderboard accrue score automatically\nfrom game events, instead of relying solely on an explicit `submitScore`\ncall — the same unified `TriggerSource` primitive TimedEvent uses for token\nsources:\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // e.g. \"DailyLogin\", \"ReferralInvite\", \"CustomAction\", board/quest/store/marketplace types\n BaseWeight?: number; // score granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's rollMultiplier/volume\n TileTypeFilter?: string[];\n TileIndexFilter?: number[];\n ChanceOutcomeFilter?: string[];\n OutcomeFilter?: string[];\n Params?: Record<string, string>;\n Limits?: LimitSpec;\n}\n```\n\nTwo independent gates apply, both verified in `LeaderboardV2.cs`:\n\n1. **Aggregation gate** — `ScoreSources` are cumulative deltas\n (`BaseScore × multiplier`), so they're only ever matched/applied on\n `Sum`-aggregated leaderboards. On any other `ScoreAggregation`, both the\n server-side board/marketplace grant builders and\n `submitScoreFromSource` silently skip (no score change, no error) — for\n `BestScore`/`BestTime`/`LastValue` leaderboards, score must come from an\n explicit `submitScore` with the real value (a time, a high score), not\n accumulate off game events.\n2. **Client/server source gate** — `submitScoreFromSource` (the client-facing\n gateway) rejects a fixed list of **server-only** source types outright\n with `\"Source '{type}' cannot be called from client.\"`: all\n `Board*` sources (`BoardTileLanding`, `BoardPassStart`, `BoardAttack`,\n `BoardRaid`, `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`),\n `QuestComplete`, `StorePurchase`, `MilestoneReward`,\n `LeaderboardRankReward`, `MarketplaceSell`, `MarketplaceBuy`. Those are\n wired into leaderboards automatically by the corresponding server feature\n (GameLoop rolls, Marketplace settlements) — you don't and can't call them\n from the client. Client-safe sources like `DailyLogin`, `ReferralInvite`,\n or `CustomAction` (with your own `Params`) are the ones meant for\n `submitScoreFromSource`.\n\nIf no configured `ScoreSources` entry matches the given `sourceType`/\n`outcome`/`params` (or `BaseWeight <= 0`), the call is a **benign no-op**:\n`ok: true` with `{ NewScore: 0, CycleVersion: 0 }`, not an error — the\nbackend deliberately doesn't leak \"does this source exist\" information via\nan error. Don't render a `NewScore` toast without checking it's `> 0`.\n\n---\n\n## Responses\n\n```ts\ninterface LeaderboardUserEntry {\n UserID: string;\n Score?: number;\n Rank?: number;\n LastScoreUpdateUtc?: string;\n IpHash?: string;\n PublicProfile?: UserPublicDataModel; // display name/avatar for rendering the row\n SeasonTierAtCycleEnd?: number; // this entry's season tier when the cycle closed (archive snapshot only; 0 while cycle is active)\n}\n\ninterface GetLeaderboardResponse {\n LeaderboardID: string;\n CycleVersion?: number;\n TotalParticipants?: number;\n CycleEndUtc?: string;\n TopUsers?: LeaderboardUserEntry[]; // length capped at TopUsersLimit\n BracketID?: string; // set when BracketSize splits the board\n}\n\ninterface GetMyProgressResponse {\n LeaderboardID: string;\n CurrentScore?: number;\n LastKnownRank?: number;\n HasUnclaimedReward?: boolean;\n ScoreEarnedThisCycle?: number;\n MilestoneTokensEarned?: number;\n NextMilestone?: MilestoneDefinition; // next unreached milestone, or absent if none left\n HasUnclaimedMilestone?: boolean;\n ClaimableGraceCycles?: LeaderboardGraceCycle[];\n}\n\ninterface SubmitScoreResponse {\n LeaderboardID: string;\n NewScore: number;\n CycleVersion: number;\n}\n\ninterface ClaimCycleRewardResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n Rank?: number;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimLeaderboardMilestoneResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n MilestoneID: string;\n Resources?: ResourceOperation;\n}\n\ninterface LeaderboardOverview {\n LeaderboardID: string;\n Leaderboard?: BatchItemResult<GetLeaderboardResponse> | null;\n Progress?: BatchItemResult<GetMyProgressResponse> | null;\n}\n\ninterface ClaimAllRewardsResult {\n LeaderboardID: string;\n CycleReward?: BatchItemResult<ClaimCycleRewardResponse> | null;\n Milestones?: BatchItemResult<ClaimLeaderboardMilestoneResponse>[] | null;\n}\n```\n\n---\n\n## Batch semantics\n\nAll batch/array-of-IDs inputs (`LeaderboardIDs`, `Milestones` pairs, `Scores`\npairs) go through the same normalization on the backend\n(`BatchSupport.NormalizeIDs` / equivalent inline dedup in\n`SubmitScoresBatch`/`ClaimMilestonesBatch`): entries are trimmed, empty/\ninvalid ones dropped, duplicates removed (first occurrence wins, by ID for\nsingle-value batches or by the natural composite key — `LeaderboardID` for\nscores, `` `${LeaderboardID}:${MilestoneID}` `` for milestone pairs), then the\nlist is **clamped to 50 items**. Anything past the 50th is **silently\ndropped** — it never appears in the result array at all, success or failure.\nThe SDK does not pre-validate, warn, or chunk this for you.\n\n**Atomicity is per-subset, not per-call**, and this is the biggest surprise\nrelative to other v2 modules' batch actions:\n\n- `submitScoresBatch`: leaderboards that are `Cyclic` **and** not bracketed\n (`BracketSize == 0`) are all folded into a **single Mongo transaction** —\n either every one of those items' score+top-list update commits, or none\n do. Leaderboards that are bracketed, or `Scheduled`/`Chained`, are instead\n processed **one at a time** by internally re-calling the same code path as\n the single `submitScore` action — each such item is its own independent\n operation, can fail on its own, and does not affect the atomic group's\n outcome (or vice versa).\n- `claimCycleRewardsBatch` / `claimMilestonesBatch` / `claimAllRewardsBatch`:\n same split. `Cyclic` (non-bracketed or bracketed — bracket archives are\n just a different lookup, still part of the cyclic path) targets are merged\n into **one atomic claim** (`ApplyResourceOperationAtomicAsync` with all\n their patches/filters ANDed and their `Resources` grants merged — see\n `ApplyClaimBatchAsync`/`BatchSupport.ApplyMergedClaimAsync`). `Scheduled`/\n `Chained` targets are each claimed independently (one atomic operation\n _per instance_, since instances have no shared version to gate a joint\n transaction on), and for `claimAllRewardsBatch`/`claimCycleRewardsBatch` a\n non-cyclic leaderboard may internally claim **several ended-but-in-grace\n instances at once**, merging their resources into one aggregated\n `BatchItemResult` entry.\n- Where a merged claim did pay out resources, the combined `ResourceOperation`\n is attached to only the **first** successful item that had a non-empty\n grant in each atomic group (`resourcesAttached` flag) — don't expect every\n successful item in a multi-leaderboard claim to carry its own slice of\n `Resources`; read the aggregate off whichever item has it and trust the\n SDK's applied-balance side effects for the rest.\n- Invalid items (unknown leaderboard/milestone, \"already claimed\", \"no\n unclaimed reward\", timing violations) are filtered out **before** the\n atomic transaction is built, so one stale/bad entry in a batch never blocks\n the others from committing.\n\nIdempotency for single calls follows the platform-wide `RelatedEntityID`\npattern (`ResourceService.ResolveRelatedEntityID`): cycle\nclaims key off `lb_cycle_{leaderboardID}_v{cycleVersion}` (or\n`lb_inst_{instanceDocID}` for Scheduled/Chained), milestone claims key off\n`lb_milestone_{tokenEntityID}_{milestoneID}`. Batch claims build one\ncombined, deterministic reason from the sorted keys of everything actually\nincluded in that transaction (`BatchSupport.BuildBatchReason`) — retries with\nthe same effective item set are idempotent; changing the item set changes the\nreason.\n"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "lootbox-system",
3
+ "description": "Build a lootbox / gacha / loot-crate system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load lootbox definitions (reward slots, weighted pools, price options, pity rules) and open one or many boxes for randomized rewards, including hard-pity tracking. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants loot crate / gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or bad-luck-protection systems, or otherwise touches client.lootbox, LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or UserLootboxState — even if they don't name the module explicitly.",
4
+ "content": "---\nname: lootbox-system\ndescription: >-\n Build a lootbox / gacha / loot-crate system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load\n lootbox definitions (reward slots, weighted pools, price options, pity\n rules) and open one or many boxes for randomized rewards, including\n hard-pity tracking. Use this whenever the user is working in the iDosGames\n TS SDK or its game templates (board-game, idle-rpg) and wants loot crate /\n gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or\n bad-luck-protection systems, or otherwise touches client.lootbox,\n LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or\n UserLootboxState — even if they don't name the module explicitly.\n---\n\n# Lootbox system (iDosGames TS SDK)\n\nThe Lootbox module lets a title define reward crates: each box has one or more\n**reward slots**, each slot rolls a configurable number of times over a\nweighted **pool** of possible rewards, and boxes can carry **pity rules** that\ngrant an extra guaranteed roll from the rule's own pool every `Threshold`\nopens of that box. It's **server-authoritative**: the client asks the backend\nto open N boxes, the backend rolls every reward, applies pity, and returns the\nfull breakdown; the SDK mirrors granted resources and pity counters into the\nlocal cache. You never roll the loot yourself — you call `open()`, check the\nresult, and render from the response + cache.\n\nThis skill is for **using** the production `LootboxService`, not for porting\nor extending it. If an open is rejected, that's the backend enforcing a rule\n(cost, unknown box/option) — surface the error, don't try to reproduce the\nroll or the pity math client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n lootboxes: price options, reward slots + weighted pools, and pity rules.\n Fetched with `getDefinitions()`.\n2. **Pity state** (state, per player) — how many times each pity rule has\n fired and its running open-counter. There's no dedicated getter for this;\n it rides in on `open()`'s response and on the general user-state bootstrap\n (`client.user.getClientState()`).\n\nA lootbox is identified by a string `LootboxID`. Reward slots and pity rules\nroll over a shared **weighted pool** primitive (`LootboxRewardRoll`) — the same\nshape used by the Collection module's bonus slots. For the full formulas\n(weighted-pick algorithm, pity threshold math, the catalog pre-filter, the\noptional reward-progression multiplier), read\n[references/data-model.md](references/data-model.md). You don't need it to\ncall the two methods below — only to drive richer config-preview UI (odds,\npity countdowns) or to reason about an edge case.\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 lootbox = client.lootbox; // the LootboxService\n```\n\nEvery lootbox method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — missing\n`LootboxID` or `count < 1`), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the 600ms client-side throttle window), `\"connection\"`\n(transient, offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` carries the human-readable reason,\ne.g. `\"Lootbox config not found.\"`, `\"Price option {id} not found.\"`,\n`\"Price option has empty RequiredResources.\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ---------------------------------------------- | ---------------------------- |\n| `getDefinitions()` | Load the title's lootbox catalog (config). | `LootboxDefinitionsResponse` |\n| `open(lootboxID, count, selectedOptionID)` | Pay and open `count` boxes in one atomic call. | `LootboxOpenResponse` |\n\n`selectedOptionID` is a **number** key into the box's `PriceOptions` map (each\noption is a distinct price, e.g. one gem price and one real-money-currency\nprice) — there's no default, you must pick one. `count` opens that many boxes\nat once; the server clamps it to `[1, 100]` regardless of what you send, then\ncharges `count` times the selected option's price (grouped/summed per\ncurrency and item, not one charge per box) and rolls each box independently —\npity can trigger more than once mid-batch if `count` is large enough.\n\nOn success, `open()`:\n\n- applies any `data.TriggeredPity` entries into the cached per-rule pity\n counters (`client.data.user.state?.Lootbox?.Pity`), resetting\n `OpensSinceLastTrigger` to `0` and stamping `LastTriggeredAtUtc` — this also\n fires `user:lootboxUpdated`;\n- applies `data.Resources` (consumed price / granted rewards) to the cached\n currency and item balances via the shared resource-operation pipeline —\n this fires `user:inventoryUpdated`/`user:virtualCurrencyUpdated`/\n `user:eventTokenUpdated` as appropriate, **not** `user:lootboxUpdated`.\n\nRead updated balances and pity state from the cache as usual.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LootboxDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\n// Pity counters (only present once an open() has triggered pity at least once,\n// or after a full client.user.getClientState() bootstrap):\nconst pity = client.data.user.state?.Lootbox?.Pity ?? {};\npity[\"box1:pity1\"]?.OpensSinceLastTrigger;\npity[\"box1:pity1\"]?.LastTriggeredAtUtc;\n```\n\nThe pity cache key is `` `${lootboxID}:${ruleID}` ``. There is no\n`getUserLootboxState()` — the module has no state-fetch method of its own, and\nthe local cache only ever resets a counter to `0` on a trigger; it does not\nlocally increment it on non-triggering opens. The **server** does persist the\ntrue incremented counter on every open, and that authoritative `Lootbox.Pity`\nmap comes down as part of the user-profile bootstrap\n(`client.user.getClientState()` → `UserState.Lootbox`). So: treat the\nlocally-patched cache as \"when did this rule last fire,\" and refresh via\n`getClientState()` when you need a live \"N opens until pity\" countdown.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `lootbox:definitionsLoaded` → `LootboxDefinitions`\n- `lootbox:opened` → `LootboxOpenResponse`\n\nThe coarse `user:lootboxUpdated` fires specifically when pity state is\nwritten (i.e. only on calls whose response included `TriggeredPity`) — an\n`open()` that didn't trigger any pity rule won't fire it, even though\nbalances still changed (via `user:inventoryUpdated` etc.). The umbrella\n`user:anyUpdated` fires on both paths, so prefer that for a generic\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"lootbox:opened\", (r) => {\n console.log(`Opened ${r.OpenedCount}x ${r.LootboxID}`);\n r.TriggeredPity?.forEach((p) => console.log(`Pity fired: ${p.RuleID}`));\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and preview a box's odds\n\n```ts\nawait client.lootbox.getDefinitions();\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\nfor (const [lootboxID, def] of Object.entries(defs?.Definitions ?? {})) {\n def.PriceOptions; // Record<optionID (stringified number), { PriceOptionID, RequiredResources }>\n def.RewardSlots; // [{ SlotID, MinRolls, MaxRolls, Pool: [{ Reward, Weight, AmountRange }] }]\n def.PityRules; // [{ RuleID, Threshold, Pool }]\n}\n```\n\nEach `RewardSlot` rolls a random number of times uniformly in\n`[MinRolls, MaxRolls]`; each roll independently picks one entry from `Pool`\nweighted by `Weight` (optionally randomizing the granted `Amount` within\n`AmountRange`). Use this to show odds/rates in a UI, but the actual roll\nalways happens server-side — never let the client compute or pre-determine\nthe outcome.\n\n### Open a single box\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 1, 1);\nif (!res.ok) return showError(res.error); // e.g. can't afford\nres.data.Resources; // aggregated grant (already applied to cache)\nres.data.Results; // per-box ResourceOperation breakdown (one entry, for count=1)\n```\n\n### Open in bulk and surface pity\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 10, 1);\nif (!res.ok) return showError(res.error);\n\nconsole.log(`Opened ${res.data.OpenedCount} boxes`);\nfor (const trigger of res.data.TriggeredPity ?? []) {\n showPityToast(trigger.RuleID, trigger.BoxIndex); // BoxIndex = which box in Results triggered it\n}\n// balances/items already reflected in client.data.user.*\n```\n\nThe charge is atomic across the whole batch (one merged debit for all `count`\nboxes), but each box still rolls independently — some boxes in the batch can\ntrigger pity while others don't, and with a large `count` a single rule can\ntrigger more than once.\n\n### Display a pity progress bar\n\n```ts\nawait client.user.getClientState(); // refresh authoritative pity counters\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\nconst counter =\n client.data.user.state?.Lootbox?.Pity?.[\"box1:guaranteed_legendary\"];\nconst threshold = defs?.Definitions?.[\"box1\"]?.PityRules?.find(\n (r) => r.RuleID === \"guaranteed_legendary\",\n)?.Threshold;\n\nconst opensSince = counter?.OpensSinceLastTrigger ?? 0;\nconst remaining = threshold ? threshold - opensSince : undefined; // opens left until guaranteed\n```\n\nDon't derive `remaining` from the locally-patched cache after an `open()`\ncall unless that call's response included this exact `RuleID` in\n`TriggeredPity` (which resets it to `0`) — otherwise the local cache is stale\nfor non-triggering opens and you should re-fetch via `getClientState()`.\n\n### Show the \"what did I get\" reveal for one open() call\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 5, 2);\nif (!res.ok) return showError(res.error);\n\nres.data.Results?.forEach((box, i) => {\n const items = box.Grant?.Standard?.Entries ?? []; // this box's granted currencies/items\n const wasPityBox = res.data.TriggeredPity?.some((p) => p.BoxIndex === i);\n renderBoxReveal(items, wasPityBox);\n});\n```\n\n`Results[i]` already has the pity reward folded in for the box that triggered\nit, and is pre-filtered for the player's premium tier — so `Results` sums to\n`Resources`. Use `Results` for the per-box reveal animation, and the cache\n(post-`open()`) for running totals/balances.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Open\" can\n charge twice. Disable the control while a call is in flight.\n- **`selectedOptionID` is required and numeric**, unlike Craft's string\n `selectedOptionID` — don't confuse the two modules' option-id types.\n- **Pity is a plain opens-counter, not \"opens since last rare.\"** It counts\n every open of that `LootboxID` regardless of what was rolled from\n `RewardSlots`, and fires in addition to (never instead of) the normal roll.\n It's also keyed per box _and_ per rule (`lootboxID:ruleID`), so a box with\n both a soft-pity and a hard-pity rule tracks them fully independently.\n- **A stale/removed item in a reward pool can't break an open.** The backend\n pre-filters every pool against the title's active item catalogs before\n rolling (`RewardSlotHelpers.SanitizePool`); pool entries that only grant a\n since-deleted item are dropped and their weight redistributes to the rest.\n You don't need client-side defenses against a \"broken\" roll.\n- **`Results` is a list of `ResourceOperation`, not a list of named items** —\n if you need a flattened list of \"what did I get,\" derive it from\n `data.Resources.Grant.Standard.Entries` (and/or walk `Results`) rather than\n expecting a pre-flattened reward array.\n- **An optional `RewardMultiplier` can scale rewards with no visible signal.**\n If a lootbox config has one set, opened rewards are already scaled\n server-side before you see them — there's no getter to preview the current\n multiplier (unlike Reward's `getMilestoneRewardMultiplier()`), so don't\n build a \"boosted rewards\" indicator that tries to recompute it; see\n [references/data-model.md](references/data-model.md).\n- **`user:lootboxUpdated` only fires on a pity write**, not on every\n successful open — a box with no `PityRules` (or one that just didn't\n trigger) updates balances via `user:inventoryUpdated`/\n `user:virtualCurrencyUpdated`/`user:eventTokenUpdated` instead. Use\n `user:anyUpdated` if you want one hook that covers both.\n- **Render from the cache for balances/pity, from the response for the\n \"reward reveal\" animation** — the response is the only place you get the\n full roll breakdown for a single `open()` call as a discrete unit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config field,\nthe weighted-roll and pity-threshold formulas transcribed from the backend,\nthe catalog pre-filter, cost scaling for `count > 1`, and the\nreward-progression multiplier shape. Read it when building config-driven UI\n(odds previews, pity countdowns) or when you need to reason precisely about a\nbatch-open edge case.\n",
5
+ "references": [
6
+ {
7
+ "path": "data-model.md",
8
+ "content": "# Lootbox data model — reference\n\nFull shape of the config (`LootboxDefinitions`), the pity/roll formulas\ntranscribed from the backend, and the reward-progression multiplier overlay.\nAll of these are **strictly typed in the SDK** — `LootboxDefinitions` and every\nnested block (`LootboxDefinition`, `LootboxPriceOption`, `LootboxRewardSlot`,\n`LootboxRewardRoll`, `LootboxAmountRange`, `LootboxPityRule`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LootboxDefinitions>(\"Lootbox\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — the cached `Lootbox.Pity` map\n- [Config: LootboxDefinitions](#config-lootboxdefinitions) — what `getDefinitions()` returns\n- [LootboxDefinition](#lootboxdefinition)\n- [Price options](#price-options)\n- [Reward slots + weighted-roll math](#reward-slots--weighted-roll-math)\n- [Pity rules + threshold math](#pity-rules--threshold-math)\n- [Catalog pre-filter (SanitizePool)](#catalog-pre-filter-sanitizepool)\n- [Reward-progression multiplier (RewardMultiplier)](#reward-progression-multiplier-rewardmultiplier)\n- [Cost scaling for count > 1](#cost-scaling-for-count--1)\n- [Open response shape](#open-response-shape)\n\n---\n\n## Player state\n\nThere is no `getUserLootboxState()` — the Lootbox module doesn't expose a\nstate-fetch method of its own. What the SDK caches locally lives at\n`client.data.user.state?.Lootbox`:\n\n```ts\ninterface UserLootboxState {\n Pity?: Record<string, UserLootboxPityCounter>; // key = `${LootboxID}:${RuleID}`\n}\ninterface UserLootboxPityCounter {\n OpensSinceLastTrigger: number; // 0..Threshold-1\n LastTriggeredAtUtc: string; // ISO timestamp, last time this rule fired\n}\n```\n\nThe SDK only **writes** this map from `open()`'s `TriggeredPity` — and only\nwhen a trigger actually happened, always resetting `OpensSinceLastTrigger` to\n`0` (`LootboxService.ts` → `ctx.data.user.applyLootboxPityTriggers`, then\n`UserData.applyLootboxPityTriggers` in `cache/UserData.ts`). It never\nincrements the counter locally on a non-triggering open. The **backend**,\nhowever, persists the true incremented counter on every single open\n(`LootboxHelpers.ComputePityApplication`, see below) — that authoritative\n`Lootbox.Pity` map comes down whole as part of `UserState` from\n`client.user.getClientState()`. Call that to get an accurate \"N opens until\npity\" countdown; don't trust the locally-patched cache for anything beyond\n\"did rule X fire, and when.\"\n\n---\n\n## Config: LootboxDefinitions\n\nReturned by `getDefinitions()` as `{ LootboxDefinitions }`; cached via\n`client.data.config.getSection<LootboxDefinitions>(\"Lootbox\")`.\n\n```ts\ninterface LootboxDefinitions {\n Definitions?: Record<string, LootboxDefinition> | null; // key = LootboxID\n}\n```\n\n---\n\n## LootboxDefinition\n\nOne box template in the title catalog (`LootboxDefinition.cs`).\n\n```ts\ninterface LootboxDefinition {\n LootboxID: string;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<string, LootboxPriceOption> | null; // key = numeric PriceOptionID as string\n RewardSlots?: LootboxRewardSlot[] | null;\n PityRules?: LootboxPityRule[] | null;\n RewardMultiplier?: RewardProgressionMultiplierSpec | null; // see below; not a separately exported type\n}\n```\n\n`PriceOptions` is keyed by the **stringified** `PriceOptionID` in the wire\nJSON (a `Dictionary<string, LootboxPriceOption>` on the backend), but you pass\n`selectedOptionID` to `open()` as a **number** — the SDK/backend do the\nstring/number bridging for you (`args.SelectedOptionID.ToString()` in\n`Lootbox.cs`'s `OpenLootbox`).\n\n---\n\n## Price options\n\n```ts\ninterface LootboxPriceOption {\n PriceOptionID?: number;\n RequiredResources?: ResourceConsume; // consume-only: what this option charges\n}\n```\n\n`RequiredResources` is a plain `ResourceConsume` (`Standard` + optional\n`PremiumDiscounts`). The backend requires at least one of: a non-empty\n`Standard.Entries`, a non-empty `Standard.EventTokens`, or a non-empty\n`PremiumDiscounts` list — an option with all three empty rejects with\n`\"Price option has empty RequiredResources.\"` (a box gated entirely behind a\n100%-off premium discount is valid: F2P players simply can't afford it and get\na normal insufficient-funds rejection).\n\n---\n\n## Reward slots + weighted-roll math\n\n```ts\ninterface LootboxRewardSlot {\n SlotID?: string; // analytics/UI/debug id, not roll logic\n MinRolls?: number;\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only: Standard.Entries / Standard.EventTokens / PremiumTiers\n Weight?: number;\n AmountRange?: LootboxAmountRange; // { Min: number; Max: number }\n}\n```\n\nPer box opened, **every slot rolls independently** (`RewardSlotHelpers.RollSlots`\nin `Services/RewardSlotHelpers.cs`):\n\n1. **Roll count** for the slot is uniform-random in `[MinRolls, MaxRolls]`\n inclusive (`min = max(0, MinRolls)`, `max = max(min, MaxRolls)`). Set\n `MinRolls = MaxRolls = 1` for a guaranteed single roll; `MinRolls = 0` to\n make the whole slot optional.\n2. Each individual roll picks **one** entry from `Pool` by weight: sum all\n `Weight` values (entries with `Weight <= 0` or no `Reward` are skipped),\n draw a uniform random integer in `[0, totalWeight)` via the platform's\n `SecureRandom`, and walk the cumulative weights to find the hit — a\n standard weighted pick, not a percentage table you need to normalize\n yourself.\n3. If the picked entry has `AmountRange`, the final `Amount` is a uniform\n random integer in `[Min, Max]` (inclusive; `max` is clamped to be `>= min`)\n and **replaces** `Amount` on every entry/token inside that roll's `Reward`\n — not just one. This is why the backend comment recommends one resource per\n `AmountRange` entry: a `Reward` with two different currencies sharing one\n `AmountRange` would apply the _same_ rolled number to both.\n4. All rolls across all slots (plus any pity rolls, see below) are merged into\n one `ResourceOperation` by summing same-key entries (same\n `Type`+`CurrencyID`/`ItemID`+`CatalogID`) and same-address event tokens.\n\nThere is no \"duplicate protection\" or per-roll independence guarantee beyond\nwhat `Pool` weights encode — two rolls in the same box can land on the same\npool entry.\n\n---\n\n## Pity rules + threshold math\n\n```ts\ninterface LootboxPityRule {\n RuleID?: string; // stable — renaming resets every player's counter\n Threshold?: number; // must be >= 1\n Pool?: LootboxRewardRoll[]; // same weighted-roll shape as a reward slot's Pool\n}\n```\n\nThis is a **plain running counter of opens**, not \"opens since last rare drop\"\n— it counts every open of this `LootboxID` regardless of what was rolled, and\nis completely independent of `RewardSlots`. Pity rewards are granted **in\naddition to** the normal slot rolls, not instead of them.\n\nPer-rule math for one `open(lootboxID, count, ...)` call\n(`LootboxHelpers.ComputePityApplication` in `Services/LootboxHelpers.cs`):\n\n```\nkey = `${lootboxID}:${RuleID}`\ncurrentCounter = cached counter for key, or 0 if absent\ntotalSteps = currentCounter + count\ntriggers = floor(totalSteps / Threshold) // how many times this rule fires\nnewCounter = totalSteps % Threshold // counter value after this open\n```\n\n- `triggers` can be **more than 1** in a single call when `count` is large\n relative to `Threshold` (e.g. opening 25 boxes against `Threshold = 10`\n starting from counter 8 triggers twice: at step 10 and step 20, ending at\n counter 3).\n- Each trigger does **one independent weighted roll** over the rule's own\n `Pool` (same algorithm as a reward slot roll, including `AmountRange`).\n- Each trigger's `BoxIndex` (0-based, into `Results`/the batch) is computed as\n `(Threshold - 1 - currentCounter) + i * Threshold` for the `i`-th trigger\n (0-based) of that rule within this call — i.e. the exact box in the batch\n that pushed the counter over the threshold. Always in `[0, count - 1]`.\n- Multiple pity rules on the same box are **fully independent**: each tracks\n its own counter under its own `key` and can trigger on different boxes\n within the same batch (e.g. a `Threshold = 10` \"bonus sticker\" rule and a\n `Threshold = 90` \"guaranteed legendary\" rule).\n- A rule with `Threshold <= 0`, no `RuleID`, or an empty `Pool` is skipped\n entirely (never triggers, never patches a counter) — treat it as\n misconfigured rather than \"always trigger\" or \"never trigger by design.\"\n\nThe counter is persisted via a Mongo patch in the **same atomic transaction**\nas the resource grant/consume (`extraPatches` passed into\n`ResourceService.ApplyResourceOperationAtomicAsync`) — a failed/insufficient-funds\nopen never advances the pity counter, and a successful open's counter update\ncan never be \"lost\" relative to the reward it unlocked.\n\n---\n\n## Catalog pre-filter (SanitizePool)\n\nBefore rolling, both `RollSlots` and pity's `RollWithWeight` **pre-filter**\neach `Pool` through the title's active item catalogs\n(`RewardSlotHelpers.SanitizePool`, shared with the Collection module's bonus\nslots):\n\n- Any `Reward.Standard.Entries` item entry that doesn't resolve in\n `ItemDefinitions.Catalogs` (via the same `ItemCatalogResolver` used\n elsewhere, strict match with fallback for a moved-catalog item) is stripped\n from that pool entry's grant.\n- If a pool entry's `Reward` has **no resource left** after stripping (no\n surviving item, currency, or event token in `Standard`, and none in any\n `PremiumTiers` bundle), the whole entry is dropped from the pool — its\n `Weight` is simply excluded from `totalWeight`, so it doesn't dilute the\n remaining valid entries and doesn't produce an empty-reward roll.\n- Currency and event-token entries are **never** stripped — only `Item`-type\n entries are checked against the catalog.\n- If no `ItemDefinitions`/`Catalogs` are supplied at all, the pool is used\n as-is (backward-compatible no-op).\n\nNet effect for you as a consumer: a stale/removed item reference in a\nlootbox's config can never crash or nullify an open — worst case, that one\nweighted slice of the pool silently stops being reachable until the config is\nfixed. You don't need to defend against \"got an empty reward\" client-side.\n\n---\n\n## Reward-progression multiplier (RewardMultiplier)\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: string; // \"BoardStageLevel\" | \"BoardRank\" | \"BoardCyclesCompleted\"\n // | \"CharacterLevel\" | \"SeasonTier\" | \"EventTokenTotalEarned\"\n // | \"VirtualCurrencyBalance\" | \"PlayerLevel\"\n SourceKey?: string; // e.g. which currency/event-token id, when Source needs a key\n CurveType?: \"Tiered\" | \"Linear\";\n Tiers?: { AtProgress?: number; Multiplier?: number }[]; // used when CurveType === \"Tiered\"\n TierMode?: \"Step\" | \"Linear\"; // interpolation between tiers\n BaseMultiplier?: number;\n PerUnit?: number; // used when CurveType === \"Linear\": multiplier grows per unit of progress\n Anchor?: number;\n MinMultiplier?: number;\n MaxMultiplier?: number;\n IncludeRewards?: ResourceBundle; // restrict which reward entries the multiplier scales\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\nThis is the same platform-wide progression-multiplier overlay used by other\nmodules (Reward's `MilestoneRewardMultiplier`, Season tier rewards, etc.) —\n**not a separate type exported from `@idosgames/core`'s public index**; it\nonly appears structurally as the `RewardMultiplier` field's type inside\n`LootboxDefinition`. Read it off the resolved definitions object, don't try to\n`import type { RewardProgressionMultiplierSpec }` directly.\n\nUnlike Reward's version, Lootbox has **no getter** for the resolved\nmultiplier — there's nothing like `getMilestoneRewardMultiplier()` here. The\nbackend evaluates it internally on every `open()` and applies it silently:\n\n- `null`/absent `RewardMultiplier` → multiplier is always `1.0`, i.e. no-op.\n- Otherwise the backend reads the player's current progress for\n `Source`/`SourceKey`, evaluates the curve (`Tiered`: step or linear\n interpolation between `Tiers` breakpoints, clamped below the first/at-or-above\n the last; `Linear`: `BaseMultiplier + PerUnit * max(0, progress - Anchor)`),\n clamps to `[MinMultiplier, MaxMultiplier]` (`MaxMultiplier <= 0` means\n unbounded above), and scales matching reward entries' `Amount` with\n ceiling-rounding (shared `ModifierService` — same rounding rule used\n platform-wide, not reimplemented per module).\n- The multiplier applies to **both** normal `RewardSlots` rolls and pity\n rewards, scaled **per box** before merging (so `Results[i]` for each box in\n a batch already reflects the multiplier). It never touches the **cost**\n (`PriceOptions`) — only what's granted.\n- `IncludeRewards`/`ExcludeRewards` let the title scope the multiplier to\n specific currencies/items/event-tokens instead of the whole grant; an empty\n `IncludeRewards` means \"everything,\" and `ExcludeRewards` wins on conflict.\n\nBecause this all happens server-side with no exposed getter, there is no\nclient-side way to preview the exact multiplier before opening — if you want\nto show \"your rewards are boosted,\" drive that off whatever domain state\nbacks `Source` (e.g. the player's board stage, character level) rather than\ntrying to recompute the curve.\n\n---\n\n## Cost scaling for count > 1\n\n`open(lootboxID, count, selectedOptionID)` charges `count` times the selected\noption's `RequiredResources.Standard`, computed by grouping+summing\n(`BuildScaledCost` in `Lootbox.cs`): every `VirtualCurrency` entry keyed by\n`CurrencyID` and every `Item` entry keyed by `(CatalogID, ItemID)` has its\n`Amount` multiplied by `count` and duplicate keys merged before charging —\n`PremiumDiscounts` are not pre-scaled here; they're applied automatically\ninside `ResourceService`'s premium-discount filtering on the final merged\ncost. `count` is clamped server-side to `[1, 100]` regardless of what you\nsend.\n\n---\n\n## Open response shape\n\n```ts\ninterface LootboxOpenResponse {\n ServerTimeUtc: string;\n LootboxID: string;\n OpenedCount?: number; // == the clamped count actually processed\n SelectedOptionID?: number;\n Resources?: ResourceOperation; // aggregated grant (all boxes + pity) and the total consume (cost)\n Results?: ResourceOperation[]; // one entry per box opened, in order; pity rewards folded into the box that triggered them\n TriggeredPity?: LootboxPityTriggerResponse[]; // null if no rule fired this call\n}\ninterface LootboxPityTriggerResponse {\n RuleID: string;\n BoxIndex?: number; // 0-based index into Results for the box that crossed the threshold\n}\n```\n\n`Results[i].Grant` is filtered per-box for the player's active premium tier\nbefore being returned (`ResourceService.FilterByPremium`), so its totals sum\nto `Resources.Grant` — both reflect what the player is actually entitled to,\nnot the raw unfiltered config. `Results[i]`'s event tokens are plain\n`EventTokenOperation` (no `Requested`/`Applied`/`NewBalance` — those only\nexist on the aggregated `Resources`), so read balances/streaks from\n`Resources`, not from `Results`.\n"
9
+ }
10
+ ]
11
+ }