@idosgames/mcp 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +20 -16
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/idle-rpg.json +5 -5
- package/registry/modules/voxelcraft.json +1 -1
- package/registry/skills/acquisition-attribution.json +6 -0
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +1 -1
- package/registry/skills/lootbox-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +1 -1
- package/registry/skills/purchase-system.json +2 -2
- package/registry/skills/referral-system.json +3 -3
- package/registry/skills/reward-system.json +1 -1
- package/registry/skills/social-system.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "game-loop-system",
|
|
3
3
|
"description": "Build a board-style core game loop on the iDosGames TypeScript SDK (@idosgames/core) via client.gameLoop (GameLoopService): roll dice around a board, attack/raid other players' or bots' cities, build up buildings, resolve Special (Instant/Timed) tile choices, and run the cooperative Community Chest group meter. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a dice/roll board loop, an attack or raid/heist mini-game, city building, survival/Special tile events, or a co-op group-progress feature — or otherwise touches client.gameLoop, GameLoopService, GameLoopModels, BoardLoopState, BoardLoopDefinition, or CommunityChest — even if they don't name the module explicitly. templates/board-game is built entirely on this module.",
|
|
4
|
-
"content": "---\nname: game-loop-system\ndescription: >-\n Build a board-style core game loop on the iDosGames TypeScript SDK\n (@idosgames/core) via client.gameLoop (GameLoopService): roll dice around a\n board, attack/raid other players' or bots' cities, build up buildings,\n resolve Special (Instant/Timed) tile choices, and run the cooperative\n Community Chest group meter. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n dice/roll board loop, an attack or raid/heist mini-game, city building,\n survival/Special tile events, or a co-op group-progress feature — or\n otherwise touches client.gameLoop, GameLoopService, GameLoopModels,\n BoardLoopState, BoardLoopDefinition, or CommunityChest — even if they don't\n name the module explicitly. templates/board-game is built entirely on this\n module.\n---\n\n# Game loop system (iDosGames TS SDK)\n\nThe GameLoop module ships a board-style core loop: a player rolls dice, moves\naround a ring of tiles, and lands on tiles that trigger attacking another\nplayer's (or a bot's) city, raiding a heist grid for bonuses, building up their\nown buildings, or a special timed/instant reward choice. A separate, related\nfeature bundled in the same module — **Community Chest** — is a cooperative\ngroup meter: players join a small group, every board roll contributes points to\none shared progress bar, and the group claims milestone/grand-prize rewards\ntogether. They share one config root and one player-state root\n(`GameLoop.Board` / `GameLoop.CommunityChest`) but otherwise don't interact.\n\nEverything is **server-authoritative**: the client asks the backend to\nroll/attack/raid/build/choose/claim, the backend validates and resolves it, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever compute outcomes (dice results, bot behavior, raid layouts, rewards)\nyourself — you call a method, check the result, and render from the cache.\n\nThis skill is for **using** the production `GameLoopService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(no pending interaction, wrong stage, cooldown, insufficient funds) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Mental model\n\n**Board loop** — one state machine per player:\n\n1. `boardLoopRoll` moves the player's token and returns what tile they landed\n on. The response may carry `ActionRequired` (`\"ATTACK\"` or `\"RAID\"`) or a\n `SpecialModeOffer` — when present, the SDK stashes it as `Board.Pending` and\n the UI must resolve it before the player can roll again.\n2. Depending on `Pending.Type`, the UI drives one of: `boardLoopAttack`,\n `boardLoopRaid` / `boardLoopRaidFast`, or `boardSpecialChoose` →\n (`boardSpecialApplyMultiplier`) → `boardSpecialClaim`.\n3. `boardLoopBuild` is independent of rolling — any time a building slot exists\n and the player can afford the upgrade, they can build. Reaching every\n building's `MaxLevel` on the current stage returns `StageComplete`, which\n resets `Position` to 0 and bumps `StageLevel`. The board is **infinite** —\n stages past the title's authored content are procedurally synthesized\n server-side (visuals cycle, economy scales via the stage's `Unit(N)`), so\n there's no \"last stage\" a player can actually reach; don't build UI around\n an end state (see Gotchas).\n\n**Community chest** — a small, mostly independent co-op side-feature:\n\n1. `getCommunityChestState` tells the player if they're in an active/forming\n group and how much time is left in the round.\n2. `joinOrCreateCommunityChest` puts them in a group (existing or new).\n3. Regular `boardLoopRoll` calls (not a separate action) contribute points to\n the group's shared meter — read `BoardRollResponse.CommunityChestContribution`\n for the delta/unlocked milestones on each roll.\n4. `claimCommunityChestMilestone` / `claimCommunityChestGrandPrize` pull\n rewards once thresholds are hit; `leaveCommunityChest` exits early.\n\nFor the full field-by-field shape of both sub-systems (stage/tile config,\nheist raid variants, Special gradation tiers, Community Chest group document),\nread [references/data-model.md](references/data-model.md). You do **not** need\nit to call the methods — only to drive richer UI off the config (tile art,\nbuilding names, milestone thresholds).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst gameLoop = client.gameLoop; // the GameLoopService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods — board loop\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. \"No pending interaction\", \"Raid already in\nprogress\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------- |\n| `getGameLoops()` | Load the root GameLoop config (Board + CommunityChest configs). | `GameLoopDefinitions` |\n| `getBoardDefinition(silent?)` | Load the current-stage board config (tiles, buildings, economy). | `BoardLoopDefinition` |\n| `getBoardDefinitionForLevel(stageLevel, silent?)` | Load the board config for a specific stage (e.g. to preview). | `BoardLoopDefinition` |\n| `getUserBoardState()` | Load this player's board state (position, buildings, pending). | `BoardLoopState` |\n| `boardLoopRoll(rollMultiplier?)` | Roll dice and move the token (default multiplier 1). | `BoardRollResponse` (`NewPosition`, `Steps`, `DiceValues`) |\n| `boardLoopAttack(buildingIndex?)` | Resolve a pending ATTACK (pass `-1` or omit for auto-target). | `AttackResponse` (`Outcome`, `Operation`) |\n| `boardLoopRaid(digIndex, existingRelatedEntityID?)` | Dig one heist cell (Sequential raid mode), `digIndex` 0-11. | `RaidResponse` (`Status`, `FoundBonus`) |\n| `boardLoopRaidFast(digIndices)` | Submit a batch of opened cell indices (Fast raid mode). | `RaidResponse` |\n| `boardLoopBuild(buildingIndex)` | Upgrade one building a level. | `BuildResponse` (`NewLevel`, `StageComplete`) |\n| `boardSpecialChoose(choiceID)` | Pick a Special-tile choice (Instant or Timed). | `SpecialChooseResponse` (`Mode`) |\n| `boardSpecialApplyMultiplier(existingRelatedEntityID?)` | Apply one ad-view multiplier to a pending Timed Special. | `SpecialApplyMultiplierResponse` |\n| `boardSpecialClaim()` | Claim the pending Special reward (early or after the window). | `SpecialClaimResponse` (`FinalMultiplier`) |\n\n## Methods — community chest\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------- |\n| `getCommunityChestState()` | This player's chest state (active group ref + seconds remaining). | `CommunityChestUserStateResponse` |\n| `joinOrCreateCommunityChest()` | Join an open group, or create one if none is forming. | `CommunityChestGroupStateResponse` |\n| `claimCommunityChestMilestone(milestoneID, groupID?)` | Claim one reached-but-unclaimed milestone reward. | `CommunityChestClaimResponse` |\n| `claimCommunityChestGrandPrize(groupID?)` | Claim the Grand Prize once the shared meter is filled. | `CommunityChestClaimResponse` |\n| `leaveCommunityChest(groupID?)` | Leave the currently-active group. | `CommunityChestLeaveResponse` (`Success`) |\n\n`groupID` is optional on the claim/leave calls — omit it to target the\nplayer's current active group.\n\nOn success, every method above mirrors the confirmed change into the cache and\nemits an event — you don't apply anything by hand. Granted/consumed resources\nride along in `data.Operation` (board methods) or `data.Resources` (chest\nclaims) and are already applied to the cached currency/item balances.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Board state (only present after getUserBoardState() or a board action):\nconst board = client.data.user.state?.GameLoop?.Board;\nboard?.StageLevel; // current stage\nboard?.Position; // tile index on the ring\nboard?.BuildingStates; // [{ SlotIndex, Level, IsDamaged, MaxLevelRewardClaimed }]\nboard?.Pending; // non-null while an ATTACK/RAID/SPECIAL is unresolved\nboard?.CyclesCompleted; // full loops of the ring\nboard?.SpecialStats; // lifetime Special-mode counters\n\n// Community Chest state:\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\nchest?.ActiveGroupID;\nchest?.ActiveRoundIndex;\nchest?.History; // recent completed rounds\n\n// Config (two separate cache sections):\nimport type { GameLoopDefinitions, BoardLoopDefinition } from \"@idosgames/core\";\nconst gameLoopCfg =\n client.data.config.getSection<GameLoopDefinitions>(\"GameLoop\"); // CommunityChest config lives here\nconst boardCfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\"); // current-stage board config\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `gameLoop:gameLoopsLoaded` → `GameLoopDefinitions`\n- `gameLoop:boardDefinitionLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardDefinitionForLevelLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardStateLoaded` → `BoardLoopState`\n- `gameLoop:boardRolled` → `BoardRollResponse`\n- `gameLoop:boardAttacked` → `AttackResponse`\n- `gameLoop:boardRaided` → `RaidResponse`\n- `gameLoop:boardRaidedFast` → `RaidResponse`\n- `gameLoop:boardBuilt` → `BuildResponse`\n- `gameLoop:boardSpecialChose` → `SpecialChooseResponse`\n- `gameLoop:boardSpecialApplyMultiplier` → `SpecialApplyMultiplierResponse`\n- `gameLoop:boardSpecialClaimed` → `SpecialClaimResponse`\n- `gameLoop:communityChestStateLoaded` → `CommunityChestUserStateResponse`\n- `gameLoop:communityChestJoined` → `CommunityChestGroupStateResponse`\n- `gameLoop:communityChestMilestoneClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestGrandPrizeClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestLeft` → `CommunityChestLeaveResponse`\n\nThe coarse `user:gameLoopUpdated` (+ umbrella `user:anyUpdated`) also fires on\nevery board or chest cache write — handy for a \"re-render everything\" hook,\nand what `templates/board-game` actually uses (`useBoardState()` subscribes to\n`user:anyUpdated` and reads `client.data.user.state?.GameLoop?.Board`).\n\n```ts\nconst off = client.on(\"gameLoop:boardRolled\", (r) => {\n console.log(`landed on ${r.NewPosition} (${r.LandedTileType})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the board and render it\n\n```ts\nawait client.gameLoop.getBoardDefinition();\nawait client.gameLoop.getUserBoardState();\n\nconst cfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\");\nconst board = client.data.user.state?.GameLoop?.Board;\n\nconst stage = board?.StageLevel ?? 1;\nconst stageDef = cfg?.StagesByLevel?.[String(stage)];\nconst template = cfg?.BoardTemplatesByID?.[stageDef?.BoardTemplateID ?? \"\"];\n// walk template.Tiles (keyed by string index) to lay out the ring;\n// render token at board.Position, buildings from board.BuildingStates.\n```\n\n`getBoardDefinition()` returns the config for the player's **current** stage;\npass `silent: true` to suppress whatever loading UI/telemetry the platform\nadapter would otherwise trigger for a background refresh. Use\n`getBoardDefinitionForLevel(n)` to preview a different stage's config (e.g. a\n\"next stage preview\" screen) without touching the player's actual stage.\n\n### Roll, then resolve whatever comes up\n\n```ts\nconst roll = await client.gameLoop.boardLoopRoll(1);\nif (!roll.ok) return showError(roll.error);\n\nconst board = client.data.user.state?.GameLoop?.Board;\nswitch (board?.Pending?.Type) {\n case \"ATTACK\":\n // render AttackPanel from board.Pending.TargetUserID / TargetBuildingStates\n break;\n case \"RAID\":\n // render RaidPanel from board.Pending.RaidLayout\n break;\n case \"SPECIAL\":\n // render SpecialPanel from board.Pending.Special\n break;\n default:\n // no pending interaction — landed on a plain/chance/economy tile,\n // roll.data.Operation already applied to cached balances.\n break;\n}\n```\n\nAnimate the dice from `DiceValues` — the per-die faces the server actually\nrolled, one entry per `Dice.Count`, each `1..Dice.Sides`, summing to `Steps`.\nDo not split `Steps` into faces yourself: the split is ambiguous, and a\ntutorial-scripted step may not be expressible as dice at all (it can be any\nvalue up to a full lap). `DiceValues` is `null` exactly in that scripted case —\nmove the token by `Steps` and skip the dice animation.\n\n`rollMultiplier` does **not** change how far the token moves — the dice roll\n(`Steps`) is always the sum of the configured dice regardless of multiplier.\nThe multiplier only scales (a) the dice cost — one unit of `RollCurrencyID`\nper multiplier step, so `x3` costs 3 dice in one call — and (b) the size of\nwhatever reward the landed tile/pass-start grants. It must be one of\n`BoardLoopDefinition.AllowedRollMultipliers`, and must not exceed the stage's\n`MaxRollMultiplier` (further capped by a per-player override the backend may\napply) — violating either is rejected with a specific message (\"Multiplier\nx{N} is not allowed…\" / \"…exceeds the maximum x{M} for this stage\") rather\nthan silently clamped or rounded down. Read `AllowedRollMultipliers` to build\na multiplier picker; \"Not enough dice\" if the balance can't cover the\nrequested multiplier.\n\n`Pending` has a server TTL (`ExpiresAtUtc`). For ATTACK/RAID this is a fixed\n15 minutes from the roll (mirrored client-side with the same default). For\nSPECIAL it is **not** 15 minutes — it's `max(OfferExpireSeconds, longest\nchoice DurationSeconds + ClaimExpireSeconds)`, i.e. long enough to cover\nchoosing, playing out the longest Timed window, and claiming afterward. Either\nway, an expired pending interaction is rejected by the backend on the next\naction call (\"No active ATTACK\"/\"No active RAID\"/\"No active SPECIAL pending\"),\nso re-fetch `getUserBoardState()` on a stale-pending error rather than\ntrusting the local clock alone.\n\n### Resolve an ATTACK\n\n```ts\nconst targets = board?.Pending?.TargetBuildingStates ?? [];\n// -1 (or omit) lets the server auto-pick a target building.\nconst atk = await client.gameLoop.boardLoopAttack(targets[0]?.SlotIndex ?? -1);\nif (!atk.ok) return showError(atk.error);\natk.data.Outcome; // \"Hit\" | \"Blocked\" (target had a shield)\natk.data.IsBotTarget; // bot fights settle Operation locally\n// PvP fights settle via atk.data.DualResult (both sides' resource deltas) —\n// only the caller's own side (FromResult) is applied to this client's cache.\n```\n\nAttacking always clears `Pending`. If the call fails (`reason: \"server\"` or\n`\"connection\"`), the SDK automatically re-fetches `getUserBoardState()` to\nreconcile — don't also call it yourself in the error branch.\n\n### Resolve a RAID (both modes)\n\nSequential (`RaidMode: \"Sequential\"` — one dig per call):\n\n```ts\nconst dig = await client.gameLoop.boardLoopRaid(digIndex); // 0-11\nif (!dig.ok) return showError(dig.error);\nif (dig.data.Status === \"CONTINUE\") {\n // board.Pending.RaidLayout / OpenedIndices updated in cache; dig again.\n} else {\n // Finished — Status is \"FINISHED_SMALL\" | \"FINISHED_MEDIUM\" | \"FINISHED_BIG\"\n // | \"FINISHED_JACKPOT\" (never the literal \"Complete\"). Pending cleared,\n // reward already applied. dig.data.Outcome carries the same tier as an enum\n // string (\"Small\"/\"Medium\"/\"Big\"/\"Jackpot\").\n}\n```\n\nFast (`RaidMode: \"Fast\"` — client reveals cells from the pre-sent layout\nlocally, then submits the full opened set once a match is found):\n\n```ts\nconst openedSoFar = [...(board?.Pending?.OpenedIndices ?? []), newIndex];\nconst res = await client.gameLoop.boardLoopRaidFast(openedSoFar);\n```\n\n`digIndex` must be 0-11 (a fixed 12-cell grid, always shuffled 4×Small/4×Medium/\n4×Big unless a jackpot variant overrides the symbol mix); `boardLoopRaidFast`\nrejects an empty or duplicate-containing `digIndices` array client-side. Both\nraid methods reject if the title's `RaidMode` doesn't match (calling\n`boardLoopRaid` on a `\"Fast\"`-configured board fails with \"Use\nBoardLoopRaidFast for this board\", and vice versa \"Use BoardLoopRaid for this\nboard\") — read `BoardLoopDefinition.RaidMode` once and call the matching\nmethod, don't let the UI offer both. On a non-`\"CONTINUE\"` `Status` both raid\ncalls clear `Pending` and apply the reward the same way (`Operation`, falling\nback to `DualResult.FromResult` for PvP-style raids). Matching 3 of a kind\nbefore all 12 cells are opened ends the raid immediately — remaining cells are\nsimply never revealed.\n\n### Special tile: choose, optionally boost with an ad, claim\n\n`ChoiceID` is a config-defined id from the offer (`SpecialModeOffer.Choices[].ChoiceID`,\ne.g. `\"SmallCash\"`/`\"BigCashTimed\"`) — **not** the literal string `\"Instant\"`/\n`\"Timed\"`. Render the offer's choices and pass whichever `ChoiceID` the player\npicked:\n\n```ts\nconst board = client.data.user.state?.GameLoop?.Board;\nconst offerChoices = board?.Pending?.Special?.Choices ?? []; // stashed from the roll response\nconst picked = offerChoices[0]; // whatever the player tapped\n\nconst choice = await client.gameLoop.boardSpecialChoose(picked.ChoiceID);\nif (!choice.ok) return showError(choice.error);\n\nif (choice.data.Mode === \"Instant\" || choice.data.Mode === 0) {\n // reward already granted and Pending cleared — nothing else to do.\n} else {\n // Timed: a countdown window is now open (board.Pending.Special.DurationSeconds).\n // Optionally boost the payout with rewarded ads before the window closes:\n const boosted = await client.gameLoop.boardSpecialApplyMultiplier();\n if (boosted.ok) console.log(boosted.data.AccumulatedMultiplier);\n\n // Claim any time — early claim may forgo the ad multiplier and any\n // gradation tier not yet reached:\n const claim = await client.gameLoop.boardSpecialClaim();\n claim.data?.IsEarlyClaim; // true if claimed before the first gradation tier's threshold\n}\n```\n\n`Mode` can come back as either the string (`\"Instant\"`/`\"Timed\"`) or its\nnumeric enum value (`0`/`1`) — check both, as the templates do\n(`data.Mode === \"Timed\" || data.Mode === 1`). A choice can only be committed\nonce per pending SPECIAL (\"Choice already committed\" on a repeat call), and a\nTimed choice is rejected server-side unless its config sets a non-empty\ngradation ladder (\"Timed choice requires a non-empty Gradation ladder\") — this\nis a content-authoring constraint, not something the client can work around.\n\n`boardSpecialApplyMultiplier` can be called multiple times up to the choice's\n`Multipliers.MaxAdViews` (\"Max ad views reached\" past the cap) and only while\nthe Timed window is still open (\"Play window already closed\"); each call rolls\none multiplier step uniformly from `PerAdMultiplierRange` and folds it into\n`AccumulatedMultiplier` per `FormulaKind` — `\"Additive\"` (default) sums the\nrolled steps (final reward multiplier = `1 + AccumulatedMultiplier`),\n`\"Multiplicative\"` multiplies them together (final multiplier =\n`AccumulatedMultiplier` itself, floored at 1.0). Read\n`AccumulatedMultiplier`/`RemainingAdViews` to gate the \"watch another ad\"\nbutton. The reward itself is only computed and paid at `boardSpecialClaim`\ntime — `boardSpecialApplyMultiplier` never grants anything by itself, it just\nrecords the roll.\n\n### Build\n\n```ts\nconst build = await client.gameLoop.boardLoopBuild(slotIndex);\nif (!build.ok) return showError(build.error); // e.g. can't afford, already maxed\nif (build.data.StageComplete) {\n // board.StageLevel bumped, Position reset to 0, BuildingStates cleared —\n // re-fetch getBoardDefinition() for the new stage's config.\n} else {\n // board.BuildingStates[slotIndex].Level bumped in cache already.\n}\n```\n\nBuilding is independent of the roll/pending flow — it can be done any time a\nbuildable slot exists, whether or not `Pending` is set.\n\n### Community Chest: join, contribute via rolling, claim\n\n```ts\nawait client.gameLoop.getCommunityChestState();\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\n\nif (!chest?.ActiveGroupID) {\n const joined = await client.gameLoop.joinOrCreateCommunityChest();\n if (!joined.ok) return showError(joined.error);\n}\n\n// Contribution happens as a side effect of normal rolling — not a separate call:\nconst roll = await client.gameLoop.boardLoopRoll(1);\nconst contribution = roll.data?.CommunityChestContribution;\nif (contribution?.UnlockedMilestoneIDs?.length) {\n // show \"milestone unlocked\" toast(s) for each id\n}\nif (contribution?.Completed) {\n // shared meter hit MaxProgress — Grand Prize is now claimable for the group.\n}\n\nfor (const milestoneID of contribution?.UnlockedMilestoneIDs ?? []) {\n const claim = await client.gameLoop.claimCommunityChestMilestone(milestoneID);\n if (!claim.ok) console.warn(claim.error); // e.g. already claimed by a race\n}\n```\n\n`joinOrCreateCommunityChest` is idempotent from the caller's perspective — if\nthe player is already in a group in the current round it just returns that\ngroup rather than erroring or creating a duplicate. `claimCommunityChestGrandPrize()`\nonly succeeds once the group's `Status` is `\"Completed\"` (meter filled to\n`MaxProgress`) — checking `contribution.Completed` client-side is a UI\nshortcut, the server re-checks `Status` itself; each member claims\nindividually via their own `GrandPrizeClaimed` flag, so one member's claim\nnever claims it for the whole group. If matchmaking can't find or fill an open\ngroup after a few retries, `joinOrCreateCommunityChest` fails with\n\"Matchmaking failed after retries. Please try again.\" — a plain retry from the\nUI is the right recovery, not a special code path.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Roll\"/\"Attack\" can duplicate. Disable the control while a\n call is in flight. (Firing the same endpoint again within the throttle\n window, default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **`boardLoopRaidFast` only updates the cache on completion.** Unlike\n sequential `boardLoopRaid` (which patches `Pending.RaidLayout`/\n `OpenedIndices` on every `CONTINUE`), a `CONTINUE` response from\n `boardLoopRaidFast` does **not** get mirrored into the cache at all — Fast\n mode is designed so the client already knows the full layout locally and\n only calls the server once, on the winning submission. Don't expect\n `Pending.OpenedIndices` to reflect fast-mode digs mid-game; track opened\n cells in local component state instead (see `templates/board-game`'s\n `RaidPanel`).\n- **On a failed board action, the SDK self-heals by re-fetching state** —\n `boardLoopAttack`, `boardLoopRaid`, and `boardLoopRaidFast` all call\n `getUserBoardState()` automatically when their result is not `ok`. Don't\n duplicate that call in your error handler; just show `result.error`.\n- **Two separate config cache sections.** `getGameLoops()` caches under\n `\"GameLoop\"` (root `GameLoopDefinitions`, including the `CommunityChest`\n config); `getBoardDefinition()`/`getBoardDefinitionForLevel()` cache under\n `\"BoardDefinition\"` (a `BoardLoopDefinition`, i.e. just the board half). If\n you only need Community Chest config, `getGameLoops()` alone is enough — you\n don't need to also load the board.\n- **PvP resource deltas are two-sided.** Attack/raid against a real player\n return `DualResult` with `FromResult`/`ToResult`; only `FromResult` (this\n caller's own delta) is ever applied to the local cache — you cannot see or\n apply the opponent's side from this client, nor should you.\n- **`Mode`/`ChosenMode` on Special responses can be string or numeric enum.**\n Compare against both the string literal and its ordinal (`0`/`1`) as shown\n in the recipes — the wire format isn't fully normalized to strings.\n- **The board has no real end state.** Stages past whatever the title\n authored in `StagesByLevel` are synthesized server-side on demand (visuals\n cycle through `ProceduralEconomy.VisualTemplateCycle`, economy scales via the\n stage's `Unit(N)`) — a player can never actually run out of stages to build\n through. `BoardLoopState.AllStagesCompleted` is defined in the SDK's types\n but the backend never sets it; don't build a \"you beat the game\" screen\n around it.\n- **Attack shields are consumed, not just checked.** A `Blocked` outcome costs\n the defender exactly one unit of `ShieldCurrencyID` (server-side, dual-party\n transaction) — it isn't a passive flag. A bot target's shield is a\n per-roll coin flip (`Bots.ShieldChance`) that only affects that one\n interaction, not a persisted balance.\n- **`SpecialClaimResponse` carries a `ReachedTierIndex` the SDK doesn't type\n yet.** The backend returns which gradation tier was actually paid out\n (`-1` = the below-first-tier reward, `>=0` = index into the choice's\n `Gradation.Tiers`), but the current `@idosgames/core` response type doesn't\n declare that field — it still round-trips (schemas keep `.passthrough()`)\n but reading it requires an `as any`/loose cast until the SDK catches up.\n- **`CommunityChestDefinition.MemberGracePeriodMinutes` is config-only today.**\n Nothing in the backend currently reads it — a departed member's slot is\n _not_ auto-backfilled with a bot; only a still-`\"Forming\"` group gets\n bot-filled, and only after `MatchmakingTimeoutMinutes` elapses. Don't build\n UI that promises a grace-period replacement.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield for both the board loop and Community Chest: stage/tile/building config,\nprocedural economy knobs, heist raid variants, Special gradation tiers, and\nthe Community Chest group document. Read it when building config-driven UI\n(tile art, reward previews, milestone bars) or when an error message points at\na config rule you need to understand.\n",
|
|
4
|
+
"content": "---\nname: game-loop-system\ndescription: >-\n Build a board-style core game loop on the iDosGames TypeScript SDK\n (@idosgames/core) via client.gameLoop (GameLoopService): roll dice around a\n board, attack/raid other players' or bots' cities, build up buildings,\n resolve Special (Instant/Timed) tile choices, and run the cooperative\n Community Chest group meter. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n dice/roll board loop, an attack or raid/heist mini-game, city building,\n survival/Special tile events, or a co-op group-progress feature — or\n otherwise touches client.gameLoop, GameLoopService, GameLoopModels,\n BoardLoopState, BoardLoopDefinition, or CommunityChest — even if they don't\n name the module explicitly. templates/board-game is built entirely on this\n module.\n---\n\n# Game loop system (iDosGames TS SDK)\n\nThe GameLoop module ships a board-style core loop: a player rolls dice, moves\naround a ring of tiles, and lands on tiles that trigger attacking another\nplayer's (or a bot's) city, raiding a heist grid for bonuses, building up their\nown buildings, or a special timed/instant reward choice. A separate, related\nfeature bundled in the same module — **Community Chest** — is a cooperative\ngroup meter: players join a small group, every board roll contributes points to\none shared progress bar, and the group claims milestone/grand-prize rewards\ntogether. They share one config root and one player-state root\n(`GameLoop.Board` / `GameLoop.CommunityChest`) but otherwise don't interact.\n\nEverything is **server-authoritative**: the client asks the backend to\nroll/attack/raid/build/choose/claim, the backend validates and resolves it, and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever compute outcomes (dice results, bot behavior, raid layouts, rewards)\nyourself — you call a method, check the result, and render from the cache.\n\nThis skill is for **using** the production `GameLoopService`, not for porting\nor extending it. If a call is rejected, that's the backend enforcing a rule\n(no pending interaction, wrong stage, cooldown, insufficient funds) — surface\nthe error, don't try to reproduce the check client-side.\n\n## Mental model\n\n**Board loop** — one state machine per player:\n\n1. `boardLoopRoll` moves the player's token and returns what tile they landed\n on. The response may carry `ActionRequired` (`\"ATTACK\"` or `\"RAID\"`) or a\n `SpecialModeOffer` — when present, the SDK stashes it as `Board.Pending` and\n the UI must resolve it before the player can roll again.\n2. Depending on `Pending.Type`, the UI drives one of: `boardLoopAttack`,\n `boardLoopRaid` / `boardLoopRaidFast`, or `boardSpecialChoose` →\n (`boardSpecialApplyMultiplier`) → `boardSpecialClaim`.\n3. `boardLoopBuild` is independent of rolling — any time a building slot exists\n and the player can afford the upgrade, they can build. Reaching every\n building's `MaxLevel` on the current stage returns `StageComplete`, which\n resets `Position` to 0 and bumps `StageLevel`. The board is **infinite** —\n stages past the title's authored content are procedurally synthesized\n server-side (visuals cycle, economy scales via the stage's `Unit(N)`), so\n there's no \"last stage\" a player can actually reach; don't build UI around\n an end state (see Gotchas).\n\n**Community chest** — a small, mostly independent co-op side-feature:\n\n1. `getCommunityChestState` tells the player if they're in an active/forming\n group and how much time is left in the round.\n2. `joinOrCreateCommunityChest` puts them in a group (existing or new).\n3. Regular `boardLoopRoll` calls (not a separate action) contribute points to\n the group's shared meter — read `BoardRollResponse.CommunityChestContribution`\n for the delta/unlocked milestones on each roll.\n4. `claimCommunityChestMilestone` / `claimCommunityChestGrandPrize` pull\n rewards once thresholds are hit; `leaveCommunityChest` exits early.\n\nFor the full field-by-field shape of both sub-systems (stage/tile config,\nheist raid variants, Special gradation tiers, Community Chest group document),\nread [references/data-model.md](references/data-model.md). You do **not** need\nit to call the methods — only to drive richer UI off the config (tile art,\nbuilding names, milestone thresholds).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst gameLoop = client.gameLoop; // the GameLoopService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods — board loop\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\nthrottle window), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (backend rejected it — `error` carries\nthe human-readable reason, e.g. \"No pending interaction\", \"Raid already in\nprogress\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------- |\n| `getGameLoops()` | Load the root GameLoop config (Board + CommunityChest configs). | `GameLoopDefinitions` |\n| `getBoardDefinition(silent?)` | Load the current-stage board config (tiles, buildings, economy). | `BoardLoopDefinition` |\n| `getBoardDefinitionForLevel(stageLevel, silent?)` | Load the board config for a specific stage (e.g. to preview). | `BoardLoopDefinition` |\n| `getUserBoardState()` | Load this player's board state (position, buildings, pending). | `BoardLoopState` |\n| `boardLoopRoll(rollMultiplier?)` | Roll dice and move the token (default multiplier 1). | `BoardRollResponse` (`NewPosition`, `Steps`, `DiceValues`) |\n| `boardLoopAttack(buildingIndex?)` | Resolve a pending ATTACK (pass `-1` or omit for auto-target). | `AttackResponse` (`Outcome`, `Operation`) |\n| `boardLoopRaid(digIndex, existingRelatedEntityID?)` | Dig one heist cell (Sequential raid mode), `digIndex` 0-11. | `RaidResponse` (`Status`, `FoundBonus`) |\n| `boardLoopRaidFast(digIndices)` | Submit a batch of opened cell indices (Fast raid mode). | `RaidResponse` |\n| `boardLoopBuild(buildingIndex)` | Upgrade one building a level. | `BuildResponse` (`NewLevel`, `StageComplete`) |\n| `boardSpecialChoose(choiceID)` | Pick a Special-tile choice (Instant or Timed). | `SpecialChooseResponse` (`Mode`) |\n| `boardSpecialApplyMultiplier(existingRelatedEntityID?)` | Apply one ad-view multiplier to a pending Timed Special. | `SpecialApplyMultiplierResponse` |\n| `boardSpecialClaim()` | Claim the pending Special reward (early or after the window). | `SpecialClaimResponse` (`FinalMultiplier`) |\n\n## Methods — community chest\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------- |\n| `getCommunityChestState()` | This player's chest state (active group ref + seconds remaining). | `CommunityChestUserStateResponse` |\n| `joinOrCreateCommunityChest()` | Join an open group, or create one if none is forming. | `CommunityChestGroupStateResponse` |\n| `claimCommunityChestMilestone(milestoneID, groupID?)` | Claim one reached-but-unclaimed milestone reward. | `CommunityChestClaimResponse` |\n| `claimCommunityChestGrandPrize(groupID?)` | Claim the Grand Prize once the shared meter is filled. | `CommunityChestClaimResponse` |\n| `leaveCommunityChest(groupID?)` | Leave the currently-active group. | `CommunityChestLeaveResponse` (`Success`) |\n\n`groupID` is optional on the claim/leave calls — omit it to target the\nplayer's current active group.\n\nOn success, every method above mirrors the confirmed change into the cache and\nemits an event — you don't apply anything by hand. Granted/consumed resources\nride along in `data.Operation` (board methods) or `data.Resources` (chest\nclaims) and are already applied to the cached currency/item balances.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\n// Board state (only present after getUserBoardState() or a board action):\nconst board = client.data.user.state?.GameLoop?.Board;\nboard?.StageLevel; // current stage\nboard?.Position; // tile index on the ring\nboard?.BuildingStates; // [{ SlotIndex, Level, IsDamaged, MaxLevelRewardClaimed }]\nboard?.Pending; // non-null while an ATTACK/RAID/SPECIAL is unresolved\nboard?.CyclesCompleted; // full loops of the ring\nboard?.SpecialStats; // lifetime Special-mode counters\n\n// Community Chest state:\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\nchest?.ActiveGroupID;\nchest?.ActiveRoundIndex;\nchest?.History; // recent completed rounds\n\n// Config (two separate cache sections):\nimport type { GameLoopDefinitions, BoardLoopDefinition } from \"@idosgames/core\";\nconst gameLoopCfg =\n client.data.config.getSection<GameLoopDefinitions>(\"GameLoop\"); // CommunityChest config lives here\nconst boardCfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\"); // current-stage board config\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `gameLoop:gameLoopsLoaded` → `GameLoopDefinitions`\n- `gameLoop:boardDefinitionLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardDefinitionForLevelLoaded` → `BoardLoopDefinition`\n- `gameLoop:boardStateLoaded` → `BoardLoopState`\n- `gameLoop:boardRolled` → `BoardRollResponse`\n- `gameLoop:boardAttacked` → `AttackResponse`\n- `gameLoop:boardRaided` → `RaidResponse`\n- `gameLoop:boardRaidedFast` → `RaidResponse`\n- `gameLoop:boardBuilt` → `BuildResponse`\n- `gameLoop:boardSpecialChose` → `SpecialChooseResponse`\n- `gameLoop:boardSpecialApplyMultiplier` → `SpecialApplyMultiplierResponse`\n- `gameLoop:boardSpecialClaimed` → `SpecialClaimResponse`\n- `gameLoop:communityChestStateLoaded` → `CommunityChestUserStateResponse`\n- `gameLoop:communityChestJoined` → `CommunityChestGroupStateResponse`\n- `gameLoop:communityChestMilestoneClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestGrandPrizeClaimed` → `CommunityChestClaimResponse`\n- `gameLoop:communityChestLeft` → `CommunityChestLeaveResponse`\n\nThe coarse `user:gameLoopUpdated` (+ umbrella `user:anyUpdated`) also fires on\nevery board or chest cache write — handy for a \"re-render everything\" hook,\nand what `templates/board-game` actually uses (`useBoardState()` subscribes to\n`user:anyUpdated` and reads `client.data.user.state?.GameLoop?.Board`).\n\n```ts\nconst off = client.on(\"gameLoop:boardRolled\", (r) => {\n console.log(`landed on ${r.NewPosition} (${r.LandedTileType})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the board and render it\n\n```ts\nawait client.gameLoop.getBoardDefinition();\nawait client.gameLoop.getUserBoardState();\n\nconst cfg =\n client.data.config.getSection<BoardLoopDefinition>(\"BoardDefinition\");\nconst board = client.data.user.state?.GameLoop?.Board;\n\nconst stage = board?.StageLevel ?? 1;\nconst stageDef = cfg?.StagesByLevel?.[String(stage)];\nconst template = cfg?.BoardTemplatesByID?.[stageDef?.BoardTemplateID ?? \"\"];\n// walk template.Tiles (keyed by string index) to lay out the ring;\n// render token at board.Position, buildings from board.BuildingStates.\n```\n\n`getBoardDefinition()` returns the config for the player's **current** stage;\npass `silent: true` to suppress whatever loading UI/telemetry the platform\nadapter would otherwise trigger for a background refresh. Use\n`getBoardDefinitionForLevel(n)` to preview a different stage's config (e.g. a\n\"next stage preview\" screen) without touching the player's actual stage.\n\n### Roll, then resolve whatever comes up\n\n```ts\nconst roll = await client.gameLoop.boardLoopRoll(1);\nif (!roll.ok) return showError(roll.error);\n\nconst board = client.data.user.state?.GameLoop?.Board;\nswitch (board?.Pending?.Type) {\n case \"ATTACK\":\n // render AttackPanel from board.Pending.TargetUserID / TargetBuildingStates\n break;\n case \"RAID\":\n // render RaidPanel from board.Pending.RaidLayout\n break;\n case \"SPECIAL\":\n // render SpecialPanel from board.Pending.Special\n break;\n default:\n // no pending interaction — landed on a plain/chance/economy tile,\n // roll.data.Operation already applied to cached balances.\n break;\n}\n```\n\nAnimate the dice from `DiceValues` — the per-die faces the server actually\nrolled, one entry per `Dice.Count`, each `1..Dice.Sides`, summing to `Steps`.\nDo not split `Steps` into faces yourself: the split is ambiguous, and a\ntutorial-scripted step may not be expressible as dice at all (it can be any\nvalue up to a full lap). `DiceValues` is `null` exactly in that scripted case —\nmove the token by `Steps` and skip the dice animation.\n\n`rollMultiplier` does **not** change how far the token moves — the dice roll\n(`Steps`) is always the sum of the configured dice regardless of multiplier.\nThe multiplier only scales (a) the dice cost — one unit of `RollCurrencyID`\nper multiplier step, so `x3` costs 3 dice in one call — and (b) the size of\nwhatever reward the landed tile/pass-start grants. It must be one of\n`BoardLoopDefinition.AllowedRollMultipliers`, and must not exceed the stage's\n`MaxRollMultiplier` (further capped by a per-player override the backend may\napply) — violating either is rejected with a specific message (\"Multiplier\nx{N} is not allowed…\" / \"…exceeds the maximum x{M} for this stage\") rather\nthan silently clamped or rounded down. Read `AllowedRollMultipliers` to build\na multiplier picker; \"Not enough dice\" if the balance can't cover the\nrequested multiplier.\n\n`Pending` has a server TTL (`ExpiresAtUtc`). For ATTACK/RAID this is a fixed\n15 minutes from the roll (mirrored client-side with the same default). For\nSPECIAL it is **not** 15 minutes — it's `max(OfferExpireSeconds, longest\nchoice DurationSeconds + ClaimExpireSeconds)`, i.e. long enough to cover\nchoosing, playing out the longest Timed window, and claiming afterward. Either\nway, an expired pending interaction is rejected by the backend on the next\naction call (\"No active ATTACK\"/\"No active RAID\"/\"No active SPECIAL pending\"),\nso re-fetch `getUserBoardState()` on a stale-pending error rather than\ntrusting the local clock alone.\n\n### Resolve an ATTACK\n\n```ts\nconst targets = board?.Pending?.TargetBuildingStates ?? [];\n// -1 (or omit) lets the server auto-pick a target building.\nconst atk = await client.gameLoop.boardLoopAttack(targets[0]?.SlotIndex ?? -1);\nif (!atk.ok) return showError(atk.error);\natk.data.Outcome; // \"Hit\" | \"Blocked\" (target had a shield)\natk.data.IsBotTarget; // bot fights settle Operation locally\n// PvP fights settle via atk.data.DualResult (both sides' resource deltas) —\n// only the caller's own side (FromResult) is applied to this client's cache.\n```\n\nAttacking always clears `Pending`. If the call fails (`reason: \"server\"` or\n`\"connection\"`), the SDK automatically re-fetches `getUserBoardState()` to\nreconcile — don't also call it yourself in the error branch.\n\n### Resolve a RAID (both modes)\n\nSequential (`RaidMode: \"Sequential\"` — one dig per call):\n\n```ts\nconst dig = await client.gameLoop.boardLoopRaid(digIndex); // 0-11\nif (!dig.ok) return showError(dig.error);\nif (dig.data.Status === \"CONTINUE\") {\n // board.Pending.RaidLayout / OpenedIndices updated in cache; dig again.\n} else {\n // Finished — Status is \"FINISHED_SMALL\" | \"FINISHED_MEDIUM\" | \"FINISHED_BIG\"\n // | \"FINISHED_JACKPOT\" (never the literal \"Complete\"). Pending cleared,\n // reward already applied. dig.data.Outcome carries the same tier as an enum\n // string (\"Small\"/\"Medium\"/\"Big\"/\"Jackpot\").\n}\n```\n\nFast (`RaidMode: \"Fast\"` — client reveals cells from the pre-sent layout\nlocally, then submits the full opened set once a match is found):\n\n```ts\nconst openedSoFar = [...(board?.Pending?.OpenedIndices ?? []), newIndex];\nconst res = await client.gameLoop.boardLoopRaidFast(openedSoFar);\n```\n\n`digIndex` must be 0-11 (a fixed 12-cell grid, always shuffled 4×Small/4×Medium/\n4×Big unless a jackpot variant overrides the symbol mix); `boardLoopRaidFast`\nrejects an empty or duplicate-containing `digIndices` array client-side. Both\nraid methods reject if the title's `RaidMode` doesn't match (calling\n`boardLoopRaid` on a `\"Fast\"`-configured board fails with \"Use\nBoardLoopRaidFast for this board\", and vice versa \"Use BoardLoopRaid for this\nboard\") — read `BoardLoopDefinition.RaidMode` once and call the matching\nmethod, don't let the UI offer both. On a non-`\"CONTINUE\"` `Status` both raid\ncalls clear `Pending` and apply the reward the same way (`Operation`, falling\nback to `DualResult.FromResult` for PvP-style raids). Matching 3 of a kind\nbefore all 12 cells are opened ends the raid immediately — remaining cells are\nsimply never revealed.\n\n### Special tile: choose, optionally boost with an ad, claim\n\n`ChoiceID` is a config-defined id from the offer (`SpecialModeOffer.Choices[].ChoiceID`,\ne.g. `\"SmallCash\"`/`\"BigCashTimed\"`) — **not** the literal string `\"Instant\"`/\n`\"Timed\"`. Render the offer's choices and pass whichever `ChoiceID` the player\npicked:\n\n```ts\nconst board = client.data.user.state?.GameLoop?.Board;\nconst offerChoices = board?.Pending?.Special?.Choices ?? []; // stashed from the roll response\nconst picked = offerChoices[0]; // whatever the player tapped\n\nconst choice = await client.gameLoop.boardSpecialChoose(picked.ChoiceID);\nif (!choice.ok) return showError(choice.error);\n\nif (choice.data.Mode === \"Instant\" || choice.data.Mode === 0) {\n // reward already granted and Pending cleared — nothing else to do.\n} else {\n // Timed: a countdown window is now open (board.Pending.Special.DurationSeconds).\n // Optionally boost the payout with rewarded ads before the window closes:\n const boosted = await client.gameLoop.boardSpecialApplyMultiplier();\n if (boosted.ok) console.log(boosted.data.AccumulatedMultiplier);\n\n // Claim any time — early claim may forgo the ad multiplier and any\n // gradation tier not yet reached:\n const claim = await client.gameLoop.boardSpecialClaim();\n claim.data?.IsEarlyClaim; // true if claimed before the first gradation tier's threshold\n}\n```\n\n`Mode` can come back as either the string (`\"Instant\"`/`\"Timed\"`) or its\nnumeric enum value (`0`/`1`) — check both, as the templates do\n(`data.Mode === \"Timed\" || data.Mode === 1`). A choice can only be committed\nonce per pending SPECIAL (\"Choice already committed\" on a repeat call), and a\nTimed choice is rejected server-side unless its config sets a non-empty\ngradation ladder (\"Timed choice requires a non-empty Gradation ladder\") — this\nis a content-authoring constraint, not something the client can work around.\n\n`boardSpecialApplyMultiplier` can be called multiple times up to the choice's\n`Multipliers.MaxAdViews` (\"Max ad views reached\" past the cap) and only while\nthe Timed window is still open (\"Play window already closed\"); each call rolls\none multiplier step uniformly from `PerAdMultiplierRange` and folds it into\n`AccumulatedMultiplier` per `FormulaKind` — `\"Additive\"` (default) sums the\nrolled steps (final reward multiplier = `1 + AccumulatedMultiplier`),\n`\"Multiplicative\"` multiplies them together (final multiplier =\n`AccumulatedMultiplier` itself, floored at 1.0). Read\n`AccumulatedMultiplier`/`RemainingAdViews` to gate the \"watch another ad\"\nbutton. The reward itself is only computed and paid at `boardSpecialClaim`\ntime — `boardSpecialApplyMultiplier` never grants anything by itself, it just\nrecords the roll.\n\n### Build\n\n```ts\nconst build = await client.gameLoop.boardLoopBuild(slotIndex);\nif (!build.ok) return showError(build.error); // e.g. can't afford, already maxed\nif (build.data.StageComplete) {\n // board.StageLevel bumped, Position reset to 0, BuildingStates cleared —\n // re-fetch getBoardDefinition() for the new stage's config.\n} else {\n // board.BuildingStates[slotIndex].Level bumped in cache already.\n}\n```\n\nBuilding is independent of the roll/pending flow — it can be done any time a\nbuildable slot exists, whether or not `Pending` is set.\n\n### Community Chest: join, contribute via rolling, claim\n\n```ts\nawait client.gameLoop.getCommunityChestState();\nconst chest = client.data.user.state?.GameLoop?.CommunityChest;\n\nif (!chest?.ActiveGroupID) {\n const joined = await client.gameLoop.joinOrCreateCommunityChest();\n if (!joined.ok) return showError(joined.error);\n}\n\n// Contribution happens as a side effect of normal rolling — not a separate call:\nconst roll = await client.gameLoop.boardLoopRoll(1);\nconst contribution = roll.data?.CommunityChestContribution;\nif (contribution?.UnlockedMilestoneIDs?.length) {\n // show \"milestone unlocked\" toast(s) for each id\n}\nif (contribution?.Completed) {\n // shared meter hit MaxProgress — Grand Prize is now claimable for the group.\n}\n\nfor (const milestoneID of contribution?.UnlockedMilestoneIDs ?? []) {\n const claim = await client.gameLoop.claimCommunityChestMilestone(milestoneID);\n if (!claim.ok) console.warn(claim.error); // e.g. already claimed by a race\n}\n```\n\n`joinOrCreateCommunityChest` is idempotent from the caller's perspective — if\nthe player is already in a group in the current round it just returns that\ngroup rather than erroring or creating a duplicate. `claimCommunityChestGrandPrize()`\nonly succeeds once the group's `Status` is `\"Completed\"` (meter filled to\n`MaxProgress`) — checking `contribution.Completed` client-side is a UI\nshortcut, the server re-checks `Status` itself; each member claims\nindividually via their own `GrandPrizeClaimed` flag, so one member's claim\nnever claims it for the whole group. If matchmaking can't find or fill an open\ngroup after a few retries, `joinOrCreateCommunityChest` fails with\n\"Matchmaking failed after retries. Please try again.\" — a plain retry from the\nUI is the right recovery, not a special code path.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`RelatedEntityID`), so two separate calls are two real operations — a\n double-clicked \"Roll\"/\"Attack\" can duplicate. Disable the control while a\n call is in flight. (Firing the same endpoint again within the throttle\n window, default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **`boardLoopRaidFast` only updates the cache on completion.** Unlike\n sequential `boardLoopRaid` (which patches `Pending.RaidLayout`/\n `OpenedIndices` on every `CONTINUE`), a `CONTINUE` response from\n `boardLoopRaidFast` does **not** get mirrored into the cache at all — Fast\n mode is designed so the client already knows the full layout locally and\n only calls the server once, on the winning submission. Don't expect\n `Pending.OpenedIndices` to reflect fast-mode digs mid-game; track opened\n cells in local component state instead (see `templates/board-game`'s\n `RaidPanel`).\n- **On a failed board action, the SDK self-heals by re-fetching state** —\n `boardLoopAttack`, `boardLoopRaid`, and `boardLoopRaidFast` all call\n `getUserBoardState()` automatically when their result is not `ok`. Don't\n duplicate that call in your error handler; just show `result.error`.\n- **Two separate config cache sections.** `getGameLoops()` caches under\n `\"GameLoop\"` (root `GameLoopDefinitions`, including the `CommunityChest`\n config); `getBoardDefinition()`/`getBoardDefinitionForLevel()` cache under\n `\"BoardDefinition\"` (a `BoardLoopDefinition`, i.e. just the board half). If\n you only need Community Chest config, `getGameLoops()` alone is enough — you\n don't need to also load the board.\n- **PvP resource deltas are two-sided.** Attack/raid against a real player\n return `DualResult` with `FromResult`/`ToResult`; only `FromResult` (this\n caller's own delta) is ever applied to the local cache — you cannot see or\n apply the opponent's side from this client, nor should you.\n- **`Mode`/`ChosenMode` on Special responses can be string or numeric enum.**\n Compare against both the string literal and its ordinal (`0`/`1`) as shown\n in the recipes — the wire format isn't fully normalized to strings.\n- **The board has no real end state.** Stages past whatever the title\n authored in `StagesByLevel` are synthesized server-side on demand (visuals\n cycle through `ProceduralEconomy.VisualTemplateCycle`, economy scales via the\n stage's `Unit(N)`) — a player can never actually run out of stages to build\n through. `BoardLoopState.AllStagesCompleted` is defined in the SDK's types\n but the backend never sets it; don't build a \"you beat the game\" screen\n around it.\n- **Attack shields are consumed, not just checked.** A `Blocked` outcome costs\n the defender exactly one unit of `ShieldCurrencyID` (server-side, dual-party\n transaction) — it isn't a passive flag. A bot target's shield is a\n per-roll coin flip (`Bots.ShieldChance`) that only affects that one\n interaction, not a persisted balance.\n- **`SpecialClaimResponse` carries a `ReachedTierIndex` the SDK doesn't type\n yet.** The backend returns which gradation tier was actually paid out\n (`-1` = the below-first-tier reward, `>=0` = index into the choice's\n `Gradation.Tiers`), but the current `@idosgames/core` response type doesn't\n declare that field — it still round-trips (schemas keep `.passthrough()`)\n but reading it requires an `as any`/loose cast until the SDK catches up.\n- **`CommunityChestDefinition.MemberGracePeriodMinutes` is config-only today.**\n Nothing in the backend currently reads it — a departed member's slot is\n _not_ auto-backfilled with a bot; only a still-`\"Forming\"` group gets\n bot-filled, and only after `MatchmakingTimeoutMinutes` elapses. Don't build\n UI that promises a grace-period replacement.\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield for both the board loop and Community Chest: stage/tile/building config,\nprocedural economy knobs, heist raid variants, Special gradation tiers, and\nthe Community Chest group document. Read it when building config-driven UI\n(tile art, reward previews, milestone bars) or when an error message points at\na config rule you need to understand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Item data model — reference\r\n\r\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\r\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\r\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\r\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\r\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\r\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\r\n`.passthrough()`, so a field the backend adds later still round-trips. Field\r\nnames are PascalCase (straight from the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\r\n- [ItemCatalog](#itemcatalog)\r\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\r\n- [ItemDefinition](#itemdefinition)\r\n- [ItemStats](#itemstats)\r\n- [ItemEquipment](#itemequipment)\r\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\r\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\r\n- [ItemMetadata](#itemmetadata)\r\n- [NFTModel](#nftmodel)\r\n- [Player state: InventoryV2](#player-state-inventoryv2)\r\n- [Requests, responses, and actions](#requests-responses-and-actions)\r\n\r\n---\r\n\r\n## Config: ItemDefinitions\r\n\r\nRoot container for every item catalog in the title.\r\n\r\n```ts\r\ninterface ItemDefinitions {\r\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\r\n}\r\n```\r\n\r\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\r\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\r\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\r\nthe exported type is pinned to a hand-written interface instead.\r\n\r\n## ItemCatalog\r\n\r\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\r\n\r\n```ts\r\ninterface ItemCatalog {\r\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\r\n}\r\n```\r\n\r\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\r\nguaranteed unique **within** a catalog — the same `ItemID` string can\r\nlegitimately appear in more than one catalog, which is exactly what the\r\nresolution rule below has to handle.\r\n\r\n## Catalog resolution rule\r\n\r\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\r\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\r\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\r\ncall this yourself, but its behavior explains error messages and a\r\nself-healing field you'll see on upgrade responses:\r\n\r\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\r\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\r\n unambiguous by construction.\r\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\r\n (the item was moved to a different catalog since the instance was granted),\r\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\r\n that's the resolved definition.\r\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\r\n more** catalogs, the resolver refuses to guess and returns nothing (the\r\n caller reports \"item definition not found\").\r\n\r\n**Self-heal:** when resolution succeeds via the fallback path with a\r\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\r\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\r\none as part of the same atomic write — silently, no separate event. That's why\r\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\r\nthe instance before calling upgrade: read it back off the response / refreshed\r\ncache, don't assume it's unchanged.\r\n\r\n---\r\n\r\n## ItemDefinition\r\n\r\nThe template from which player instances are created.\r\n\r\n```ts\r\ninterface ItemDefinition {\r\n ItemID: string;\r\n CatalogID: string;\r\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\r\n DisplayName?: string;\r\n Description?: string;\r\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\r\n CustomData?: string;\r\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\r\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\r\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\r\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\r\n NFT?: NFTModel; // blockchain binding, if any\r\n Stats?: ItemStats;\r\n Equipment?: ItemEquipment;\r\n Upgrade?: ItemUpgrade;\r\n Metadata?: ItemMetadata;\r\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\r\n}\r\n```\r\n\r\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\r\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\r\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\r\nconsumable can still have upgrade tiers, and an equippable item can be\r\nnon-upgradable.\r\n\r\n---\r\n\r\n## ItemStats\r\n\r\nStat modifiers/Power the item contributes when equipped. Applied in two\r\nlayers — flat bonuses added to the base stat first, then percent bonuses\r\nmultiply the (base + flat) total. Consumed by the Character module's Power\r\ncomputation (see character-system skill) — never recomputed client-side.\r\n\r\n```ts\r\ninterface ItemStats {\r\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\r\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\r\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\r\n}\r\n```\r\n\r\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\r\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\r\n\r\n---\r\n\r\n## ItemEquipment\r\n\r\nThe item-side half of the two-sided equip rule matrix (the character-side\r\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\r\nreference doc — both must pass for an equip to succeed).\r\n\r\n```ts\r\ninterface ItemEquipment {\r\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\r\n UseRequirements?: Record<string, number>; // statID -> required character stat level\r\n AllowedCharacterIDs?: string[]; // null/empty = any character\r\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\r\n}\r\n```\r\n\r\n---\r\n\r\n## ItemUpgrade + cost formula\r\n\r\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch`.\r\n\r\n```ts\r\ninterface ItemUpgrade {\r\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\r\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\r\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\r\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\r\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\r\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\r\n}\r\n```\r\n\r\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\r\nnot the step count):\r\n\r\n```\r\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\r\n// BaseCost = the Cost of the selected PriceOptions option\r\n```\r\n\r\n— identical semantics to the Character module's stat-cost scaling, and the same shared\r\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\r\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\r\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\r\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\r\nfrom `current + 1` through the resolved target — it is not a single jump priced\r\noff the destination level alone. If every scaled amount rounds to `0`, or the\r\nselected option is empty, the upgrade is rejected as misconfigured rather than\r\ntreated as free.\r\n\r\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\r\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\r\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\r\nfor the first option available on the caller's platform.\r\n\r\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\r\nthrough unchanged and resolved by the shared premium pipeline per level before\r\nthe per-level bundles are summed — see the currency-system skill for\r\n`ResourceConsume`'s premium fields.\r\n\r\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\r\nwith `firstStep = 1`, so level 1 is the plain base):\r\n\r\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\r\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\r\n before aggregation into the character's total gear-percent.\r\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\r\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\r\n balance any double-counting themselves via weights).\r\n\r\nAn **unset** curve means that quantity does not grow with level — only the base value\r\napplies at every level. There is no field here whose neutral value is `1`: empty is the\r\nneutral, always. These three are read-only\r\ninputs to server computations (Power, PvP stat calc); the SDK never\r\nrecomputes them for you.\r\n\r\n---\r\n\r\n## Fodder valuation + selection modes\r\n\r\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\r\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\r\nand picked when the upgrade's own cost is expressed in copies of itself. Any\r\nother cost entries (currencies, other items, event tokens) are charged\r\nnormally through the regular resource pipeline regardless of `Fodder` config.\r\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\r\n`ProtectLeveled` selection.\r\n\r\n```ts\r\ninterface ItemUpgradeFodder {\r\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\r\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\r\n Selection?:\r\n | \"ProtectLeveled\"\r\n | \"CheapestFirst\"\r\n | \"ClientSelected\"\r\n | \"SameLevelOnly\";\r\n}\r\n```\r\n\r\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\r\n\r\nThe server expresses the self-item portion of the upgrade cost as a **target\r\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\r\nburns fodder copies until their summed `W(level)` meets or exceeds that\r\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\r\n\r\n| Mode | `W(L)` formula | Notes |\r\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\r\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\r\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\r\n\r\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\r\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\r\nclient-side for a cost preview, but the server is what actually enforces\r\ncoverage.\r\n\r\n### Selection — which instances get burned\r\n\r\nOnly relevant when the caller doesn't already specify exact fodder for every\r\nunit needed (or when supply must be chosen automatically):\r\n\r\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\r\n eligible; anything the player has already leveled up is never auto-selected\r\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\r\n in the sense that the pool is still filtered this way in `FlatCount` mode\r\n (where fodder isn't weighted at all — see below).\r\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\r\n same-item/same-catalog, not equipped, not expired, not already claimed by\r\n another item in the same batch) are sorted by `W(level)` ascending, then by\r\n acquisition time, then by ID, and burned cheapest-first until the target\r\n value is covered. Leveled copies are eligible here and burn last (they're\r\n worth more per unit, so they're a poor early pick under this greedy order).\r\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\r\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\r\n and so on. This is the classic tier merge, and it is the mode you want when\r\n the design reads \"two of the same tier make one of the next\".\r\n\r\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\r\n *order*, not a restriction: once the cheap copies run out it will burn a\r\n leveled one, and it burns it **whole** (an instance cannot be partially\r\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\r\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\r\n the upgrade is refused instead, with an error naming the level that is\r\n short. Overshoot is impossible whenever the weight curve is integral,\r\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\r\n upgrade.\r\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\r\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\r\n validated individually (must exist, must be the same item/catalog, must not\r\n be equipped or expired, must not already be claimed elsewhere in the same\r\n batch) and rejected by name if any check fails. If the combined `W(level)`\r\n of your supplied instances doesn't cover the target, the whole upgrade is\r\n rejected — nothing is partially burned.\r\n\r\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\r\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\r\nthat mode the self-item cost is settled by the _regular_ resource-consume\r\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\r\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\r\nrather than being burned), and the rest come from plain inventory count, with\r\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\r\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\r\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\r\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\r\nself-item cost entry, or fodder that's a different item, is rejected.\r\n\r\n---\r\n\r\n## ItemMetadata\r\n\r\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\r\nsubsystem and general UI.\r\n\r\n```ts\r\ninterface ItemMetadata {\r\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\r\n CollectionID?: string; // ties the item into a Collection set/album page\r\n AuthorID?: string; // e.g. UGC/creator attribution\r\n}\r\n```\r\n\r\n---\r\n\r\n## NFTModel\r\n\r\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\r\nitem mirrored on both an EVM chain and Solana).\r\n\r\n```ts\r\ninterface NFTModel {\r\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\r\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\r\n}\r\n\r\ninterface NFTNetworkBinding {\r\n ContractAddress?: string; // EVM contract or Solana mint address\r\n TokenID?: string;\r\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\r\n}\r\n```\r\n\r\nSee the blockchain-system skill for the wallet/mint/transfer flows that\r\npopulate and consume this binding.\r\n\r\n---\r\n\r\n## Player state: InventoryV2\r\n\r\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\r\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch` (and other inventory-affecting calls).\r\n\r\n```ts\r\ninterface UserInventoryState {\r\n Version?: number;\r\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\r\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\r\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\r\n ConversionDaily?: Record<string, ConversionDailyCounter>;\r\n}\r\n\r\ninterface ItemTotals {\r\n StackableAmount: number;\r\n UnstackableAmount: number;\r\n TotalAmount: number;\r\n}\r\n\r\ninterface UnstackableItemInstanceState {\r\n ItemInstanceID: string;\r\n ItemID: string;\r\n CatalogID?: string | null;\r\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\r\n RemainingUses?: number; // consumable-with-charges items\r\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\r\n AcquiredAt: string;\r\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\r\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\r\n CustomData?: string | null;\r\n}\r\n```\r\n\r\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\r\ncurrency-system skill; they ride along in the same inventory snapshot but\r\naren't item-related.\r\n\r\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\r\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\r\nwhich half of the inventory backs it.\r\n\r\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\r\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\r\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\r\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\r\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\r\none copy — e.g. leveling one copy of a stack of five identical swords — the\r\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\r\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\r\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\r\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\r\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\r\ncome back as a **different** id than the one you called with — always read\r\nthe instance id off the response (or the refreshed cache), don't assume it's\r\nunchanged. The backend also opportunistically re-merges pristine fragments of\r\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\r\nyou don't need to do anything to trigger or handle that.\r\n\r\n---\r\n\r\n## Requests, responses, and actions\r\n\r\n```ts\r\ninterface ItemRequest extends BaseRequest {\r\n ItemInstanceID?: string;\r\n Levels?: number;\r\n TargetLevel?: number;\r\n FodderInstanceIDs?: string[];\r\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\r\n Upgrades?: ItemUpgradeRef[];\r\n}\r\n```\r\n\r\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\r\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\r\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\r\n**not** expose them — it only ever raises by one level per call. To move\r\nseveral levels in one call (on one or many instances), use\r\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\r\n\r\n```ts\r\ninterface ItemUpgradeRef {\r\n ItemInstanceID?: string;\r\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\r\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\r\n FodderInstanceIDs?: string[];\r\n}\r\n\r\ninterface FodderConsumedEntry {\r\n ItemInstanceID: string;\r\n Units: number; // how many copies of this instance/pack were burned\r\n Level: number; // the fodder instance's level at time of consumption\r\n}\r\n\r\ninterface UpgradeItemLevelResponse {\r\n ServerTimeUtc: string;\r\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\r\n ItemID: string;\r\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\r\n Level: number; // new level after the upgrade\r\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\r\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\r\n}\r\n\r\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\r\n```\r\n\r\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\r\nuseful when reading logs/errors that echo the action):\r\n\r\n```ts\r\nconst ItemAction = {\r\n UpgradeLevel: \"UpgradeLevel\",\r\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\r\n} as const;\r\n```\r\n\r\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\r\nshape used across the whole SDK — see the currency-system skill for the full\r\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\r\n`PremiumDiscounts` can reduce a displayed base cost.\r\n\r\n### Server-side limits (verified against the backend)\r\n\r\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\r\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\r\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\r\n appear in the result array at all. Chunk larger sets yourself.\r\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\r\n id in the same call only processes once.\r\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\r\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\r\n (single call fails outright; batch reports it as a failed item).\r\n- **Atomicity**: both the single and batch charge/patch happen inside one\r\n Mongo transaction — either the whole thing (cost + level + any fodder burns\r\n - owner Power recompute) applies, or none of it does.\r\n- **Idempotency**: the backend replays the same result for a repeated call\r\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\r\n server-side reason key, so re-running the _same target level_ twice is safe\r\n to retry). The TS `upgradeLevel` method, however, mints a fresh\r\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\r\n from the SDK's side, two separate calls are always two separate operations;\r\n see the Gotchas section in SKILL.md.\r\n"
|
|
8
|
+
"content": "# Item data model — reference\n\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\n- [ItemCatalog](#itemcatalog)\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\n- [ItemDefinition](#itemdefinition)\n- [ItemStats](#itemstats)\n- [ItemEquipment](#itemequipment)\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\n- [ItemMetadata](#itemmetadata)\n- [NFTModel](#nftmodel)\n- [Player state: InventoryV2](#player-state-inventoryv2)\n- [Requests, responses, and actions](#requests-responses-and-actions)\n\n---\n\n## Config: ItemDefinitions\n\nRoot container for every item catalog in the title.\n\n```ts\ninterface ItemDefinitions {\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\n}\n```\n\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\nthe exported type is pinned to a hand-written interface instead.\n\n## ItemCatalog\n\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\n\n```ts\ninterface ItemCatalog {\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\n}\n```\n\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\nguaranteed unique **within** a catalog — the same `ItemID` string can\nlegitimately appear in more than one catalog, which is exactly what the\nresolution rule below has to handle.\n\n## Catalog resolution rule\n\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\ncall this yourself, but its behavior explains error messages and a\nself-healing field you'll see on upgrade responses:\n\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\n unambiguous by construction.\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\n (the item was moved to a different catalog since the instance was granted),\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\n that's the resolved definition.\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\n more** catalogs, the resolver refuses to guess and returns nothing (the\n caller reports \"item definition not found\").\n\n**Self-heal:** when resolution succeeds via the fallback path with a\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\none as part of the same atomic write — silently, no separate event. That's why\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\nthe instance before calling upgrade: read it back off the response / refreshed\ncache, don't assume it's unchanged.\n\n---\n\n## ItemDefinition\n\nThe template from which player instances are created.\n\n```ts\ninterface ItemDefinition {\n ItemID: string;\n CatalogID: string;\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\n DisplayName?: string;\n Description?: string;\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\n CustomData?: string;\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\n NFT?: NFTModel; // blockchain binding, if any\n Stats?: ItemStats;\n Equipment?: ItemEquipment;\n Upgrade?: ItemUpgrade;\n Metadata?: ItemMetadata;\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\n}\n```\n\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\nconsumable can still have upgrade tiers, and an equippable item can be\nnon-upgradable.\n\n---\n\n## ItemStats\n\nStat modifiers/Power the item contributes when equipped. Applied in two\nlayers — flat bonuses added to the base stat first, then percent bonuses\nmultiply the (base + flat) total. Consumed by the Character module's Power\ncomputation (see character-system skill) — never recomputed client-side.\n\n```ts\ninterface ItemStats {\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\n}\n```\n\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\n\n---\n\n## ItemEquipment\n\nThe item-side half of the two-sided equip rule matrix (the character-side\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\nreference doc — both must pass for an equip to succeed).\n\n```ts\ninterface ItemEquipment {\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\n UseRequirements?: Record<string, number>; // statID -> required character stat level\n AllowedCharacterIDs?: string[]; // null/empty = any character\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\n}\n```\n\n---\n\n## ItemUpgrade + cost formula\n\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\n`upgradeLevelsBatch`.\n\n```ts\ninterface ItemUpgrade {\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\n// BaseCost = the Cost of the selected PriceOptions option\n```\n\n— identical semantics to the Character module's stat-cost scaling, and the same shared\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\nfrom `current + 1` through the resolved target — it is not a single jump priced\noff the destination level alone. If every scaled amount rounds to `0`, or the\nselected option is empty, the upgrade is rejected as misconfigured rather than\ntreated as free.\n\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\nfor the first option available on the caller's platform.\n\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\nwith `firstStep = 1`, so level 1 is the plain base):\n\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\n before aggregation into the character's total gear-percent.\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\n balance any double-counting themselves via weights).\n\nAn **unset** curve means that quantity does not grow with level — only the base value\napplies at every level. There is no field here whose neutral value is `1`: empty is the\nneutral, always. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\n Selection?:\n \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\" | \"SameLevelOnly\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\n and so on. This is the classic tier merge, and it is the mode you want when\n the design reads \"two of the same tier make one of the next\".\n\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\n _order_, not a restriction: once the cheap copies run out it will burn a\n leveled one, and it burns it **whole** (an instance cannot be partially\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\n the upgrade is refused instead, with an error naming the level that is\n short. Overshoot is impossible whenever the weight curve is integral,\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\n upgrade.\n\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: string | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Lootbox data model — reference\r\n\r\nFull shape of the config (`LootboxDefinitions`), the pity/roll formulas\r\ntranscribed from the backend, and the reward-progression multiplier overlay.\r\nAll of these are **strictly typed in the SDK** — `LootboxDefinitions` and every\r\nnested block (`LootboxDefinition`, `LootboxPriceOption`, `LootboxRewardSlot`,\r\n`LootboxRewardRoll`, `LootboxAmountRange`, `LootboxPityRule`) are exported from\r\n`@idosgames/core`, so `getDefinitions()` and\r\n`getSection<LootboxDefinitions>(\"Lootbox\")` give you concrete types, not\r\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\r\nstill round-trips. Field names are PascalCase (straight from the backend\r\nJSON).\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — the cached `Lootbox.Pity` map\r\n- [Config: LootboxDefinitions](#config-lootboxdefinitions) — what `getDefinitions()` returns\r\n- [LootboxDefinition](#lootboxdefinition)\r\n- [Price options](#price-options)\r\n- [Reward slots + weighted-roll math](#reward-slots--weighted-roll-math)\r\n- [Pity rules + threshold math](#pity-rules--threshold-math)\r\n- [Catalog pre-filter (SanitizePool)](#catalog-pre-filter-sanitizepool)\r\n- [Reward-progression multiplier (RewardMultiplier)](#reward-progression-multiplier-rewardmultiplier)\r\n- [Cost scaling for count > 1](#cost-scaling-for-count--1)\r\n- [Open response shape](#open-response-shape)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nThere is no `getUserLootboxState()` — the Lootbox module doesn't expose a\r\nstate-fetch method of its own. What the SDK caches locally lives at\r\n`client.data.user.state?.Lootbox`:\r\n\r\n```ts\r\ninterface UserLootboxState {\r\n Pity?: Record<string, UserLootboxPityCounter>; // key = `${LootboxID}:${RuleID}`\r\n}\r\ninterface UserLootboxPityCounter {\r\n OpensSinceLastTrigger: number; // 0..Threshold-1\r\n LastTriggeredAtUtc: string; // ISO timestamp, last time this rule fired\r\n}\r\n```\r\n\r\nThe SDK only **writes** this map from `open()`'s `TriggeredPity` — and only\r\nwhen a trigger actually happened, always resetting `OpensSinceLastTrigger` to\r\n`0` (`LootboxService.ts` → `ctx.data.user.applyLootboxPityTriggers`, then\r\n`UserData.applyLootboxPityTriggers` in `cache/UserData.ts`). It never\r\nincrements the counter locally on a non-triggering open. The **backend**,\r\nhowever, persists the true incremented counter on every single open\r\n(`LootboxHelpers.ComputePityApplication`, see below) — that authoritative\r\n`Lootbox.Pity` map comes down whole as part of `UserState` from\r\n`client.user.getClientState()`. Call that to get an accurate \"N opens until\r\npity\" countdown; don't trust the locally-patched cache for anything beyond\r\n\"did rule X fire, and when.\"\r\n\r\n---\r\n\r\n## Config: LootboxDefinitions\r\n\r\nReturned by `getDefinitions()` as `{ LootboxDefinitions }`; cached via\r\n`client.data.config.getSection<LootboxDefinitions>(\"Lootbox\")`.\r\n\r\n```ts\r\ninterface LootboxDefinitions {\r\n Definitions?: Record<string, LootboxDefinition> | null; // key = LootboxID\r\n}\r\n```\r\n\r\n---\r\n\r\n## LootboxDefinition\r\n\r\nOne box template in the title catalog (`LootboxDefinition.cs`).\r\n\r\n```ts\r\ninterface LootboxDefinition {\r\n LootboxID: string;\r\n AssetPaths?: Record<string, string> | null;\r\n PriceOptions?: Record<string, PriceOption> | null; // key = OptionID\r\n RewardSlots?: LootboxRewardSlot[] | null;\r\n PityRules?: LootboxPityRule[] | null;\r\n RewardMultiplier?: RewardProgressionMultiplierSpec | null; // see below; not a separately exported type\r\n}\r\n```\r\n\r\n`PriceOptions` is the platform-wide price shape: the dictionary key **is** the\r\n`OptionID`, and you pass that string as `selectedOptionID` to `open()`. Omit it\r\nand the server takes the first option available on the caller's platform, so a\r\nbox with a single price needs no client change.\r\n\r\n---\r\n\r\n## Price options\r\n\r\n```ts\r\ninterface PriceOption {\r\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\r\n Name?: string; // display name / localization key\r\n Cost?: ResourceConsume; // consume-only: what this option charges\r\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\r\n AssetPaths?: Record<string, string>;\r\n}\r\n```\r\n\r\n`Cost` is a plain `ResourceConsume` (`Standard` + optional `PremiumDiscounts`).\r\nThe backend requires at least one of: a non-empty `Standard.Entries`, a non-empty\r\n`Standard.EventTokens`, or a non-empty `PremiumDiscounts` list — an option with\r\nall three empty rejects with `\"Price option 'X' is empty.\"` (a box gated entirely\r\nbehind a 100%-off premium discount is valid: F2P players simply can't afford it\r\nand get a normal insufficient-funds rejection).\r\n\r\nA `Cost` entry of type `Purchase` means the option is paid **in a store**: buy the\r\nproduct first and pass the receipt as `open()`'s `payment` argument. Render options\r\nwith `client.checkout.availableOptions(def.PriceOptions)` — see the\r\n`checkout-system` skill.\r\n\r\n---\r\n\r\n## Reward slots + weighted-roll math\r\n\r\n```ts\r\ninterface LootboxRewardSlot {\r\n SlotID?: string; // analytics/UI/debug id, not roll logic\r\n MinRolls?: number;\r\n MaxRolls?: number;\r\n Pool?: LootboxRewardRoll[];\r\n}\r\ninterface LootboxRewardRoll {\r\n Reward?: ResourceGrant; // grant-only: Standard.Entries / Standard.EventTokens / PremiumTiers\r\n Weight?: number;\r\n AmountRange?: LootboxAmountRange; // { Min: number; Max: number }\r\n}\r\n```\r\n\r\nPer box opened, **every slot rolls independently** (`RewardSlotHelpers.RollSlots`\r\nin `Services/RewardSlotHelpers.cs`):\r\n\r\n1. **Roll count** for the slot is uniform-random in `[MinRolls, MaxRolls]`\r\n inclusive (`min = max(0, MinRolls)`, `max = max(min, MaxRolls)`). Set\r\n `MinRolls = MaxRolls = 1` for a guaranteed single roll; `MinRolls = 0` to\r\n make the whole slot optional.\r\n2. Each individual roll picks **one** entry from `Pool` by weight: sum all\r\n `Weight` values (entries with `Weight <= 0` or no `Reward` are skipped),\r\n draw a uniform random integer in `[0, totalWeight)` via the platform's\r\n `SecureRandom`, and walk the cumulative weights to find the hit — a\r\n standard weighted pick, not a percentage table you need to normalize\r\n yourself.\r\n3. If the picked entry has `AmountRange`, the final `Amount` is a uniform\r\n random integer in `[Min, Max]` (inclusive; `max` is clamped to be `>= min`)\r\n and **replaces** `Amount` on every entry/token inside that roll's `Reward`\r\n — not just one. This is why the backend comment recommends one resource per\r\n `AmountRange` entry: a `Reward` with two different currencies sharing one\r\n `AmountRange` would apply the _same_ rolled number to both.\r\n4. All rolls across all slots (plus any pity rolls, see below) are merged into\r\n one `ResourceOperation` by summing same-key entries (same\r\n `Type`+`CurrencyID`/`ItemID`+`CatalogID`) and same-address event tokens.\r\n\r\nThere is no \"duplicate protection\" or per-roll independence guarantee beyond\r\nwhat `Pool` weights encode — two rolls in the same box can land on the same\r\npool entry.\r\n\r\n---\r\n\r\n## Pity rules + threshold math\r\n\r\n```ts\r\ninterface LootboxPityRule {\r\n RuleID?: string; // stable — renaming resets every player's counter\r\n Threshold?: number; // must be >= 1\r\n Pool?: LootboxRewardRoll[]; // same weighted-roll shape as a reward slot's Pool\r\n}\r\n```\r\n\r\nThis is a **plain running counter of opens**, not \"opens since last rare drop\"\r\n— it counts every open of this `LootboxID` regardless of what was rolled, and\r\nis completely independent of `RewardSlots`. Pity rewards are granted **in\r\naddition to** the normal slot rolls, not instead of them.\r\n\r\nPer-rule math for one `open(lootboxID, count, ...)` call\r\n(`LootboxHelpers.ComputePityApplication` in `Services/LootboxHelpers.cs`):\r\n\r\n```\r\nkey = `${lootboxID}:${RuleID}`\r\ncurrentCounter = cached counter for key, or 0 if absent\r\ntotalSteps = currentCounter + count\r\ntriggers = floor(totalSteps / Threshold) // how many times this rule fires\r\nnewCounter = totalSteps % Threshold // counter value after this open\r\n```\r\n\r\n- `triggers` can be **more than 1** in a single call when `count` is large\r\n relative to `Threshold` (e.g. opening 25 boxes against `Threshold = 10`\r\n starting from counter 8 triggers twice: at step 10 and step 20, ending at\r\n counter 3).\r\n- Each trigger does **one independent weighted roll** over the rule's own\r\n `Pool` (same algorithm as a reward slot roll, including `AmountRange`).\r\n- Each trigger's `BoxIndex` (0-based, into `Results`/the batch) is computed as\r\n `(Threshold - 1 - currentCounter) + i * Threshold` for the `i`-th trigger\r\n (0-based) of that rule within this call — i.e. the exact box in the batch\r\n that pushed the counter over the threshold. Always in `[0, count - 1]`.\r\n- Multiple pity rules on the same box are **fully independent**: each tracks\r\n its own counter under its own `key` and can trigger on different boxes\r\n within the same batch (e.g. a `Threshold = 10` \"bonus sticker\" rule and a\r\n `Threshold = 90` \"guaranteed legendary\" rule).\r\n- A rule with `Threshold <= 0`, no `RuleID`, or an empty `Pool` is skipped\r\n entirely (never triggers, never patches a counter) — treat it as\r\n misconfigured rather than \"always trigger\" or \"never trigger by design.\"\r\n\r\nThe counter is persisted via a Mongo patch in the **same atomic transaction**\r\nas the resource grant/consume (`extraPatches` passed into\r\n`ResourceService.ApplyResourceOperationAtomicAsync`) — a failed/insufficient-funds\r\nopen never advances the pity counter, and a successful open's counter update\r\ncan never be \"lost\" relative to the reward it unlocked.\r\n\r\n---\r\n\r\n## Catalog pre-filter (SanitizePool)\r\n\r\nBefore rolling, both `RollSlots` and pity's `RollWithWeight` **pre-filter**\r\neach `Pool` through the title's active item catalogs\r\n(`RewardSlotHelpers.SanitizePool`, shared with the Collection module's bonus\r\nslots):\r\n\r\n- Any `Reward.Standard.Entries` item entry that doesn't resolve in\r\n `ItemDefinitions.Catalogs` (via the same `ItemCatalogResolver` used\r\n elsewhere, strict match with fallback for a moved-catalog item) is stripped\r\n from that pool entry's grant.\r\n- If a pool entry's `Reward` has **no resource left** after stripping (no\r\n surviving item, currency, or event token in `Standard`, and none in any\r\n `PremiumTiers` bundle), the whole entry is dropped from the pool — its\r\n `Weight` is simply excluded from `totalWeight`, so it doesn't dilute the\r\n remaining valid entries and doesn't produce an empty-reward roll.\r\n- Currency and event-token entries are **never** stripped — only `Item`-type\r\n entries are checked against the catalog.\r\n- If no `ItemDefinitions`/`Catalogs` are supplied at all, the pool is used\r\n as-is (backward-compatible no-op).\r\n\r\nNet effect for you as a consumer: a stale/removed item reference in a\r\nlootbox's config can never crash or nullify an open — worst case, that one\r\nweighted slice of the pool silently stops being reachable until the config is\r\nfixed. You don't need to defend against \"got an empty reward\" client-side.\r\n\r\n---\r\n\r\n## Reward-progression multiplier (RewardMultiplier)\r\n\r\n```ts\r\ninterface RewardProgressionMultiplierSpec {\r\n Source?: string; // \"BoardStageLevel\" | \"BoardRank\" | \"BoardCyclesCompleted\"\r\n // | \"CharacterLevel\" | \"SeasonTier\" | \"EventTokenTotalEarned\"\r\n // | \"VirtualCurrencyBalance\" | \"PlayerLevel\"\r\n SourceKey?: string; // e.g. which currency/event-token id, when Source needs a key\r\n Curve?: ScalarCurveSpec; // the shared platform curve; empty = no scaling\r\n Anchor?: number; // progress value the curve starts counting from; empty = 0\r\n IncludeRewards?: ResourceBundle; // restrict which reward entries the multiplier scales\r\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\r\n}\r\n```\r\n\r\nThis is the same platform-wide progression-multiplier overlay used by other\r\nmodules (Reward's `MilestoneRewardMultiplier`, Season tier rewards, etc.) —\r\n**not a separate type exported from `@idosgames/core`'s public index**; it\r\nonly appears structurally as the `RewardMultiplier` field's type inside\r\n`LootboxDefinition`. Read it off the resolved definitions object, don't try to\r\n`import type { RewardProgressionMultiplierSpec }` directly.\r\n\r\nUnlike Reward's version, Lootbox has **no getter** for the resolved\r\nmultiplier — there's nothing like `getMilestoneRewardMultiplier()` here. The\r\nbackend evaluates it internally on every `open()` and applies it silently:\r\n\r\n- `null`/absent `RewardMultiplier` → multiplier is always `1.0`, i.e. no-op.\r\n- Otherwise the backend reads the player's current progress for\r\n `Source`/`SourceKey`, evaluates `Curve` from a base of `1.0` at\r\n `step = progress`, `firstStep = Anchor` (tiered breakpoints are\r\n `Shape: \"Table\"`, a linear ramp is `Shape: \"PerStepRate\"`), clamps it to\r\n `MinResult`/`MaxResult` — **an empty bound means NO bound**, unlike the old\r\n `MaxMultiplier <= 0` convention — and scales matching reward entries' `Amount` with\r\n ceiling-rounding (shared `ModifierService` — same rounding rule used\r\n platform-wide, not reimplemented per module).\r\n- The multiplier applies to **both** normal `RewardSlots` rolls and pity\r\n rewards, scaled **per box** before merging (so `Results[i]` for each box in\r\n a batch already reflects the multiplier). It never touches the **cost**\r\n (`PriceOptions`) — only what's granted.\r\n- `IncludeRewards`/`ExcludeRewards` let the title scope the multiplier to\r\n specific currencies/items/event-tokens instead of the whole grant; an empty\r\n `IncludeRewards` means \"everything,\" and `ExcludeRewards` wins on conflict.\r\n\r\nBecause this all happens server-side with no exposed getter, there is no\r\nclient-side way to preview the exact multiplier before opening — if you want\r\nto show \"your rewards are boosted,\" drive that off whatever domain state\r\nbacks `Source` (e.g. the player's board stage, character level) rather than\r\ntrying to recompute the curve.\r\n\r\n---\r\n\r\n## Cost scaling for count > 1\r\n\r\n`open(lootboxID, count, selectedOptionID, payment?)` charges `count` times the\r\nselected option's `Cost.Standard`, computed by grouping+summing\r\n(`BuildScaledCost` in `Lootbox.cs`): every `VirtualCurrency` entry keyed by\r\n`CurrencyID` and every `Item` entry keyed by `(CatalogID, ItemID)` has its\r\n`Amount` multiplied by `count` and duplicate keys merged before charging —\r\n`PremiumDiscounts` are not pre-scaled here; they're applied automatically\r\ninside `ResourceService`'s premium-discount filtering on the final merged\r\ncost. `count` is clamped server-side to `[1, 100]` regardless of what you\r\nsend.\r\n\r\n---\r\n\r\n## Open response shape\r\n\r\n```ts\r\ninterface LootboxOpenResponse {\r\n ServerTimeUtc: string;\r\n LootboxID: string;\r\n OpenedCount?: number; // == the clamped count actually processed\r\n SelectedOptionID?: number;\r\n Resources?: ResourceOperation; // aggregated grant (all boxes + pity) and the total consume (cost)\r\n Results?: ResourceOperation[]; // one entry per box opened, in order; pity rewards folded into the box that triggered them\r\n TriggeredPity?: LootboxPityTriggerResponse[]; // null if no rule fired this call\r\n}\r\ninterface LootboxPityTriggerResponse {\r\n RuleID: string;\r\n BoxIndex?: number; // 0-based index into Results for the box that crossed the threshold\r\n}\r\n```\r\n\r\n`Results[i].Grant` is filtered per-box for the player's active premium tier\r\nbefore being returned (`ResourceService.FilterByPremium`), so its totals sum\r\nto `Resources.Grant` — both reflect what the player is actually entitled to,\r\nnot the raw unfiltered config. `Results[i]`'s event tokens are plain\r\n`EventTokenOperation` (no `Requested`/`Applied`/`NewBalance` — those only\r\nexist on the aggregated `Resources`), so read balances/streaks from\r\n`Resources`, not from `Results`.\r\n"
|
|
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, PriceOption> | null; // key = OptionID\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 the platform-wide price shape: the dictionary key **is** the\n`OptionID`, and you pass that string as `selectedOptionID` to `open()`. Omit it\nand the server takes the first option available on the caller's platform, so a\nbox with a single price needs no client change.\n\n---\n\n## Price options\n\n```ts\ninterface PriceOption {\n OptionID?: string; // equals the dictionary key; the server substitutes it when empty\n Name?: string; // display name / localization key\n Cost?: ResourceConsume; // consume-only: what this option charges\n AllowedPlatforms?: (\"Web\" | \"Android\" | \"Ios\")[]; // empty = every platform\n AssetPaths?: Record<string, string>;\n}\n```\n\n`Cost` is a plain `ResourceConsume` (`Standard` + optional `PremiumDiscounts`).\nThe backend requires at least one of: a non-empty `Standard.Entries`, a non-empty\n`Standard.EventTokens`, or a non-empty `PremiumDiscounts` list — an option with\nall three empty rejects with `\"Price option 'X' is empty.\"` (a box gated entirely\nbehind a 100%-off premium discount is valid: F2P players simply can't afford it\nand get a normal insufficient-funds rejection).\n\nA `Cost` entry of type `Purchase` means the option is paid **in a store**: buy the\nproduct first and pass the receipt as `open()`'s `payment` argument. Render options\nwith `client.checkout.availableOptions(def.PriceOptions)` — see the\n`checkout-system` skill.\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 Curve?: ScalarCurveSpec; // the shared platform curve; empty = no scaling\n Anchor?: number; // progress value the curve starts counting from; empty = 0\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 `Curve` from a base of `1.0` at\n `step = progress`, `firstStep = Anchor` (tiered breakpoints are\n `Shape: \"Table\"`, a linear ramp is `Shape: \"PerStepRate\"`), clamps it to\n `MinResult`/`MaxResult` — **an empty bound means NO bound**, unlike the old\n `MaxMultiplier <= 0` convention — 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, payment?)` charges `count` times the\nselected option's `Cost.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
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Match data model — reference\r\n\r\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\r\nrequest/response types. All of these are **strictly typed in the SDK** —\r\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\r\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\r\n`@idosgames/core`, so `getDefinitions()` and\r\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\r\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\r\nlater still round-trips. Field names are PascalCase (straight from the\r\nbackend JSON).\r\n\r\nTerminology: the backend consistently calls the cost to participate **Entry**\r\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\r\nin the Match model. Use that vocabulary in any UI copy you generate.\r\n\r\n## Contents\r\n\r\n- [Player state](#player-state) — `UserMatchState`\r\n- [Match (offer)](#match-offer) — `PvPMatch`\r\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\r\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\r\n- [InstantBattleRule](#instantbattlerule)\r\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\r\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\r\n- [Net reward / burn formula](#net-reward--burn-formula)\r\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\r\n- [Request shape](#request-shape)\r\n\r\n---\r\n\r\n## Player state\r\n\r\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\r\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\r\nhydrates it at login.\r\n\r\n```ts\r\ninterface UserMatchState {\r\n PvPBattleStrategy?: BattleStepConfig[];\r\n CreationLimits?: UserMatchCreationLimitState | null;\r\n}\r\n\r\ninterface UserMatchCreationLimitState {\r\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\r\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\r\n DailyResetUtc?: string; // next UTC midnight reset\r\n}\r\n\r\ninterface BattleStepConfig {\r\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\r\n}\r\n```\r\n\r\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\r\nserver-side (`Match.CreationLimits` on the player document, written by\r\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\r\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\r\nfor a client to eventually show \"next match available in…\" UI, but nothing in\r\n`MatchService` currently reads it back into this cache slot — treat it as\r\ninformational/future until a response actually populates it for you.\r\n\r\n---\r\n\r\n## Match (offer)\r\n\r\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\r\nand `UpdateMatchResponse`.\r\n\r\n```ts\r\ninterface PvPMatch {\r\n MatchID: string;\r\n TitleID?: string;\r\n RuleID?: string;\r\n CreatedAt?: string;\r\n CreatorID?: string;\r\n CreatorCharacterID?: string;\r\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\r\n TargetUserID?: string; // set = private/targeted challenge; absent = public\r\n Entry?: ResourceBundle; // the creator's entry cost\r\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\r\n RefundCreationCostOnCancel?: boolean;\r\n JoinedByUserID?: string;\r\n JoinedByCharacterID?: string;\r\n JoinedAt?: string;\r\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\r\n WinnerUserID?: string; // absent/null on a draw\r\n CompletedAt?: string;\r\n IsRewardDistributed?: boolean;\r\n RewardDistributedAt?: string;\r\n}\r\n```\r\n\r\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\r\nmatch) are tracked separately — cancelling refunds the entry cost always, and\r\nthe creation fee only when `RefundCreationCostOnCancel` is true.\r\n\r\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\r\nresolves the battle synchronously in the same call, so a match goes directly\r\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\r\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\r\nenum for forward-compat / other match modes, not for instant-battle.\r\n\r\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\r\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\r\nmatch's strategy from `getMyMatches` or after you've fetched the match some\r\nother way; it isn't needed to join, since your own strategy is what you send\r\nto `instantBattle`.\r\n\r\n---\r\n\r\n## Battle result\r\n\r\nReturned inside `InstantBattleResponse.Battle`.\r\n\r\n```ts\r\ninterface BattleResult {\r\n WinnerUserID?: string; // absent on a draw\r\n LoserUserID?: string; // absent on a draw\r\n Entry?: ResourceBundle; // one side's entry cost that was in play\r\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\r\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\r\n IsDraw?: boolean;\r\n P1BattleProfile?: PlayerBattleProfile; // the match creator\r\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\r\n}\r\n\r\ninterface BattleLogEntry {\r\n RoundIndex?: number; // 1-based\r\n AttackerID?: string;\r\n DefenderID?: string;\r\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\r\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\r\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\r\n DefenderHpRemaining?: number; // floored at 0\r\n}\r\n\r\ninterface PlayerBattleProfile {\r\n UserID?: string;\r\n SelectedCharacterID?: string;\r\n SelectedCharacter?: CharacterModel; // see character-system skill\r\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\r\n Stats?: FighterStats; // final computed combat stats used for this fight\r\n}\r\n\r\ninterface FighterStats {\r\n MaxHp?: number; // starting HP, for a results-screen HP bar\r\n CurrentHp?: number; // HP at the end of the fight\r\n Damage?: number;\r\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\r\n CritChance?: number; // 0..MaxCritChance\r\n CritMultiplier?: number;\r\n Armor?: number; // meaning depends on ArmorMode: damage units (Flat) or a 0..MaxArmorReduction fraction (PercentReduction)\r\n DodgeChance?: number; // 0..MaxDodgeChance\r\n}\r\n```\r\n\r\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\r\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\r\nhit drops the defender to 0 HP, the defender does not get to act that round.\r\n`AttackZone`/`DefenseZone` per log entry come from each side's\r\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\r\nstrategy shorter than the battle simply repeats from the top.\r\n\r\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\r\n\r\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\r\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\r\n3. Apply armor per `ArmorMode`:\r\n - `Flat` (default): `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\r\n - `PercentReduction`: `potentialDamage = max(MinHitDamage, rawDamage * (1 - defender.Armor))`,\r\n with `Armor` already clamped to `[0, MaxArmorReduction]` in step 7 of `CalculateStats`.\r\n The `MinHitDamage` floor applies in both modes, so armor can never heal the defender.\r\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\r\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\r\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\r\n\r\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\r\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\r\n\r\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\r\nlevel-scaling snapshot the engine used internally — the backend\r\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\r\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\r\nthat depends on it being populated.\r\n\r\n`FighterStats` is the resolved combat stats each fighter fought with — read\r\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\r\none fight, not a live/cached character stat.\r\n\r\n---\r\n\r\n## Config: MatchDefinitions\r\n\r\nReturned by `getDefinitions()`; cached via\r\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\r\n\r\n```ts\r\ninterface MatchDefinitions {\r\n InstantBattle?: InstantBattleDefinitions;\r\n}\r\n\r\ninterface InstantBattleDefinitions {\r\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\r\n Defaults?: InstantBattleSettings; // title-wide combat fallback\r\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\r\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\r\n}\r\n```\r\n\r\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\r\nthere's no other battle mode in the model today. If the title hasn't\r\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\r\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\r\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\r\n— so there is always at least one valid `RuleID` to pass.\r\n\r\nResolution order for every block is **rule's own → title `Defaults` (or\r\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\r\ninline-over-preset pattern the character module uses. `StatMapping` resolves\r\nper-field (each role can come from a different layer); `Combat`, `Entry`,\r\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\r\ntitle's `EntryDefaults`, even for fields it left unset).\r\n\r\n---\r\n\r\n## InstantBattleRule\r\n\r\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\r\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\r\n\r\n```ts\r\ninterface InstantBattleRule {\r\n RuleID?: string;\r\n DisplayName?: string;\r\n Description?: string;\r\n Economy?: MatchEconomySettings;\r\n Entry?: MatchEntrySettings;\r\n Creation?: MatchCreationSettings;\r\n Settings?: InstantBattleSettings;\r\n}\r\n\r\ninterface MatchEconomySettings {\r\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\r\n}\r\n```\r\n\r\n---\r\n\r\n## Combat formulas\r\n\r\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\r\nconfigures how a fighter's `FighterStats` are derived for a battle.\r\n\r\n```ts\r\ninterface InstantBattleSettings {\r\n StatMapping?: CombatStatMapping;\r\n Combat?: MatchCombatSettings;\r\n Formula?: MatchStatFormula;\r\n}\r\n\r\ninterface CombatStatMapping {\r\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\r\n DamageStatID?: string; // Default: \"Damage\"\r\n ArmorStatID?: string; // Default: \"Armor\"\r\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\r\n CritChanceStatID?: string; // Default: \"CritChance\"\r\n CritDamageStatID?: string; // Default: \"CritDamage\"\r\n DodgeStatID?: string; // Default: \"Speed\"\r\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\r\n}\r\n\r\ninterface MatchCombatSettings {\r\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\r\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\r\n MinHitDamage?: number; // floor for a hit after armor; default 1\r\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\r\n MaxCritChance?: number; // clamp; default 0.6\r\n MaxDodgeChance?: number; // clamp; default 0.4\r\n ArmorMode?: 'Flat' | 'PercentReduction'; // how armor reduces damage; default 'Flat'\r\n MaxArmorReduction?: number; // clamp in PercentReduction mode only (lower bound 0); default 0.9\r\n}\r\n\r\ninterface MatchStatFormula {\r\n Health?: FormulaSpec;\r\n Damage?: FormulaSpec;\r\n Armor?: FormulaSpec;\r\n AttackSpeed?: FormulaSpec;\r\n CritChance?: FormulaSpec;\r\n CritDamage?: FormulaSpec;\r\n Dodge?: FormulaSpec;\r\n}\r\n\r\ninterface FormulaSpec {\r\n Terms?: FormulaTerm[]; // the value = sum of terms\r\n}\r\n\r\ninterface FormulaTerm {\r\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\r\n Factors?: FormulaFactor[];\r\n}\r\n\r\ninterface FormulaFactor {\r\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\r\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\r\n VariableID?: string; // Kind = Variable\r\n Argument?: string; // the variable's argument (a StatID, ...)\r\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\r\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\r\n}\r\n```\r\n\r\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\r\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\r\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\r\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\r\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\r\ncombat concepts inside the primitive.\r\n\r\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\r\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\r\nfighter silently walking into battle with 1 HP.\r\n\r\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\r\ncontain an expression. That is what makes the two layers acyclic by construction.\r\n\r\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\r\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\r\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\r\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\r\na concrete character `StatID` so the character's `StatLevels` (see\r\n`character-system` skill) feed into it — the same `StatID`s also key\r\nequipment flat/percent bonuses, so a remap automatically covers gear too.\r\nFactors reference base per-stat values and multipliers, never another role's\r\n_final_ value, so there are no formula cycles.\r\n\r\n**When a role has no custom formula** (`Formula` unset for that role), the\r\nengine falls back to its built-in default (backend\r\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\r\n\r\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\r\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\r\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight), then clamped to\r\n `[0, MaxArmorReduction]` when `ArmorMode` is `PercentReduction` (never clamped in `Flat`)\r\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\r\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\r\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\r\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\r\n\r\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\r\nscaling + character-rank scaling — see `character-system`\r\n`references/data-model.md`), `RankMultiplier` is the character's current\r\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\r\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\r\nequipped-item bonuses for that `StatID` (scaled by the item instance's\r\nupgrade level). **This is config for building previews/tooltips, not\r\nsomething to execute client-side to predict a battle outcome** — the server\r\nevaluates it; treat any client-side evaluation as an estimate only.\r\n\r\n---\r\n\r\n## Entry & creation settings\r\n\r\n```ts\r\ninterface MatchEntrySettings {\r\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\r\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\r\n AllowEventTokens?: boolean; // default false\r\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\r\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\r\n}\r\n\r\ninterface EntryResourceRule {\r\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\r\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\r\n CatalogID?: string; // when Kind === \"Item\"\r\n ItemID?: string; // when Kind === \"Item\"\r\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\r\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\r\n MinAmount?: number; // 0 = no lower bound\r\n MaxAmount?: number; // 0 = no upper bound\r\n}\r\n\r\ninterface MatchCreationSettings {\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\r\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\r\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\r\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\r\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\r\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\r\n}\r\n```\r\n\r\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\r\noffer currencies/items/event tokens the rule permits, and clamp the amount\r\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\r\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\r\nas an entry.\"` regardless of policy) — refunding/awarding would have to\r\nrecreate the item instance and lose its upgrade level. Duplicate positions\r\n(same currency, or same catalog+item, or same event-token address) submitted\r\nin one `Entry` are merged server-side before validation, so you don't need to\r\ndedupe client-side.\r\n\r\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\r\n— both can be charged on creation (merged into one `Consume.Standard` charge),\r\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\r\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\r\ncreation time) control only the creation fee on cancel; the entry cost itself\r\nis always refunded on a successful cancel. The creation fee is **always**\r\nsunk once a match is actually played (win, loss, or draw), regardless of the\r\nrefund flag. Don't assume what was refunded — read it off\r\n`CancelMatchResponse.Resources`, which reflects what the server actually\r\nreturned.\r\n\r\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\r\n`createMatch` when targeting a specific opponent, and the authoritative check\r\non `instantBattle` (both directions of the pair, UTC calendar day, counting\r\n`Completed` matches) — a private challenge can still be rejected at battle\r\ntime even if it passed at creation time if the pair played other matches in\r\nbetween.\r\n\r\n---\r\n\r\n## Net reward / burn formula\r\n\r\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\r\neach of the loser's-and-winner's-combined entry positions and burns a share\r\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\r\n/ `CalculateNetReward`):\r\n\r\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\r\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\r\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\r\n rule's `Economy` is unset.\r\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\r\n exactly — no burn (items are indivisible; burning progress-style event\r\n tokens would be meaningless).\r\n\r\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\r\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\r\n\r\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\r\n\r\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\r\n leaves their balance and joins the pool); creator (winner) has\r\n `Grant.Standard = NetReward` (their own entry was already committed at\r\n `createMatch`, so only the reward is granted now).\r\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\r\n was already spent at `createMatch`, nothing more to take); joiner (winner)\r\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\r\n time) **and** `Grant.Standard = NetReward` in the same operation.\r\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\r\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\r\n never paid anything, so there's nothing to refund on their side. The\r\n creation fee is not refunded on a draw (it's sunk once played, per above).\r\n\r\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\r\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\r\nlogic branches on which one is present (see `MatchService.instantBattle` in\r\nSKILL.md's Gotchas).\r\n\r\n---\r\n\r\n## Battle strategy resolution\r\n\r\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\r\nwhichever side's profile is being built) resolve the strategy to use with the\r\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\r\n\r\n1. The `battleStrategy` passed in that specific request, if non-empty.\r\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\r\n non-empty.\r\n3. Otherwise a **freshly randomized** 3-step strategy (random\r\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\r\n — not persisted).\r\n\r\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\r\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\r\n\r\n---\r\n\r\n## Request shape\r\n\r\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\r\ninternally — useful context for reading error messages, not something you\r\nconstruct by hand:\r\n\r\n```ts\r\ninterface MatchRequest extends BaseRequest {\r\n MatchID?: string;\r\n TargetUserID?: string;\r\n Entry?: ResourceBundle;\r\n BattleStrategy?: BattleStepConfig[];\r\n CharacterID?: string;\r\n RuleID?: string;\r\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\r\n Page?: number;\r\n PageSize?: number;\r\n Statuses?: string[]; // GetMyMatches filter\r\n OnlyPublic?: boolean; // GetAvailableMatches filter\r\n}\r\n```\r\n\r\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\r\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\r\ncorrelation — informational, not something you need to read or set yourself.\r\n"
|
|
8
|
+
"content": "# Match data model — reference\n\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\nrequest/response types. All of these are **strictly typed in the SDK** —\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\n`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\nTerminology: the backend consistently calls the cost to participate **Entry**\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\nin the Match model. Use that vocabulary in any UI copy you generate.\n\n## Contents\n\n- [Player state](#player-state) — `UserMatchState`\n- [Match (offer)](#match-offer) — `PvPMatch`\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\n- [InstantBattleRule](#instantbattlerule)\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\n- [Net reward / burn formula](#net-reward--burn-formula)\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\n- [Request shape](#request-shape)\n\n---\n\n## Player state\n\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\nhydrates it at login.\n\n```ts\ninterface UserMatchState {\n PvPBattleStrategy?: BattleStepConfig[];\n CreationLimits?: UserMatchCreationLimitState | null;\n}\n\ninterface UserMatchCreationLimitState {\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\n DailyResetUtc?: string; // next UTC midnight reset\n}\n\ninterface BattleStepConfig {\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\n}\n```\n\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\nserver-side (`Match.CreationLimits` on the player document, written by\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\nfor a client to eventually show \"next match available in…\" UI, but nothing in\n`MatchService` currently reads it back into this cache slot — treat it as\ninformational/future until a response actually populates it for you.\n\n---\n\n## Match (offer)\n\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\nand `UpdateMatchResponse`.\n\n```ts\ninterface PvPMatch {\n MatchID: string;\n TitleID?: string;\n RuleID?: string;\n CreatedAt?: string;\n CreatorID?: string;\n CreatorCharacterID?: string;\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\n TargetUserID?: string; // set = private/targeted challenge; absent = public\n Entry?: ResourceBundle; // the creator's entry cost\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\n RefundCreationCostOnCancel?: boolean;\n JoinedByUserID?: string;\n JoinedByCharacterID?: string;\n JoinedAt?: string;\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\n WinnerUserID?: string; // absent/null on a draw\n CompletedAt?: string;\n IsRewardDistributed?: boolean;\n RewardDistributedAt?: string;\n}\n```\n\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\nmatch) are tracked separately — cancelling refunds the entry cost always, and\nthe creation fee only when `RefundCreationCostOnCancel` is true.\n\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\nresolves the battle synchronously in the same call, so a match goes directly\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\nenum for forward-compat / other match modes, not for instant-battle.\n\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\nmatch's strategy from `getMyMatches` or after you've fetched the match some\nother way; it isn't needed to join, since your own strategy is what you send\nto `instantBattle`.\n\n---\n\n## Battle result\n\nReturned inside `InstantBattleResponse.Battle`.\n\n```ts\ninterface BattleResult {\n WinnerUserID?: string; // absent on a draw\n LoserUserID?: string; // absent on a draw\n Entry?: ResourceBundle; // one side's entry cost that was in play\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\n IsDraw?: boolean;\n P1BattleProfile?: PlayerBattleProfile; // the match creator\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\n}\n\ninterface BattleLogEntry {\n RoundIndex?: number; // 1-based\n AttackerID?: string;\n DefenderID?: string;\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\n DefenderHpRemaining?: number; // floored at 0\n}\n\ninterface PlayerBattleProfile {\n UserID?: string;\n SelectedCharacterID?: string;\n SelectedCharacter?: CharacterModel; // see character-system skill\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\n Stats?: FighterStats; // final computed combat stats used for this fight\n}\n\ninterface FighterStats {\n MaxHp?: number; // starting HP, for a results-screen HP bar\n CurrentHp?: number; // HP at the end of the fight\n Damage?: number;\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\n CritChance?: number; // 0..MaxCritChance\n CritMultiplier?: number;\n Armor?: number; // meaning depends on ArmorMode: damage units (Flat) or a 0..MaxArmorReduction fraction (PercentReduction)\n DodgeChance?: number; // 0..MaxDodgeChance\n}\n```\n\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\nhit drops the defender to 0 HP, the defender does not get to act that round.\n`AttackZone`/`DefenseZone` per log entry come from each side's\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\nstrategy shorter than the battle simply repeats from the top.\n\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\n\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\n3. Apply armor per `ArmorMode`:\n - `Flat` (default): `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\n - `PercentReduction`: `potentialDamage = max(MinHitDamage, rawDamage * (1 - defender.Armor))`,\n with `Armor` already clamped to `[0, MaxArmorReduction]` in step 7 of `CalculateStats`.\n The `MinHitDamage` floor applies in both modes, so armor can never heal the defender.\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\n\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\n\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\nlevel-scaling snapshot the engine used internally — the backend\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\nthat depends on it being populated.\n\n`FighterStats` is the resolved combat stats each fighter fought with — read\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\none fight, not a live/cached character stat.\n\n---\n\n## Config: MatchDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n```ts\ninterface MatchDefinitions {\n InstantBattle?: InstantBattleDefinitions;\n}\n\ninterface InstantBattleDefinitions {\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\n Defaults?: InstantBattleSettings; // title-wide combat fallback\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\n}\n```\n\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\nthere's no other battle mode in the model today. If the title hasn't\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\n— so there is always at least one valid `RuleID` to pass.\n\nResolution order for every block is **rule's own → title `Defaults` (or\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\ninline-over-preset pattern the character module uses. `StatMapping` resolves\nper-field (each role can come from a different layer); `Combat`, `Entry`,\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\ntitle's `EntryDefaults`, even for fields it left unset).\n\n---\n\n## InstantBattleRule\n\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\n\n```ts\ninterface InstantBattleRule {\n RuleID?: string;\n DisplayName?: string;\n Description?: string;\n Economy?: MatchEconomySettings;\n Entry?: MatchEntrySettings;\n Creation?: MatchCreationSettings;\n Settings?: InstantBattleSettings;\n}\n\ninterface MatchEconomySettings {\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\n}\n```\n\n---\n\n## Combat formulas\n\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\nconfigures how a fighter's `FighterStats` are derived for a battle.\n\n```ts\ninterface InstantBattleSettings {\n StatMapping?: CombatStatMapping;\n Combat?: MatchCombatSettings;\n Formula?: MatchStatFormula;\n}\n\ninterface CombatStatMapping {\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\n DamageStatID?: string; // Default: \"Damage\"\n ArmorStatID?: string; // Default: \"Armor\"\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\n CritChanceStatID?: string; // Default: \"CritChance\"\n CritDamageStatID?: string; // Default: \"CritDamage\"\n DodgeStatID?: string; // Default: \"Speed\"\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\n}\n\ninterface MatchCombatSettings {\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\n MinHitDamage?: number; // floor for a hit after armor; default 1\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\n MaxCritChance?: number; // clamp; default 0.6\n MaxDodgeChance?: number; // clamp; default 0.4\n ArmorMode?: \"Flat\" | \"PercentReduction\"; // how armor reduces damage; default 'Flat'\n MaxArmorReduction?: number; // clamp in PercentReduction mode only (lower bound 0); default 0.9\n}\n\ninterface MatchStatFormula {\n Health?: FormulaSpec;\n Damage?: FormulaSpec;\n Armor?: FormulaSpec;\n AttackSpeed?: FormulaSpec;\n CritChance?: FormulaSpec;\n CritDamage?: FormulaSpec;\n Dodge?: FormulaSpec;\n}\n\ninterface FormulaSpec {\n Terms?: FormulaTerm[]; // the value = sum of terms\n}\n\ninterface FormulaTerm {\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\n Factors?: FormulaFactor[];\n}\n\ninterface FormulaFactor {\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\n VariableID?: string; // Kind = Variable\n Argument?: string; // the variable's argument (a StatID, ...)\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\n}\n```\n\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\ncombat concepts inside the primitive.\n\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\nfighter silently walking into battle with 1 HP.\n\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\ncontain an expression. That is what makes the two layers acyclic by construction.\n\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\na concrete character `StatID` so the character's `StatLevels` (see\n`character-system` skill) feed into it — the same `StatID`s also key\nequipment flat/percent bonuses, so a remap automatically covers gear too.\nFactors reference base per-stat values and multipliers, never another role's\n_final_ value, so there are no formula cycles.\n\n**When a role has no custom formula** (`Formula` unset for that role), the\nengine falls back to its built-in default (backend\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\n\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight), then clamped to\n `[0, MaxArmorReduction]` when `ArmorMode` is `PercentReduction` (never clamped in `Flat`)\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\n\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\nscaling + character-rank scaling — see `character-system`\n`references/data-model.md`), `RankMultiplier` is the character's current\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\nequipped-item bonuses for that `StatID` (scaled by the item instance's\nupgrade level). **This is config for building previews/tooltips, not\nsomething to execute client-side to predict a battle outcome** — the server\nevaluates it; treat any client-side evaluation as an estimate only.\n\n---\n\n## Entry & creation settings\n\n```ts\ninterface MatchEntrySettings {\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\n AllowEventTokens?: boolean; // default false\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\n}\n\ninterface EntryResourceRule {\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\n CatalogID?: string; // when Kind === \"Item\"\n ItemID?: string; // when Kind === \"Item\"\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\n MinAmount?: number; // 0 = no lower bound\n MaxAmount?: number; // 0 = no upper bound\n}\n\ninterface MatchCreationSettings {\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\n}\n```\n\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\noffer currencies/items/event tokens the rule permits, and clamp the amount\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\nas an entry.\"` regardless of policy) — refunding/awarding would have to\nrecreate the item instance and lose its upgrade level. Duplicate positions\n(same currency, or same catalog+item, or same event-token address) submitted\nin one `Entry` are merged server-side before validation, so you don't need to\ndedupe client-side.\n\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\n— both can be charged on creation (merged into one `Consume.Standard` charge),\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\ncreation time) control only the creation fee on cancel; the entry cost itself\nis always refunded on a successful cancel. The creation fee is **always**\nsunk once a match is actually played (win, loss, or draw), regardless of the\nrefund flag. Don't assume what was refunded — read it off\n`CancelMatchResponse.Resources`, which reflects what the server actually\nreturned.\n\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\n`createMatch` when targeting a specific opponent, and the authoritative check\non `instantBattle` (both directions of the pair, UTC calendar day, counting\n`Completed` matches) — a private challenge can still be rejected at battle\ntime even if it passed at creation time if the pair played other matches in\nbetween.\n\n---\n\n## Net reward / burn formula\n\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\neach of the loser's-and-winner's-combined entry positions and burns a share\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\n/ `CalculateNetReward`):\n\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\n rule's `Economy` is unset.\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\n exactly — no burn (items are indivisible; burning progress-style event\n tokens would be meaningless).\n\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\n\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\n\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\n leaves their balance and joins the pool); creator (winner) has\n `Grant.Standard = NetReward` (their own entry was already committed at\n `createMatch`, so only the reward is granted now).\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\n was already spent at `createMatch`, nothing more to take); joiner (winner)\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\n time) **and** `Grant.Standard = NetReward` in the same operation.\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\n never paid anything, so there's nothing to refund on their side. The\n creation fee is not refunded on a draw (it's sunk once played, per above).\n\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\nlogic branches on which one is present (see `MatchService.instantBattle` in\nSKILL.md's Gotchas).\n\n---\n\n## Battle strategy resolution\n\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\nwhichever side's profile is being built) resolve the strategy to use with the\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\n\n1. The `battleStrategy` passed in that specific request, if non-empty.\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\n non-empty.\n3. Otherwise a **freshly randomized** 3-step strategy (random\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\n — not persisted).\n\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\n\n---\n\n## Request shape\n\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\ninternally — useful context for reading error messages, not something you\nconstruct by hand:\n\n```ts\ninterface MatchRequest extends BaseRequest {\n MatchID?: string;\n TargetUserID?: string;\n Entry?: ResourceBundle;\n BattleStrategy?: BattleStepConfig[];\n CharacterID?: string;\n RuleID?: string;\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\n Page?: number;\n PageSize?: number;\n Statuses?: string[]; // GetMyMatches filter\n OnlyPublic?: boolean; // GetAvailableMatches filter\n}\n```\n\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\ncorrelation — informational, not something you need to read or set yourself.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|