@idosgames/mcp 0.1.5 → 0.1.7
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 +1 -1
- package/registry/index.json +23 -15
- 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/character-system.json +2 -2
- package/registry/skills/checkout-system.json +6 -0
- package/registry/skills/collection-system.json +2 -2
- package/registry/skills/coop-event-system.json +2 -2
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/deal-offer-system.json +2 -2
- package/registry/skills/game-loop-system.json +1 -1
- package/registry/skills/item-system.json +2 -2
- package/registry/skills/localization-system.json +1 -1
- package/registry/skills/lootbox-system.json +2 -2
- package/registry/skills/marketplace-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/premium-system.json +2 -2
- package/registry/skills/purchase-system.json +11 -0
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/reward-system.json +1 -1
- package/registry/skills/season-system.json +1 -1
- package/registry/skills/store-system.json +2 -2
- package/registry/skills/timed-boost-system.json +2 -2
- package/registry/skills/tutorial-system.json +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "item-system",
|
|
3
3
|
"description": "Work with items on the iDosGames TypeScript SDK (@idosgames/core) via client.item (ItemService) and the shared Item data model: upgrade an item instance's level (single or batch, optionally consuming fodder instances), and understand ItemDefinition / item catalogs / stackable vs unstackable item instances / equipment rules — the vocabulary Character (equipment), Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store (purchase grants) all build on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item upgrade/leveling UIs, inventory screens, item definitions/catalogs, stackable/unstackable item instances, item rarity/tags, NFT-bound items, or otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog, UnstackableItemInstanceState, or InventoryV2 — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: item-system\ndescription: >-\n Work with items on the iDosGames TypeScript SDK (@idosgames/core) via\n client.item (ItemService) and the shared Item data model: upgrade an item\n instance's level (single or batch, optionally consuming fodder instances),\n and understand ItemDefinition / item catalogs / stackable vs unstackable\n item instances / equipment rules — the vocabulary Character (equipment),\n Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store\n (purchase grants) all build on. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n item upgrade/leveling UIs, inventory screens, item definitions/catalogs,\n stackable/unstackable item instances, item rarity/tags, NFT-bound items, or\n otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog,\n UnstackableItemInstanceState, or InventoryV2 — even if they don't name the\n module explicitly.\n---\n\n# Item system (iDosGames TS SDK)\n\nThe Item module has two very different halves. `ItemService` itself is small\n— it only upgrades an item instance's level (single or batch, optionally\nburning fodder instances). But `ItemDefinition` — the config shape for what an\nitem _is_ — is the shared vocabulary every other module builds on: Character\nequips item instances into slots, Marketplace lists/auctions/trades them,\nCraft burns them as recipe inputs and mints them as outputs, Lootbox grants\nthem as rewards, and Store sells them in offer bundles. This skill covers\nboth: the upgrade-level methods you call directly, and the item data model\nyou'll read constantly from every other module's config and responses.\n\nEverything is **server-authoritative**: the client asks the backend to\nupgrade, the backend validates cost/cap/fodder and applies the change, and the\nSDK mirrors the confirmed result into a local cache your UI reads. This skill\nis for **using** the production `ItemService` and reading the item model, not\nfor porting or extending it. If a call is rejected, that's the backend\nenforcing a rule (cost, level cap, fodder mismatch, stale catalog) — surface\nthe error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n item templates: `ItemDefinition` keyed by `ItemID`, grouped into\n `ItemCatalog`s keyed by `CatalogID`, all under the root `ItemDefinitions`.\n Fetched at the title level (see the title-system skill for\n `getItemDefinitions()`), not through `client.item`.\n2. **Item instances** (state, per player) — what a player actually owns, held\n in `client.data.user.state?.InventoryV2`. Two different shapes depending\n on whether the item stacks — see below. `client.item` reads and writes\n only unstackable instances (the ones with a per-instance `Level` to\n upgrade).\n\nA definition's full address is the pair `(CatalogID, ItemID)` — `ItemID` is\nonly unique **within** a catalog, so the same `ItemID` can appear in more than\none catalog. Whether an instance is stackable or not is fixed by\n`ItemDefinition.IsStackable`. See\n[references/data-model.md](references/data-model.md) for the full field\nreference, the catalog-resolution rule (strict-then-fallback with self-heal),\nand the exact upgrade-cost/fodder formulas.\n\n## Stackable vs unstackable item instances\n\n`InventoryV2` (the `UserInventoryState` cache, at\n`client.data.user.state?.InventoryV2`) carries both kinds side by side:\n\n```ts\ninterface UserInventoryState {\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n```\n\n- **Stackable items** (`ItemDefinition.IsStackable === true`, e.g. crafting\n materials, consumables) have no individual identity — the player just has a\n quantity. They live in `Items[itemID]` as a plain count (`ItemTotals`); there\n is no instance to level up, equip, or track expiry on.\n- **Unstackable items** (`IsStackable` false/absent, e.g. weapons, armor,\n collectibles) are each a distinct instance with its own id, level, and\n lifecycle. They live in `UnstackableItems[itemInstanceID]`:\n\n ```ts\n interface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size — see references/data-model.md\n RemainingUses?: number;\n Level?: number; // what upgradeLevel() raises; default 1\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for \"is this equipped\"\n CustomData?: string | null;\n }\n ```\n\n`EquippedSlot` is the **authoritative** record of whether/where an instance is\nequipped — the Character module's per-character `Equipment` map is just a\ncache view of the same fact. See the character-system skill (equip/unequip\nmethods, slot rules) for how items get placed into `EquippedSlot`; this skill\nowns the instance side (`Level`, `ExpiresAt`, `Quantity`).\n\n`ItemService.upgradeLevel` operates on `UnstackableItems` entries only — you\npass an `ItemInstanceID`, and its `Level` is what changes. A `Quantity > 1`\ninstance is a merged pack of identical untouched copies; upgrading one copy\nout of a pack causes the server to split off a fresh instance id for it — see\nreferences/data-model.md for exactly when that happens and why the response's\n`ItemInstanceID` can differ from what you called with.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst items = client.item; // the ItemService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args, e.g. empty `ItemInstanceID`), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window,\ndefault 600ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Already at maximum level\n(3/3).\"`, `\"Item instance 'inst-1' not found.\"`, `\"Item instance 'inst-1' has\nexpired.\"`, `\"Item 'sword' is not upgradable.\"`, or a fodder-coverage\nshortfall).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `upgradeLevel(itemInstanceID, fodderInstanceIDs?)` | Raise one item instance's level by one, optionally burning fodder instances. | `UpgradeItemLevelResponse` (`Level`, `FodderConsumed`) |\n| `upgradeLevelsBatch(upgrades: ItemUpgradeRef[])` | Upgrade several item instances in one atomic call (each with its own multi-level/fodder options). | `UpgradeLevelsBatchResponse` = `BatchItemResult<UpgradeItemLevelResponse>[]` |\n\n`ItemUpgradeRef` (used only inside `upgradeLevelsBatch`):\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to the cap\n FodderInstanceIDs?: string[]; // instances burned to pay for this instance's upgrade\n}\n```\n\nOn success, both methods **re-fetch the server-authoritative inventory**\n(`client.userService.getUserInventory()` internally) and mirror it wholesale\ninto `client.data.user.state?.InventoryV2` — they don't hand-patch the one\ninstance you upgraded. Read the new `Level` off the refreshed cache, or off\n`result.data.Level` directly. Both also emit an event; the coarse\n`user:inventoryUpdated` (+ `user:anyUpdated`) fires as part of that inventory\nrefresh too.\n\n## Reading state and reacting to changes\n\n```ts\n// Current unstackable instances (only present after login/getUserInventory/upgradeLevel):\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\ninst?.Level; // current level\ninst?.EquippedSlot; // where it's equipped, if anywhere\n\n// Stackable item counts:\nconst totals = client.data.user.state?.InventoryV2?.Items?.[\"potion\"];\ntotals?.TotalAmount;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `item:levelUpgraded` → `UpgradeItemLevelResponse`\n- `item:levelsUpgradedBatch` → `UpgradeLevelsBatchResponse`\n\nThe coarse `user:inventoryUpdated` (and `user:anyUpdated`) also fire on the\ninventory refresh that follows every successful upgrade — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"item:levelUpgraded\", (r) => {\n console.log(`${r.ItemInstanceID} is now level ${r.Level}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Upgrade one item instance\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\");\nif (!res.ok) return showError(res.error); // e.g. \"Already at maximum level (3/3).\"\nres.data.Level; // new level\nres.data.ItemInstanceID; // may differ from \"inst-123\" if it split off a pristine pack — see below\nres.data.FodderConsumed; // [] unless a weighted (Merge/InvestmentRefund) fodder mode applied\n// client.data.user.state?.InventoryV2 has already been re-fetched.\n```\n\n### Upgrade with fodder (burn other instances to pay the cost)\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\", [\n \"inst-456\",\n \"inst-789\",\n]);\nif (!res.ok) return showError(res.error);\nfor (const f of res.data.FodderConsumed ?? []) {\n console.log(\n `burned ${f.ItemInstanceID} (was level ${f.Level}, ${f.Units} units)`,\n );\n}\n```\n\n`fodderInstanceIDs` is only meaningful when the item's config defines a fodder\nvaluation mode (`ItemDefinition.Upgrade.Fodder`) that requires client\nselection (`Selection: \"ClientSelected\"`) — otherwise the server auto-picks\nfodder itself (`ProtectLeveled`/`CheapestFirst`), or there's no self-item cost\nat all and any fodder you pass is simply rejected as a mismatch. Passing\nfodder that's a different item, already equipped, expired, or already claimed\nelsewhere in the same batch is rejected by instance id. See\nreferences/data-model.md for the exact valuation math (`W(level)` per mode)\nand selection rules.\n\n### Multi-level upgrade to an absolute target\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"inst-123\", TargetLevel: 10 },\n]);\nif (!res.ok) return showError(res.error); // outer call-level failure\nconst [item] = res.data;\nif (!item.Success) return showItemError(item.Id, item.Error);\n```\n\n`upgradeLevelsBatch` is the only way to pass `Levels`/`TargetLevel` from the\nSDK — the single `upgradeLevel` call only ever raises by one level per call\n(even though the underlying backend request shape supports a multi-level jump\non the single action too, the SDK doesn't expose it that way). To jump several\nlevels on a single instance in one shot, call the batch method with one entry.\nThe charge is the **sum** of each level's cost in the range, not a single\nlump price for the destination level — see references/data-model.md for the\nformula.\n\n### Batch-upgrade several instances at once\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"sword-1\", Levels: 2 },\n { ItemInstanceID: \"shield-1\" }, // Levels defaults to 1\n { ItemInstanceID: \"bow-1\", FodderInstanceIDs: [\"bow-2\", \"bow-3\"] },\n]);\nif (!res.ok) return showError(res.error);\nfor (const entry of res.data) {\n if (entry.Success) applyOk(entry.Id);\n else showItemError(entry.Id, entry.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that instance's\nupgrade applied. The resource charge across the batch is atomic/merged — if\nthe combined cost can't be paid, every included item comes back\n`Success: false`. Refs are deduped by `ItemInstanceID` server-side, and the\nserver processes at most **50 entries per call** — entries past 50 are\nsilently dropped and don't appear in the results at all, so chunk larger sets\ninto multiple calls yourself. An `ItemInstanceID` containing `.` or `$` is\nrejected per-entry rather than failing the whole batch.\n\n### Read the catalog for display (rarity, tags, upgrade cap)\n\n```ts\nimport type { ItemDefinitions } from \"@idosgames/core\";\n\nfunction findItemDef(defs: ItemDefinitions | undefined, itemID: string) {\n for (const catalog of Object.values(defs?.Catalogs ?? {})) {\n const def = catalog.Items?.[itemID];\n if (def) return def; // first match; see references/data-model.md if itemID isn't unique title-wide\n }\n return undefined;\n}\n\nconst defs = client.data.config.itemDefinitions; // ItemDefinitions | undefined\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\nconst def = inst && findItemDef(defs, inst.ItemID);\ndef?.Metadata?.RarityID; // \"Epic\", etc — drives UI framing\ndef?.Upgrade?.MaxLevel; // upgrade cap for the progress bar\n```\n\n`client.data.config.itemDefinitions` is a dedicated cache getter (not the\ngeneric `getSection` map other modules use) — it returns whichever came in\nlast: a standalone `client.title.getItemDefinitions()` call, or the `Item`\nblock embedded in the full title config from `getTitlePublicConfiguration()`\n(see the title-system skill for both). When you already know an instance's\n`CatalogID`, look it up directly (`defs.Catalogs?.[catalogID]?.Items?.[itemID]`)\ninstead of scanning — it's the same strict-first rule the backend applies, and\navoids the rare same-`ItemID`-in-two-catalogs ambiguity described in\nreferences/data-model.md.\n\n## Gotchas\n\n- **`upgradeLevel` only ever steps +1.** There's no `levels`/`targetLevel`\n option on the single call — use `upgradeLevelsBatch` with one ref for\n multi-level jumps.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`upgrade_item_{instanceID}_{uuid}`), so two separate calls are two real\n operations — a double-clicked \"Upgrade\" can charge twice. Disable the\n control while a call is in flight. (Firing the same endpoint again within\n the throttle window, default 600ms, is rejected with `reason: \"throttled\"`\n rather than duplicated, but don't rely on that for correctness.)\n- **Render from the refreshed inventory, not a locally patched copy.** The\n service re-fetches `GetUserInventory` on success rather than mutating just\n the one instance — treat `client.data.user.state?.InventoryV2` as the\n source of truth after any upgrade call. In particular, the upgraded\n instance's id in the response can differ from the id you called with (see\n the `Quantity`/pristine-pack split note in references/data-model.md).\n- **Fodder valuation is mode-specific, not \"any spare copy is worth 1.\"**\n `Merge` and `InvestmentRefund` value a fodder copy by its own level\n (`W(level)`, growing super-linearly for `Merge`) — a single high-level\n fodder instance can outweigh several low-level ones, or vice versa, and\n `FodderConsumed` only reports burns for these two weighted modes. Under the\n legacy/`FlatCount` mode, fodder isn't weighted at all: the self-item portion\n of the cost is paid through the ordinary resource-consume pipeline (one\n unit of it is implicitly the instance being upgraded), and `FodderConsumed`\n stays empty even if you pass `fodderInstanceIDs`. Don't build a \"fodder\n value\" UI assuming a flat count applies universally — read\n `ItemDefinition.Upgrade.Fodder.ValuationMode` first.\n- **Catalog IDs can self-heal underneath you.** If an item was moved to a\n different catalog after an instance was granted, the resolver falls back to\n a title-wide scan by `ItemID` and — if unambiguous — silently patches the\n instance's stored `CatalogID` to the resolved one as part of the upgrade.\n Read `CatalogID` off the response/refreshed cache, don't cache it\n separately. If the same `ItemID` now exists in two or more catalogs, the\n fallback refuses to guess and the upgrade fails with a \"not found\" error\n even though the instance still nominally exists — that's a config/data\n issue for the title owner, not something to route around client-side.\n- **Stackable items never appear in `UnstackableItems`.** If\n `ItemDefinition.IsStackable` is true, there is no per-instance `Level` to\n upgrade — `upgradeLevel`/`upgradeLevelsBatch` don't apply to it at all\n (attempting it is rejected as \"stackable; levels are only supported on\n unstackable items\").\n- **Equipment truth lives on the instance, not the character.** `EquippedSlot`\n on `UnstackableItemInstanceState` is authoritative; the Character module's\n per-character `Equipment` map is a synced cache view. Upgrading an equipped\n instance also recomputes and patches its owner's `Power` server-side in the\n same atomic write — see the character-system skill for equip/unequip calls\n and the two-sided character/item slot-rule matrix.\n- **Cost objects are the shared `ResourceConsume`/`ResourceGrant` types.**\n `ItemDefinition.Upgrade.BaseCostResource` and the response's `Resources`\n field use the same shared resource model as every other module — see the\n currency-system skill for the full breakdown; briefly, `Resources` on the\n response is a `ResourceOperation` (`{ Grant?, Consume? }`) that's already\n been applied to cached balances by the time you read it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every `ItemDefinition`\nfield (stats, equip rules, upgrade/fodder config, NFT binding, metadata), the\ncatalog/root shape and resolution rule, the upgrade cost formula, and the\nfodder valuation/selection formulas transcribed from the backend. Read it when\nbuilding config-driven UI (upgrade cost previews, fodder pickers, rarity\nbadges) or when an error message points at a config rule you need to\nunderstand.\n",
|
|
4
|
+
"content": "---\nname: item-system\ndescription: >-\n Work with items on the iDosGames TypeScript SDK (@idosgames/core) via\n client.item (ItemService) and the shared Item data model: upgrade an item\n instance's level (single or batch, optionally consuming fodder instances),\n and understand ItemDefinition / item catalogs / stackable vs unstackable\n item instances / equipment rules — the vocabulary Character (equipment),\n Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store\n (purchase grants) all build on. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n item upgrade/leveling UIs, inventory screens, item definitions/catalogs,\n stackable/unstackable item instances, item rarity/tags, NFT-bound items, or\n otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog,\n UnstackableItemInstanceState, or InventoryV2 — even if they don't name the\n module explicitly.\n---\n\n# Item system (iDosGames TS SDK)\n\nThe Item module has two very different halves. `ItemService` itself is small\n— it only upgrades an item instance's level (single or batch, optionally\nburning fodder instances). But `ItemDefinition` — the config shape for what an\nitem _is_ — is the shared vocabulary every other module builds on: Character\nequips item instances into slots, Marketplace lists/auctions/trades them,\nCraft burns them as recipe inputs and mints them as outputs, Lootbox grants\nthem as rewards, and Store sells them in offer bundles. This skill covers\nboth: the upgrade-level methods you call directly, and the item data model\nyou'll read constantly from every other module's config and responses.\n\nEverything is **server-authoritative**: the client asks the backend to\nupgrade, the backend validates cost/cap/fodder and applies the change, and the\nSDK mirrors the confirmed result into a local cache your UI reads. This skill\nis for **using** the production `ItemService` and reading the item model, not\nfor porting or extending it. If a call is rejected, that's the backend\nenforcing a rule (cost, level cap, fodder mismatch, stale catalog) — surface\nthe error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n item templates: `ItemDefinition` keyed by `ItemID`, grouped into\n `ItemCatalog`s keyed by `CatalogID`, all under the root `ItemDefinitions`.\n Fetched at the title level (see the title-system skill for\n `getItemDefinitions()`), not through `client.item`.\n2. **Item instances** (state, per player) — what a player actually owns, held\n in `client.data.user.state?.InventoryV2`. Two different shapes depending\n on whether the item stacks — see below. `client.item` reads and writes\n only unstackable instances (the ones with a per-instance `Level` to\n upgrade).\n\nA definition's full address is the pair `(CatalogID, ItemID)` — `ItemID` is\nonly unique **within** a catalog, so the same `ItemID` can appear in more than\none catalog. Whether an instance is stackable or not is fixed by\n`ItemDefinition.IsStackable`. See\n[references/data-model.md](references/data-model.md) for the full field\nreference, the catalog-resolution rule (strict-then-fallback with self-heal),\nand the exact upgrade-cost/fodder formulas.\n\n## Stackable vs unstackable item instances\n\n`InventoryV2` (the `UserInventoryState` cache, at\n`client.data.user.state?.InventoryV2`) carries both kinds side by side:\n\n```ts\ninterface UserInventoryState {\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n```\n\n- **Stackable items** (`ItemDefinition.IsStackable === true`, e.g. crafting\n materials, consumables) have no individual identity — the player just has a\n quantity. They live in `Items[itemID]` as a plain count (`ItemTotals`); there\n is no instance to level up, equip, or track expiry on.\n- **Unstackable items** (`IsStackable` false/absent, e.g. weapons, armor,\n collectibles) are each a distinct instance with its own id, level, and\n lifecycle. They live in `UnstackableItems[itemInstanceID]`:\n\n ```ts\n interface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size — see references/data-model.md\n RemainingUses?: number;\n Level?: number; // what upgradeLevel() raises; default 1\n AcquiredAt: string;\n ExpiresAt?: string | null;\n EquippedSlot?: EquipmentSlot | null; // { CharacterID, SlotID } — source of truth for \"is this equipped\"\n CustomData?: string | null;\n }\n ```\n\n`EquippedSlot` is the **authoritative** record of whether/where an instance is\nequipped — the Character module's per-character `Equipment` map is just a\ncache view of the same fact. See the character-system skill (equip/unequip\nmethods, slot rules) for how items get placed into `EquippedSlot`; this skill\nowns the instance side (`Level`, `ExpiresAt`, `Quantity`).\n\n`ItemService.upgradeLevel` operates on `UnstackableItems` entries only — you\npass an `ItemInstanceID`, and its `Level` is what changes. A `Quantity > 1`\ninstance is a merged pack of identical untouched copies; upgrading one copy\nout of a pack causes the server to split off a fresh instance id for it — see\nreferences/data-model.md for exactly when that happens and why the response's\n`ItemInstanceID` can differ from what you called with.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst items = client.item; // the ItemService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (bad local args, e.g. empty `ItemInstanceID`), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the throttle window,\ndefault 600ms), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Already at maximum level\n(3/3).\"`, `\"Item instance 'inst-1' not found.\"`, `\"Item instance 'inst-1' has\nexpired.\"`, `\"Item 'sword' is not upgradable.\"`, or a fodder-coverage\nshortfall).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `upgradeLevel(itemInstanceID, fodderInstanceIDs?)` | Raise one item instance's level by one, optionally burning fodder instances. | `UpgradeItemLevelResponse` (`Level`, `FodderConsumed`) |\n| `upgradeLevelsBatch(upgrades: ItemUpgradeRef[])` | Upgrade several item instances in one atomic call (each with its own multi-level/fodder options). | `UpgradeLevelsBatchResponse` = `BatchItemResult<UpgradeItemLevelResponse>[]` |\n\n`ItemUpgradeRef` (used only inside `upgradeLevelsBatch`):\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to the cap\n FodderInstanceIDs?: string[]; // instances burned to pay for this instance's upgrade\n}\n```\n\nOn success, both methods **re-fetch the server-authoritative inventory**\n(`client.userService.getUserInventory()` internally) and mirror it wholesale\ninto `client.data.user.state?.InventoryV2` — they don't hand-patch the one\ninstance you upgraded. Read the new `Level` off the refreshed cache, or off\n`result.data.Level` directly. Both also emit an event; the coarse\n`user:inventoryUpdated` (+ `user:anyUpdated`) fires as part of that inventory\nrefresh too.\n\n## Reading state and reacting to changes\n\n```ts\n// Current unstackable instances (only present after login/getUserInventory/upgradeLevel):\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\ninst?.Level; // current level\ninst?.EquippedSlot; // where it's equipped, if anywhere\n\n// Stackable item counts:\nconst totals = client.data.user.state?.InventoryV2?.Items?.[\"potion\"];\ntotals?.TotalAmount;\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `item:levelUpgraded` → `UpgradeItemLevelResponse`\n- `item:levelsUpgradedBatch` → `UpgradeLevelsBatchResponse`\n\nThe coarse `user:inventoryUpdated` (and `user:anyUpdated`) also fire on the\ninventory refresh that follows every successful upgrade — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"item:levelUpgraded\", (r) => {\n console.log(`${r.ItemInstanceID} is now level ${r.Level}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Upgrade one item instance\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\");\nif (!res.ok) return showError(res.error); // e.g. \"Already at maximum level (3/3).\"\nres.data.Level; // new level\nres.data.ItemInstanceID; // may differ from \"inst-123\" if it split off a pristine pack — see below\nres.data.FodderConsumed; // [] unless a weighted (Merge/InvestmentRefund) fodder mode applied\n// client.data.user.state?.InventoryV2 has already been re-fetched.\n```\n\n### Upgrade with fodder (burn other instances to pay the cost)\n\n```ts\nconst res = await client.item.upgradeLevel(\"inst-123\", [\n \"inst-456\",\n \"inst-789\",\n]);\nif (!res.ok) return showError(res.error);\nfor (const f of res.data.FodderConsumed ?? []) {\n console.log(\n `burned ${f.ItemInstanceID} (was level ${f.Level}, ${f.Units} units)`,\n );\n}\n```\n\n`fodderInstanceIDs` is only meaningful when the item's config defines a fodder\nvaluation mode (`ItemDefinition.Upgrade.Fodder`) that requires client\nselection (`Selection: \"ClientSelected\"`) — otherwise the server auto-picks\nfodder itself (`ProtectLeveled`/`CheapestFirst`), or there's no self-item cost\nat all and any fodder you pass is simply rejected as a mismatch. Passing\nfodder that's a different item, already equipped, expired, or already claimed\nelsewhere in the same batch is rejected by instance id. See\nreferences/data-model.md for the exact valuation math (`W(level)` per mode)\nand selection rules.\n\n### Multi-level upgrade to an absolute target\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"inst-123\", TargetLevel: 10 },\n]);\nif (!res.ok) return showError(res.error); // outer call-level failure\nconst [item] = res.data;\nif (!item.Success) return showItemError(item.Id, item.Error);\n```\n\n`upgradeLevelsBatch` is the only way to pass `Levels`/`TargetLevel` from the\nSDK — the single `upgradeLevel` call only ever raises by one level per call\n(even though the underlying backend request shape supports a multi-level jump\non the single action too, the SDK doesn't expose it that way). To jump several\nlevels on a single instance in one shot, call the batch method with one entry.\nThe charge is the **sum** of each level's cost in the range, not a single\nlump price for the destination level — see references/data-model.md for the\nformula.\n\n### Batch-upgrade several instances at once\n\n```ts\nconst res = await client.item.upgradeLevelsBatch([\n { ItemInstanceID: \"sword-1\", Levels: 2 },\n { ItemInstanceID: \"shield-1\" }, // Levels defaults to 1\n { ItemInstanceID: \"bow-1\", FodderInstanceIDs: [\"bow-2\", \"bow-3\"] },\n]);\nif (!res.ok) return showError(res.error);\nfor (const entry of res.data) {\n if (entry.Success) applyOk(entry.Id);\n else showItemError(entry.Id, entry.Error); // this one was rejected\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that instance's\nupgrade applied. The resource charge across the batch is atomic/merged — if\nthe combined cost can't be paid, every included item comes back\n`Success: false`. Refs are deduped by `ItemInstanceID` server-side, and the\nserver processes at most **50 entries per call** — entries past 50 are\nsilently dropped and don't appear in the results at all, so chunk larger sets\ninto multiple calls yourself. An `ItemInstanceID` containing `.` or `$` is\nrejected per-entry rather than failing the whole batch.\n\n### Read the catalog for display (rarity, tags, upgrade cap)\n\n```ts\nimport type { ItemDefinitions } from \"@idosgames/core\";\n\nfunction findItemDef(defs: ItemDefinitions | undefined, itemID: string) {\n for (const catalog of Object.values(defs?.Catalogs ?? {})) {\n const def = catalog.Items?.[itemID];\n if (def) return def; // first match; see references/data-model.md if itemID isn't unique title-wide\n }\n return undefined;\n}\n\nconst defs = client.data.config.itemDefinitions; // ItemDefinitions | undefined\nconst inst =\n client.data.user.state?.InventoryV2?.UnstackableItems?.[\"inst-123\"];\nconst def = inst && findItemDef(defs, inst.ItemID);\ndef?.Metadata?.RarityID; // \"Epic\", etc — drives UI framing\ndef?.Upgrade?.MaxLevel; // upgrade cap for the progress bar\n```\n\n`client.data.config.itemDefinitions` is a dedicated cache getter (not the\ngeneric `getSection` map other modules use) — it returns whichever came in\nlast: a standalone `client.title.getItemDefinitions()` call, or the `Item`\nblock embedded in the full title config from `getTitlePublicConfiguration()`\n(see the title-system skill for both). When you already know an instance's\n`CatalogID`, look it up directly (`defs.Catalogs?.[catalogID]?.Items?.[itemID]`)\ninstead of scanning — it's the same strict-first rule the backend applies, and\navoids the rare same-`ItemID`-in-two-catalogs ambiguity described in\nreferences/data-model.md.\n\n## Gotchas\n\n- **`upgradeLevel` only ever steps +1.** There's no `levels`/`targetLevel`\n option on the single call — use `upgradeLevelsBatch` with one ref for\n multi-level jumps.\n- **Guard against double-submit.** Each call mints a fresh idempotency key\n (`upgrade_item_{instanceID}_{uuid}`), so two separate calls are two real\n operations — a double-clicked \"Upgrade\" can charge twice. Disable the\n control while a call is in flight. (Firing the same endpoint again within\n the throttle window, default 600ms, is rejected with `reason: \"throttled\"`\n rather than duplicated, but don't rely on that for correctness.)\n- **Render from the refreshed inventory, not a locally patched copy.** The\n service re-fetches `GetUserInventory` on success rather than mutating just\n the one instance — treat `client.data.user.state?.InventoryV2` as the\n source of truth after any upgrade call. In particular, the upgraded\n instance's id in the response can differ from the id you called with (see\n the `Quantity`/pristine-pack split note in references/data-model.md).\n- **Fodder valuation is mode-specific, not \"any spare copy is worth 1.\"**\n `Merge` and `InvestmentRefund` value a fodder copy by its own level\n (`W(level)`, growing super-linearly for `Merge`) — a single high-level\n fodder instance can outweigh several low-level ones, or vice versa, and\n `FodderConsumed` only reports burns for these two weighted modes. Under the\n legacy/`FlatCount` mode, fodder isn't weighted at all: the self-item portion\n of the cost is paid through the ordinary resource-consume pipeline (one\n unit of it is implicitly the instance being upgraded), and `FodderConsumed`\n stays empty even if you pass `fodderInstanceIDs`. Don't build a \"fodder\n value\" UI assuming a flat count applies universally — read\n `ItemDefinition.Upgrade.Fodder.ValuationMode` first.\n- **Catalog IDs can self-heal underneath you.** If an item was moved to a\n different catalog after an instance was granted, the resolver falls back to\n a title-wide scan by `ItemID` and — if unambiguous — silently patches the\n instance's stored `CatalogID` to the resolved one as part of the upgrade.\n Read `CatalogID` off the response/refreshed cache, don't cache it\n separately. If the same `ItemID` now exists in two or more catalogs, the\n fallback refuses to guess and the upgrade fails with a \"not found\" error\n even though the instance still nominally exists — that's a config/data\n issue for the title owner, not something to route around client-side.\n- **Stackable items never appear in `UnstackableItems`.** If\n `ItemDefinition.IsStackable` is true, there is no per-instance `Level` to\n upgrade — `upgradeLevel`/`upgradeLevelsBatch` don't apply to it at all\n (attempting it is rejected as \"stackable; levels are only supported on\n unstackable items\").\n- **Equipment truth lives on the instance, not the character.** `EquippedSlot`\n on `UnstackableItemInstanceState` is authoritative; the Character module's\n per-character `Equipment` map is a synced cache view. Upgrading an equipped\n instance also recomputes and patches its owner's `Power` server-side in the\n same atomic write — see the character-system skill for equip/unequip calls\n and the two-sided character/item slot-rule matrix.\n- **Cost objects are the shared `ResourceConsume`/`ResourceGrant` types.**\n `ItemDefinition.Upgrade.PriceOptions` and the response's `Resources`\n field use the same shared resource model as every other module — see the\n currency-system skill for the full breakdown; briefly, `Resources` on the\n response is a `ResourceOperation` (`{ Grant?, Consume? }`) that's already\n been applied to cached balances by the time you read it.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every `ItemDefinition`\nfield (stats, equip rules, upgrade/fodder config, NFT binding, metadata), the\ncatalog/root shape and resolution rule, the upgrade cost formula, and the\nfodder valuation/selection formulas transcribed from the backend. Read it when\nbuilding config-driven UI (upgrade cost previews, fodder pickers, rarity\nbadges) or when an error message points at a config rule you need to\nunderstand.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Item data model — reference\n\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON).\n\n## Contents\n\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\n- [ItemCatalog](#itemcatalog)\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\n- [ItemDefinition](#itemdefinition)\n- [ItemStats](#itemstats)\n- [ItemEquipment](#itemequipment)\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\n- [ItemMetadata](#itemmetadata)\n- [NFTModel](#nftmodel)\n- [Player state: InventoryV2](#player-state-inventoryv2)\n- [Requests, responses, and actions](#requests-responses-and-actions)\n\n---\n\n## Config: ItemDefinitions\n\nRoot container for every item catalog in the title.\n\n```ts\ninterface ItemDefinitions {\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\n}\n```\n\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\nthe exported type is pinned to a hand-written interface instead.\n\n## ItemCatalog\n\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\n\n```ts\ninterface ItemCatalog {\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\n}\n```\n\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\nguaranteed unique **within** a catalog — the same `ItemID` string can\nlegitimately appear in more than one catalog, which is exactly what the\nresolution rule below has to handle.\n\n## Catalog resolution rule\n\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\ncall this yourself, but its behavior explains error messages and a\nself-healing field you'll see on upgrade responses:\n\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\n unambiguous by construction.\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\n (the item was moved to a different catalog since the instance was granted),\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\n that's the resolved definition.\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\n more** catalogs, the resolver refuses to guess and returns nothing (the\n caller reports \"item definition not found\").\n\n**Self-heal:** when resolution succeeds via the fallback path with a\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\none as part of the same atomic write — silently, no separate event. That's why\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\nthe instance before calling upgrade: read it back off the response / refreshed\ncache, don't assume it's unchanged.\n\n---\n\n## ItemDefinition\n\nThe template from which player instances are created.\n\n```ts\ninterface ItemDefinition {\n ItemID: string;\n CatalogID: string;\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\n DisplayName?: string;\n Description?: string;\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\n CustomData?: string;\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\n NFT?: NFTModel; // blockchain binding, if any\n Stats?: ItemStats;\n Equipment?: ItemEquipment;\n Upgrade?: ItemUpgrade;\n Metadata?: ItemMetadata;\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\n}\n```\n\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\nconsumable can still have upgrade tiers, and an equippable item can be\nnon-upgradable.\n\n---\n\n## ItemStats\n\nStat modifiers/Power the item contributes when equipped. Applied in two\nlayers — flat bonuses added to the base stat first, then percent bonuses\nmultiply the (base + flat) total. Consumed by the Character module's Power\ncomputation (see character-system skill) — never recomputed client-side.\n\n```ts\ninterface ItemStats {\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\n}\n```\n\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\n\n---\n\n## ItemEquipment\n\nThe item-side half of the two-sided equip rule matrix (the character-side\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\nreference doc — both must pass for an equip to succeed).\n\n```ts\ninterface ItemEquipment {\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\n UseRequirements?: Record<string, number>; // statID -> required character stat level\n AllowedCharacterIDs?: string[]; // null/empty = any character\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\n}\n```\n\n---\n\n## ItemUpgrade + cost formula\n\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\n`upgradeLevelsBatch`.\n\n```ts\ninterface ItemUpgrade {\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\n BaseCostResource?: ResourceConsume; // cost of the step from level 1 to level 2\n CostScalingFactor?: number; // linear cost growth per level; 0 = flat cost at every level\n FlatScalingFactor?: number; // ItemStats.FlatBonuses growth per level\n PercentScalingFactor?: number; // ItemStats.PercentBonuses growth per level\n PowerScalingFactor?: number; // ItemStats.Power growth per level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = round(BaseCostResource.Amount * (1 + CostScalingFactor * (N - 1)))\n```\n\n— identical semantics to the Character module's `StatDefinition.BaseCostResource`\nscaling. `N - 1` means the level-1→2 step costs exactly `BaseCostResource`,\nunscaled. A multi-level upgrade (`Levels` / `TargetLevel`) charges the **sum**\nof this formula for every level from `current + 1` through the resolved target\n— it is not a single jump priced off the destination level alone. If every\nscaled amount in `BaseCostResource` rounds to `0`, or `BaseCostResource` is\nempty, the upgrade is rejected as misconfigured rather than treated as free.\n\n`BaseCostResource`'s optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (all use the same `1 + factor *\n(L - 1)` shape, base level is 1):\n\n- Flat bonus effective multiplier: `1 + FlatScalingFactor * (L - 1)` applied to\n each `ItemStats.FlatBonuses` value.\n- Percent bonus effective multiplier: `1 + PercentScalingFactor * (L - 1)`\n applied to each `ItemStats.PercentBonuses` value, before aggregation into\n the character's total gear-percent.\n- Effective Power: `round(ItemStats.Power * (1 + PowerScalingFactor * (L - 1)))`,\n added into `CharacterModel.Power` alongside stat-based Power (the two are\n simple sums — designers balance any double-counting themselves via weights).\n\nA `Factor` of `0` on any of these means that quantity does not grow with\nlevel — only the base value applies at every level. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n MergeRatio?: number; // used when ValuationMode === \"Merge\"; typical 2..5\n Selection?: \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = round(MergeRatio ^ (L - 1))`, rounded away from zero | `MergeRatio <= 1` degenerates to `W(L) = 1` (flat). Reads as \"`MergeRatio` copies of level `L` ≈ one copy of level `L+1`\". |\n| `InvestmentRefund` | `W(L) = 1 + Σ_{k=2}^{L} round(BaseSelfAmount * (1 + CostScalingFactor * (k - 1)))`, each term rounded away from zero | `BaseSelfAmount` is the self-item `Amount` found inside `Upgrade.BaseCostResource` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: string | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
|
|
8
|
+
"content": "# Item data model — reference\r\n\r\nFull shape of the item config (`ItemDefinitions`), the upgrade request/response\r\ntypes, the upgrade cost/fodder formulas (transcribed from the backend), the\r\ncatalog-resolution rule, and the player-state (inventory) shapes. All of these\r\nare **strictly typed in the SDK** — `ItemDefinitions` and every nested block\r\n(`ItemDefinition`, `ItemStats`, `ItemEquipment`, `ItemUpgrade`, `ItemMetadata`,\r\n`NFTModel`, …) are exported from `@idosgames/core`. The schemas keep\r\n`.passthrough()`, so a field the backend adds later still round-trips. Field\r\nnames are PascalCase (straight from the backend JSON).\r\n\r\n## Contents\r\n\r\n- [Config: ItemDefinitions](#config-itemdefinitions) — root catalog container\r\n- [ItemCatalog](#itemcatalog)\r\n- [Catalog resolution rule](#catalog-resolution-rule) — strict → fallback, self-heal, ambiguity\r\n- [ItemDefinition](#itemdefinition)\r\n- [ItemStats](#itemstats)\r\n- [ItemEquipment](#itemequipment)\r\n- [ItemUpgrade + cost formula](#itemupgrade--cost-formula)\r\n- [Fodder valuation + selection modes](#fodder-valuation--selection-modes)\r\n- [ItemMetadata](#itemmetadata)\r\n- [NFTModel](#nftmodel)\r\n- [Player state: InventoryV2](#player-state-inventoryv2)\r\n- [Requests, responses, and actions](#requests-responses-and-actions)\r\n\r\n---\r\n\r\n## Config: ItemDefinitions\r\n\r\nRoot container for every item catalog in the title.\r\n\r\n```ts\r\ninterface ItemDefinitions {\r\n Catalogs?: Record<string, ItemCatalog> | null; // key = CatalogID\r\n}\r\n```\r\n\r\n`ItemDefinitions` and `ItemCatalog` are given explicit `z.ZodType` annotations\r\nin the SDK rather than inferred — the fully-inferred passthrough tree is deep\r\nenough that `tsc` won't serialize it for the emitted declaration (TS7056), so\r\nthe exported type is pinned to a hand-written interface instead.\r\n\r\n## ItemCatalog\r\n\r\nA themed grouping of items (e.g. \"Weapons\", \"Consumables\").\r\n\r\n```ts\r\ninterface ItemCatalog {\r\n Items?: Record<string, ItemDefinition> | null; // key = ItemID\r\n}\r\n```\r\n\r\nAn item's full address is the pair `(CatalogID, ItemID)`. `ItemID` is only\r\nguaranteed unique **within** a catalog — the same `ItemID` string can\r\nlegitimately appear in more than one catalog, which is exactly what the\r\nresolution rule below has to handle.\r\n\r\n## Catalog resolution rule\r\n\r\nEvery server-side item lookup (upgrade, equip, battle stat calc, …) goes\r\nthrough one canonical resolver (`ItemCatalogResolver.Resolve`, backend\r\n`IDosGamesSDK/API/Client/v2/Item/Services/ItemCatalogResolver.cs`). You don't\r\ncall this yourself, but its behavior explains error messages and a\r\nself-healing field you'll see on upgrade responses:\r\n\r\n1. **Strict match** — if the instance carries a non-empty `CatalogID`, look up\r\n `(CatalogID, ItemID)` directly. If found, done — this catalog is\r\n unambiguous by construction.\r\n2. **Fallback scan** — if `CatalogID` is empty, or the strict lookup misses\r\n (the item was moved to a different catalog since the instance was granted),\r\n scan every catalog for `ItemID`. If it's found in **exactly one** catalog,\r\n that's the resolved definition.\r\n3. **Ambiguous → not found** — if the fallback scan finds `ItemID` in **two or\r\n more** catalogs, the resolver refuses to guess and returns nothing (the\r\n caller reports \"item definition not found\").\r\n\r\n**Self-heal:** when resolution succeeds via the fallback path with a\r\n`CatalogID` different from what was stored on the instance, `upgradeLevel` /\r\n`upgradeLevelsBatch` patch the instance's stored `CatalogID` to the resolved\r\none as part of the same atomic write — silently, no separate event. That's why\r\n`UpgradeItemLevelResponse.CatalogID` can differ from what you last read off\r\nthe instance before calling upgrade: read it back off the response / refreshed\r\ncache, don't assume it's unchanged.\r\n\r\n---\r\n\r\n## ItemDefinition\r\n\r\nThe template from which player instances are created.\r\n\r\n```ts\r\ninterface ItemDefinition {\r\n ItemID: string;\r\n CatalogID: string;\r\n ItemClass?: string; // free-form category: \"Weapon\",\"Armor\",\"Consumable\",\"Sticker\",\"LootBox\",\"Cosmetic\",...\r\n DisplayName?: string;\r\n Description?: string;\r\n Tags?: string[]; // free-form: \"rare\",\"event_halloween_2026\",\"tradable\",\"seasonal\",...\r\n CustomData?: string;\r\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\r\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\r\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\r\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\r\n NFT?: NFTModel; // blockchain binding, if any\r\n Stats?: ItemStats;\r\n Equipment?: ItemEquipment;\r\n Upgrade?: ItemUpgrade;\r\n Metadata?: ItemMetadata;\r\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\r\n}\r\n```\r\n\r\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\r\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\r\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\r\nconsumable can still have upgrade tiers, and an equippable item can be\r\nnon-upgradable.\r\n\r\n---\r\n\r\n## ItemStats\r\n\r\nStat modifiers/Power the item contributes when equipped. Applied in two\r\nlayers — flat bonuses added to the base stat first, then percent bonuses\r\nmultiply the (base + flat) total. Consumed by the Character module's Power\r\ncomputation (see character-system skill) — never recomputed client-side.\r\n\r\n```ts\r\ninterface ItemStats {\r\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\r\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\r\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\r\n}\r\n```\r\n\r\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\r\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\r\n\r\n---\r\n\r\n## ItemEquipment\r\n\r\nThe item-side half of the two-sided equip rule matrix (the character-side\r\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\r\nreference doc — both must pass for an equip to succeed).\r\n\r\n```ts\r\ninterface ItemEquipment {\r\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\r\n UseRequirements?: Record<string, number>; // statID -> required character stat level\r\n AllowedCharacterIDs?: string[]; // null/empty = any character\r\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\r\n}\r\n```\r\n\r\n---\r\n\r\n## ItemUpgrade + cost formula\r\n\r\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch`.\r\n\r\n```ts\r\ninterface ItemUpgrade {\r\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\r\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\r\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\r\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\r\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\r\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\r\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\r\n}\r\n```\r\n\r\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\r\nnot the step count):\r\n\r\n```\r\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\r\n// BaseCost = the Cost of the selected PriceOptions option\r\n```\r\n\r\n— identical semantics to the Character module's stat-cost scaling, and the same shared\r\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\r\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\r\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\r\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\r\nfrom `current + 1` through the resolved target — it is not a single jump priced\r\noff the destination level alone. If every scaled amount rounds to `0`, or the\r\nselected option is empty, the upgrade is rejected as misconfigured rather than\r\ntreated as free.\r\n\r\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\r\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\r\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\r\nfor the first option available on the caller's platform.\r\n\r\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\r\nthrough unchanged and resolved by the shared premium pipeline per level before\r\nthe per-level bundles are summed — see the currency-system skill for\r\n`ResourceConsume`'s premium fields.\r\n\r\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\r\nwith `firstStep = 1`, so level 1 is the plain base):\r\n\r\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\r\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\r\n before aggregation into the character's total gear-percent.\r\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\r\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\r\n balance any double-counting themselves via weights).\r\n\r\nAn **unset** curve means that quantity does not grow with level — only the base value\r\napplies at every level. There is no field here whose neutral value is `1`: empty is the\r\nneutral, always. These three are read-only\r\ninputs to server computations (Power, PvP stat calc); the SDK never\r\nrecomputes them for you.\r\n\r\n---\r\n\r\n## Fodder valuation + selection modes\r\n\r\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\r\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\r\nand picked when the upgrade's own cost is expressed in copies of itself. Any\r\nother cost entries (currencies, other items, event tokens) are charged\r\nnormally through the regular resource pipeline regardless of `Fodder` config.\r\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\r\n`ProtectLeveled` selection.\r\n\r\n```ts\r\ninterface ItemUpgradeFodder {\r\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\r\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\r\n Selection?: \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\";\r\n}\r\n```\r\n\r\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\r\n\r\nThe server expresses the self-item portion of the upgrade cost as a **target\r\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\r\nburns fodder copies until their summed `W(level)` meets or exceeds that\r\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\r\n\r\n| Mode | `W(L)` formula | Notes |\r\n| ------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\r\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\r\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\r\n\r\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\r\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\r\nclient-side for a cost preview, but the server is what actually enforces\r\ncoverage.\r\n\r\n### Selection — which instances get burned\r\n\r\nOnly relevant when the caller doesn't already specify exact fodder for every\r\nunit needed (or when supply must be chosen automatically):\r\n\r\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\r\n eligible; anything the player has already leveled up is never auto-selected\r\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\r\n in the sense that the pool is still filtered this way in `FlatCount` mode\r\n (where fodder isn't weighted at all — see below).\r\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\r\n same-item/same-catalog, not equipped, not expired, not already claimed by\r\n another item in the same batch) are sorted by `W(level)` ascending, then by\r\n acquisition time, then by ID, and burned cheapest-first until the target\r\n value is covered. Leveled copies are eligible here and burn last (they're\r\n worth more per unit, so they're a poor early pick under this greedy order).\r\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\r\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\r\n validated individually (must exist, must be the same item/catalog, must not\r\n be equipped or expired, must not already be claimed elsewhere in the same\r\n batch) and rejected by name if any check fails. If the combined `W(level)`\r\n of your supplied instances doesn't cover the target, the whole upgrade is\r\n rejected — nothing is partially burned.\r\n\r\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\r\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\r\nthat mode the self-item cost is settled by the _regular_ resource-consume\r\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\r\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\r\nrather than being burned), and the rest come from plain inventory count, with\r\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\r\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\r\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\r\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\r\nself-item cost entry, or fodder that's a different item, is rejected.\r\n\r\n---\r\n\r\n## ItemMetadata\r\n\r\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\r\nsubsystem and general UI.\r\n\r\n```ts\r\ninterface ItemMetadata {\r\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\r\n CollectionID?: string; // ties the item into a Collection set/album page\r\n AuthorID?: string; // e.g. UGC/creator attribution\r\n}\r\n```\r\n\r\n---\r\n\r\n## NFTModel\r\n\r\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\r\nitem mirrored on both an EVM chain and Solana).\r\n\r\n```ts\r\ninterface NFTModel {\r\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\r\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\r\n}\r\n\r\ninterface NFTNetworkBinding {\r\n ContractAddress?: string; // EVM contract or Solana mint address\r\n TokenID?: string;\r\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\r\n}\r\n```\r\n\r\nSee the blockchain-system skill for the wallet/mint/transfer flows that\r\npopulate and consume this binding.\r\n\r\n---\r\n\r\n## Player state: InventoryV2\r\n\r\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\r\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\r\n`upgradeLevelsBatch` (and other inventory-affecting calls).\r\n\r\n```ts\r\ninterface UserInventoryState {\r\n Version?: number;\r\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\r\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\r\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\r\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\r\n ConversionDaily?: Record<string, ConversionDailyCounter>;\r\n}\r\n\r\ninterface ItemTotals {\r\n StackableAmount: number;\r\n UnstackableAmount: number;\r\n TotalAmount: number;\r\n}\r\n\r\ninterface UnstackableItemInstanceState {\r\n ItemInstanceID: string;\r\n ItemID: string;\r\n CatalogID?: string | null;\r\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\r\n RemainingUses?: number; // consumable-with-charges items\r\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\r\n AcquiredAt: string;\r\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\r\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\r\n CustomData?: string | null;\r\n}\r\n```\r\n\r\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\r\ncurrency-system skill; they ride along in the same inventory snapshot but\r\naren't item-related.\r\n\r\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\r\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\r\nwhich half of the inventory backs it.\r\n\r\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\r\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\r\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\r\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\r\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\r\none copy — e.g. leveling one copy of a stack of five identical swords — the\r\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\r\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\r\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\r\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\r\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\r\ncome back as a **different** id than the one you called with — always read\r\nthe instance id off the response (or the refreshed cache), don't assume it's\r\nunchanged. The backend also opportunistically re-merges pristine fragments of\r\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\r\nyou don't need to do anything to trigger or handle that.\r\n\r\n---\r\n\r\n## Requests, responses, and actions\r\n\r\n```ts\r\ninterface ItemRequest extends BaseRequest {\r\n ItemInstanceID?: string;\r\n Levels?: number;\r\n TargetLevel?: number;\r\n FodderInstanceIDs?: string[];\r\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\r\n Upgrades?: ItemUpgradeRef[];\r\n}\r\n```\r\n\r\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\r\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\r\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\r\n**not** expose them — it only ever raises by one level per call. To move\r\nseveral levels in one call (on one or many instances), use\r\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\r\n\r\n```ts\r\ninterface ItemUpgradeRef {\r\n ItemInstanceID?: string;\r\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\r\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\r\n FodderInstanceIDs?: string[];\r\n}\r\n\r\ninterface FodderConsumedEntry {\r\n ItemInstanceID: string;\r\n Units: number; // how many copies of this instance/pack were burned\r\n Level: number; // the fodder instance's level at time of consumption\r\n}\r\n\r\ninterface UpgradeItemLevelResponse {\r\n ServerTimeUtc: string;\r\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\r\n ItemID: string;\r\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\r\n Level: number; // new level after the upgrade\r\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\r\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\r\n}\r\n\r\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\r\n```\r\n\r\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\r\nuseful when reading logs/errors that echo the action):\r\n\r\n```ts\r\nconst ItemAction = {\r\n UpgradeLevel: \"UpgradeLevel\",\r\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\r\n} as const;\r\n```\r\n\r\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\r\nshape used across the whole SDK — see the currency-system skill for the full\r\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\r\n`PremiumDiscounts` can reduce a displayed base cost.\r\n\r\n### Server-side limits (verified against the backend)\r\n\r\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\r\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\r\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\r\n appear in the result array at all. Chunk larger sets yourself.\r\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\r\n id in the same call only processes once.\r\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\r\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\r\n (single call fails outright; batch reports it as a failed item).\r\n- **Atomicity**: both the single and batch charge/patch happen inside one\r\n Mongo transaction — either the whole thing (cost + level + any fodder burns\r\n - owner Power recompute) applies, or none of it does.\r\n- **Idempotency**: the backend replays the same result for a repeated call\r\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\r\n server-side reason key, so re-running the _same target level_ twice is safe\r\n to retry). The TS `upgradeLevel` method, however, mints a fresh\r\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\r\n from the SDK's side, two separate calls are always two separate operations;\r\n see the Gotchas section in SKILL.md.\r\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "localization-system",
|
|
3
3
|
"description": "Translate a game built on the iDosGames TypeScript SDK (@idosgames/core) via client.localization (LocalizationService): translate a key with t(), read the resolved locale, list the languages the title offers, switch the player's language, handle plurals and placeholders, and react to the localization:changed event. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants translations, multiple languages, i18n, a language picker, localized item/quest/store names, plural forms, or otherwise touches client.localization, LocalizationService, LocalizationState, LocalizationDefinitions, or t() — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: localization-system\ndescription: >-\n Translate a game built on the iDosGames TypeScript SDK (@idosgames/core) via\n client.localization (LocalizationService): translate a key with t(), read the\n resolved locale, list the languages the title offers, switch the player's\n language, handle plurals and placeholders, and react to the\n localization:changed event. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and\n wants translations, multiple languages, i18n, a language picker, localized\n item/quest/store names, plural forms, or otherwise touches\n client.localization, LocalizationService, LocalizationState,\n LocalizationDefinitions, or t() — even if they don't name the module\n explicitly.\n---\n\n# Localization (iDosGames TS SDK)\n\nTranslations live **outside the title config**, in their own store, delivered\n**one file per locale**. The config only says which languages exist and which\none is the fallback. The client never loads them by hand: tables arrive with\nthe player's state at login, and `t()` reads them synchronously.\n\n## The one rule that explains everything\n\n```\nt(x) = your table → fallback table → x itself\n```\n\nThe last step is not error handling — it is the design. A title whose config\nholds literal names (`DisplayName: \"Iron Sword\"`) works **unchanged**: a\nliteral is simply a key with no translation. So you can wrap
|
|
4
|
+
"content": "---\nname: localization-system\ndescription: >-\n Translate a game built on the iDosGames TypeScript SDK (@idosgames/core) via\n client.localization (LocalizationService): translate a key with t(), read the\n resolved locale, list the languages the title offers, switch the player's\n language, handle plurals and placeholders, and react to the\n localization:changed event. Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and\n wants translations, multiple languages, i18n, a language picker, localized\n item/quest/store names, plural forms, or otherwise touches\n client.localization, LocalizationService, LocalizationState,\n LocalizationDefinitions, or t() — even if they don't name the module\n explicitly.\n---\n\n# Localization (iDosGames TS SDK)\n\nTranslations live **outside the title config**, in their own store, delivered\n**one file per locale**. The config only says which languages exist and which\none is the fallback. The client never loads them by hand: tables arrive with\nthe player's state at login, and `t()` reads them synchronously.\n\n## The one rule that explains everything\n\n```\nt(x) = your table → fallback table → x itself\n```\n\nThe last step is not error handling — it is the design. A title whose config\nholds literal names (`DisplayName: \"Iron Sword\"`) works **unchanged**: a\nliteral is simply a key with no translation. So you can wrap _everything_ in\n`t()`, including strings that came out of the title config, and nothing breaks\nbefore a single word has been translated.\n\n```ts\nt(item.DisplayName); // \"Железный меч\" if translated, \"Iron Sword\" if not\n```\n\n## Basic use\n\n```ts\nconst { localization } = client;\n\nlocalization.t(\"quest.daily.title\"); // → \"Ежедневное задание\"\nlocalization.locale; // → \"ru\" (what the server RESOLVED, not what you asked for)\nlocalization.has(\"store.button.buy\"); // → true\n```\n\n**Nothing to load.** `client.user.getClientState*` (and therefore login)\nbrings the tables in. Do not call anything at startup to \"initialize\"\nlocalization.\n\n### Placeholders\n\n```ts\nlocalization.t(\"shop.greeting\", { name: player.name }); // \"Привет, Аня!\"\n```\n\nA parameter you didn't pass stays visible as `{name}`. That is deliberate: a\nplaceholder on screen gets noticed and fixed; a silently dropped fragment of a\nsentence does not.\n\n### Plurals\n\nStore one entry per CLDR category, suffixed:\n\n```\nitems.count.one = \"{count} предмет\"\nitems.count.few = \"{count} предмета\"\nitems.count.many = \"{count} предметов\"\n```\n\n```ts\nlocalization.t(\"items.count\", { count: 7 }); // \"7 предметов\"\n```\n\nPass `count` and the SDK picks the category with `Intl.PluralRules` **for the\ncurrent locale**, falling back to `.other` and then to the bare key. Don't\nhand-roll plural rules: Russian has three forms, Polish four, Arabic six.\n\n## Two tables, not one\n\nThe player's own locale is always downloaded. The **fallback** (the title's\ndefault language) is downloaded _only_ when the player's language is not fully\ntranslated — the server computes coverage and says so:\n\n```ts\nlocalization.fallbackLocale; // \"en\" → partially translated, or null → complete\n```\n\nA fully translated language therefore carries **one** file, and edits to the\ndefault language cost that player nothing. This is why coverage is computed\nserver-side and why you should not merge tables yourself.\n\n## Language picker\n\n```ts\nlocalization.locales;\n// [{ Locale: \"en\", DisplayName: \"English\", Order: 0 },\n// { Locale: \"ru\", DisplayName: \"Русский\", Order: 1 }]\n\nawait localization.setLocale(\"ru\");\n```\n\n`DisplayName` is an **endonym** — the language's name in that language. The\npicker is read by someone who may not know the language currently on screen,\nso never translate it.\n\n`setLocale` fetches through the API rather than the CDN, caches the result, and\nemits `localization:changed`. It resolves what the server actually gave you:\n\n```ts\nconst result = await localization.setLocale(\"pt-BR\");\nif (result.ok) console.log(result.data); // \"pt-br\", or \"pt\", or \"en\"\n```\n\n## Redraw on change\n\n`t()` is synchronous, so labels you already drew will not update themselves:\n\n```ts\nclient.on(\"localization:changed\", ({ locale, fallbackLocale }) => {\n redrawAllLabels();\n});\n```\n\nIt fires on login, on `setLocale`, and whenever the tables change.\n\n## Anything shown BEFORE login is not translatable\n\nTables arrive with the player's state, and that call needs a session. So the login screen —\nand any splash, consent gate or error shown before the player is authenticated — **cannot** get\nits text from the localization tables. Wrapping those strings in `t()` compiles fine and then\nrenders the key.\n\nShip those strings in the build (a plain object in the game's source, keyed by device language).\nThis is a deliberate decision, not a gap waiting to be filled: serving them would need an\nanonymous endpoint, and the owner chose baked-in strings instead.\n\nEverything after `client.auth.login*` resolves normally — including the very first screen the\nplayer sees once logged in.\n\n## Things that will bite you\n\n- **`settings.locale` is a wish, `localization.locale` is the fact.** The\n server resolves `pt-BR` → `pt` → `en` against what the title actually has.\n Cache and compare against the resolved value.\n- **`Version: 0` means the language exists but has no translations yet.** Not\n an error — `t()` returns keys, and the game runs.\n- **A missing table never fails the game.** Unlike the title config (no config\n = no game), losing translations degrades to keys and keeps playing.\n- **Don't read `config.Localization.Locales` for text.** That section holds\n settings only; the translations are not in the config and never will be.\n- **Don't poll.** There is nothing to poll — the tables change only when the\n publisher edits them, and the server tells you via the version handshake.\n\n## Registering keys as you write code\n\nIf you are connected to the platform's title-data MCP, you have two tools for this:\n\n- `get_localization([prefix])` — the keys that already exist, with their default-language text.\n **Call it before inventing a key.** Two keys for the same label means the publisher translates\n the same words twice and one copy silently goes stale.\n- `save_localization({ keys })` — create or update keys in the title's **default** language.\n Partial: only the keys you send are written, the rest of the table is left alone (people\n translate it too). Do not write other locales — translators and machine translation fill those,\n and a string that exists only in a target language never shows up in the coverage report.\n\nWrite the key and register it **in the same turn** as the code that uses it. Writing\n`t('shop.button.buy')` without registering the key is not broken — it renders the key — but it\nleaves the publisher a label they cannot find in the dashboard.\n\nNamespace by module (`shop.`, `quest.`, `board.`); a flat table of a thousand unprefixed keys\ncannot be filtered by anyone.\n\n## Where the strings come from\n\nThe publisher edits them in the dashboard (LiveOps → Localization), imports a\nCSV/XLIFF, or has them machine-translated. Nothing in the game writes\ntranslations — they are shared by every player of the title, exactly like the\nrest of its config.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lootbox-system",
|
|
3
3
|
"description": "Build a lootbox / gacha / loot-crate system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load lootbox definitions (reward slots, weighted pools, price options, pity rules) and open one or many boxes for randomized rewards, including hard-pity tracking. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants loot crate / gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or bad-luck-protection systems, or otherwise touches client.lootbox, LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or UserLootboxState — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: lootbox-system\ndescription: >-\n Build a lootbox / gacha / loot-crate system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load\n lootbox definitions (reward slots, weighted pools, price options, pity\n rules) and open one or many boxes for randomized rewards, including\n hard-pity tracking. Use this whenever the user is working in the iDosGames\n TS SDK or its game templates (board-game, idle-rpg) and wants loot crate /\n gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or\n bad-luck-protection systems, or otherwise touches client.lootbox,\n LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or\n UserLootboxState — even if they don't name the module explicitly.\n---\n\n# Lootbox system (iDosGames TS SDK)\n\nThe Lootbox module lets a title define reward crates: each box has one or more\n**reward slots**, each slot rolls a configurable number of times over a\nweighted **pool** of possible rewards, and boxes can carry **pity rules** that\ngrant an extra guaranteed roll from the rule's own pool every `Threshold`\nopens of that box. It's **server-authoritative**: the client asks the backend\nto open N boxes, the backend rolls every reward, applies pity, and returns the\nfull breakdown; the SDK mirrors granted resources and pity counters into the\nlocal cache. You never roll the loot yourself — you call `open()`, check the\nresult, and render from the response + cache.\n\nThis skill is for **using** the production `LootboxService`, not for porting\nor extending it. If an open is rejected, that's the backend enforcing a rule\n(cost, unknown box/option) — surface the error, don't try to reproduce the\nroll or the pity math client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n lootboxes: price options, reward slots + weighted pools, and pity rules.\n Fetched with `getDefinitions()`.\n2. **Pity state** (state, per player) — how many times each pity rule has\n fired and its running open-counter. There's no dedicated getter for this;\n it rides in on `open()`'s response and on the general user-state bootstrap\n (`client.user.getClientState()`).\n\nA lootbox is identified by a string `LootboxID`. Reward slots and pity rules\nroll over a shared **weighted pool** primitive (`LootboxRewardRoll`) — the same\nshape used by the Collection module's bonus slots. For the full formulas\n(weighted-pick algorithm, pity threshold math, the catalog pre-filter, the\noptional reward-progression multiplier), read\n[references/data-model.md](references/data-model.md). You don't need it to\ncall the two methods below — only to drive richer config-preview UI (odds,\npity countdowns) or to reason about an edge case.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst lootbox = client.lootbox; // the LootboxService\n```\n\nEvery lootbox method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — missing\n`LootboxID` or `count < 1`), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the 600ms client-side throttle window), `\"connection\"`\n(transient, offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` carries the human-readable reason,\ne.g. `\"Lootbox config not found.\"`, `\"Price option {id} not found.\"`,\n`\"Price option has empty RequiredResources.\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------ | ---------------------------------------------- | ---------------------------- |\n| `getDefinitions()` | Load the title's lootbox catalog (config). | `LootboxDefinitionsResponse` |\n| `open(lootboxID, count, selectedOptionID)` | Pay and open `count` boxes in one atomic call. `count` is clamped server-side to the lootbox's `MaxOpenCount` → `Settings.MaxOpenCount` → platform default (100); `OpenedCount` reports what actually happened. | `LootboxOpenResponse` |\n\n`selectedOptionID` is a **number** key into the box's `PriceOptions` map (each\noption is a distinct price, e.g. one gem price and one real-money-currency\nprice) — there's no default, you must pick one. `count` opens that many boxes\nat once; the server clamps it to `[1, 100]` regardless of what you send, then\ncharges `count` times the selected option's price (grouped/summed per\ncurrency and item, not one charge per box) and rolls each box independently —\npity can trigger more than once mid-batch if `count` is large enough.\n\nOn success, `open()`:\n\n- applies any `data.TriggeredPity` entries into the cached per-rule pity\n counters (`client.data.user.state?.Lootbox?.Pity`), resetting\n `OpensSinceLastTrigger` to `0` and stamping `LastTriggeredAtUtc` — this also\n fires `user:lootboxUpdated`;\n- applies `data.Resources` (consumed price / granted rewards) to the cached\n currency and item balances via the shared resource-operation pipeline —\n this fires `user:inventoryUpdated`/`user:virtualCurrencyUpdated`/\n `user:eventTokenUpdated` as appropriate, **not** `user:lootboxUpdated`.\n\nRead updated balances and pity state from the cache as usual.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LootboxDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\n// Pity counters (only present once an open() has triggered pity at least once,\n// or after a full client.user.getClientState() bootstrap):\nconst pity = client.data.user.state?.Lootbox?.Pity ?? {};\npity[\"box1:pity1\"]?.OpensSinceLastTrigger;\npity[\"box1:pity1\"]?.LastTriggeredAtUtc;\n```\n\nThe pity cache key is `` `${lootboxID}:${ruleID}` ``. There is no\n`getUserLootboxState()` — the module has no state-fetch method of its own, and\nthe local cache only ever resets a counter to `0` on a trigger; it does not\nlocally increment it on non-triggering opens. The **server** does persist the\ntrue incremented counter on every open, and that authoritative `Lootbox.Pity`\nmap comes down as part of the user-profile bootstrap\n(`client.user.getClientState()` → `UserState.Lootbox`). So: treat the\nlocally-patched cache as \"when did this rule last fire,\" and refresh via\n`getClientState()` when you need a live \"N opens until pity\" countdown.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `lootbox:definitionsLoaded` → `LootboxDefinitions`\n- `lootbox:opened` → `LootboxOpenResponse`\n\nThe coarse `user:lootboxUpdated` fires specifically when pity state is\nwritten (i.e. only on calls whose response included `TriggeredPity`) — an\n`open()` that didn't trigger any pity rule won't fire it, even though\nbalances still changed (via `user:inventoryUpdated` etc.). The umbrella\n`user:anyUpdated` fires on both paths, so prefer that for a generic\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"lootbox:opened\", (r) => {\n console.log(`Opened ${r.OpenedCount}x ${r.LootboxID}`);\n r.TriggeredPity?.forEach((p) => console.log(`Pity fired: ${p.RuleID}`));\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and preview a box's odds\n\n```ts\nawait client.lootbox.getDefinitions();\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\nfor (const [lootboxID, def] of Object.entries(defs?.Definitions ?? {})) {\n def.PriceOptions; // Record<optionID (stringified number), { PriceOptionID, RequiredResources }>\n def.RewardSlots; // [{ SlotID, MinRolls, MaxRolls, Pool: [{ Reward, Weight, AmountRange }] }]\n def.PityRules; // [{ RuleID, Threshold, Pool }]\n}\n```\n\nEach `RewardSlot` rolls a random number of times uniformly in\n`[MinRolls, MaxRolls]`; each roll independently picks one entry from `Pool`\nweighted by `Weight` (optionally randomizing the granted `Amount` within\n`AmountRange`). Use this to show odds/rates in a UI, but the actual roll\nalways happens server-side — never let the client compute or pre-determine\nthe outcome.\n\n### Open a single box\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 1, 1);\nif (!res.ok) return showError(res.error); // e.g. can't afford\nres.data.Resources; // aggregated grant (already applied to cache)\nres.data.Results; // per-box ResourceOperation breakdown (one entry, for count=1)\n```\n\n### Open in bulk and surface pity\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 10, 1);\nif (!res.ok) return showError(res.error);\n\nconsole.log(`Opened ${res.data.OpenedCount} boxes`);\nfor (const trigger of res.data.TriggeredPity ?? []) {\n showPityToast(trigger.RuleID, trigger.BoxIndex); // BoxIndex = which box in Results triggered it\n}\n// balances/items already reflected in client.data.user.*\n```\n\nThe charge is atomic across the whole batch (one merged debit for all `count`\nboxes), but each box still rolls independently — some boxes in the batch can\ntrigger pity while others don't, and with a large `count` a single rule can\ntrigger more than once.\n\n### Display a pity progress bar\n\n```ts\nawait client.user.getClientState(); // refresh authoritative pity counters\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\nconst counter =\n client.data.user.state?.Lootbox?.Pity?.[\"box1:guaranteed_legendary\"];\nconst threshold = defs?.Definitions?.[\"box1\"]?.PityRules?.find(\n (r) => r.RuleID === \"guaranteed_legendary\",\n)?.Threshold;\n\nconst opensSince = counter?.OpensSinceLastTrigger ?? 0;\nconst remaining = threshold ? threshold - opensSince : undefined; // opens left until guaranteed\n```\n\nDon't derive `remaining` from the locally-patched cache after an `open()`\ncall unless that call's response included this exact `RuleID` in\n`TriggeredPity` (which resets it to `0`) — otherwise the local cache is stale\nfor non-triggering opens and you should re-fetch via `getClientState()`.\n\n### Show the \"what did I get\" reveal for one open() call\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 5, 2);\nif (!res.ok) return showError(res.error);\n\nres.data.Results?.forEach((box, i) => {\n const items = box.Grant?.Standard?.Entries ?? []; // this box's granted currencies/items\n const wasPityBox = res.data.TriggeredPity?.some((p) => p.BoxIndex === i);\n renderBoxReveal(items, wasPityBox);\n});\n```\n\n`Results[i]` already has the pity reward folded in for the box that triggered\nit, and is pre-filtered for the player's premium tier — so `Results` sums to\n`Resources`. Use `Results` for the per-box reveal animation, and the cache\n(post-`open()`) for running totals/balances.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Open\" can\n charge twice. Disable the control while a call is in flight.\n- **`selectedOptionID` is required and numeric**, unlike Craft's string\n `selectedOptionID` — don't confuse the two modules' option-id types.\n- **Pity is a plain opens-counter, not \"opens since last rare.\"** It counts\n every open of that `LootboxID` regardless of what was rolled from\n `RewardSlots`, and fires in addition to (never instead of) the normal roll.\n It's also keyed per box _and_ per rule (`lootboxID:ruleID`), so a box with\n both a soft-pity and a hard-pity rule tracks them fully independently.\n- **A stale/removed item in a reward pool can't break an open.** The backend\n pre-filters every pool against the title's active item catalogs before\n rolling (`RewardSlotHelpers.SanitizePool`); pool entries that only grant a\n since-deleted item are dropped and their weight redistributes to the rest.\n You don't need client-side defenses against a \"broken\" roll.\n- **`Results` is a list of `ResourceOperation`, not a list of named items** —\n if you need a flattened list of \"what did I get,\" derive it from\n `data.Resources.Grant.Standard.Entries` (and/or walk `Results`) rather than\n expecting a pre-flattened reward array.\n- **An optional `RewardMultiplier` can scale rewards with no visible signal.**\n If a lootbox config has one set, opened rewards are already scaled\n server-side before you see them — there's no getter to preview the current\n multiplier (unlike Reward's `getMilestoneRewardMultiplier()`), so don't\n build a \"boosted rewards\" indicator that tries to recompute it; see\n [references/data-model.md](references/data-model.md).\n- **`user:lootboxUpdated` only fires on a pity write**, not on every\n successful open — a box with no `PityRules` (or one that just didn't\n trigger) updates balances via `user:inventoryUpdated`/\n `user:virtualCurrencyUpdated`/`user:eventTokenUpdated` instead. Use\n `user:anyUpdated` if you want one hook that covers both.\n- **Render from the cache for balances/pity, from the response for the\n \"reward reveal\" animation** — the response is the only place you get the\n full roll breakdown for a single `open()` call as a discrete unit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config field,\nthe weighted-roll and pity-threshold formulas transcribed from the backend,\nthe catalog pre-filter, cost scaling for `count > 1`, and the\nreward-progression multiplier shape. Read it when building config-driven UI\n(odds previews, pity countdowns) or when you need to reason precisely about a\nbatch-open edge case.\n",
|
|
4
|
+
"content": "---\nname: lootbox-system\ndescription: >-\n Build a lootbox / gacha / loot-crate system in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.lootbox (LootboxService): load\n lootbox definitions (reward slots, weighted pools, price options, pity\n rules) and open one or many boxes for randomized rewards, including\n hard-pity tracking. Use this whenever the user is working in the iDosGames\n TS SDK or its game templates (board-game, idle-rpg) and wants loot crate /\n gacha / mystery box UIs, reward-pool or drop-rate config, pity-counter or\n bad-luck-protection systems, or otherwise touches client.lootbox,\n LootboxService, LootboxDefinitions, LootboxRewardSlot, LootboxPityRule, or\n UserLootboxState — even if they don't name the module explicitly.\n---\n\n# Lootbox system (iDosGames TS SDK)\n\nThe Lootbox module lets a title define reward crates: each box has one or more\n**reward slots**, each slot rolls a configurable number of times over a\nweighted **pool** of possible rewards, and boxes can carry **pity rules** that\ngrant an extra guaranteed roll from the rule's own pool every `Threshold`\nopens of that box. It's **server-authoritative**: the client asks the backend\nto open N boxes, the backend rolls every reward, applies pity, and returns the\nfull breakdown; the SDK mirrors granted resources and pity counters into the\nlocal cache. You never roll the loot yourself — you call `open()`, check the\nresult, and render from the response + cache.\n\nThis skill is for **using** the production `LootboxService`, not for porting\nor extending it. If an open is rejected, that's the backend enforcing a rule\n(cost, unknown box/option) — surface the error, don't try to reproduce the\nroll or the pity math client-side.\n\n## The two data shapes\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n lootboxes: price options, reward slots + weighted pools, and pity rules.\n Fetched with `getDefinitions()`.\n2. **Pity state** (state, per player) — how many times each pity rule has\n fired and its running open-counter. There's no dedicated getter for this;\n it rides in on `open()`'s response and on the general user-state bootstrap\n (`client.user.getClientState()`).\n\nA lootbox is identified by a string `LootboxID`. Reward slots and pity rules\nroll over a shared **weighted pool** primitive (`LootboxRewardRoll`) — the same\nshape used by the Collection module's bonus slots. For the full formulas\n(weighted-pick algorithm, pity threshold math, the catalog pre-filter, the\noptional reward-progression multiplier), read\n[references/data-model.md](references/data-model.md). You don't need it to\ncall the two methods below — only to drive richer config-preview UI (odds,\npity countdowns) or to reason about an edge case.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst lootbox = client.lootbox; // the LootboxService\n```\n\nEvery lootbox method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nBoth methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before touching\n`result.data`. `reason` is one of `\"client\"` (bad local args — missing\n`LootboxID` or `count < 1`), `\"unauthorized\"`, `\"throttled\"` (fired the same\nendpoint again inside the 600ms client-side throttle window), `\"connection\"`\n(transient, offer Retry), `\"validation\"` (response/schema drift), or\n`\"server\"` (backend rejected it — `error` carries the human-readable reason,\ne.g. `\"Lootbox config not found.\"`, `\"Price option {id} not found.\"`,\n`\"Price option 'X' is empty.\"`, insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |\n| `getDefinitions()` | Load the title's lootbox catalog (config). | `LootboxDefinitionsResponse` |\n| `open(lootboxID, count, selectedOptionID?, payment?)` | Pay and open `count` boxes in one atomic call. `count` is clamped server-side to the lootbox's `MaxOpenCount` → `Settings.MaxOpenCount` → platform default (100); `OpenedCount` reports what actually happened. | `LootboxOpenResponse` |\n\n`selectedOptionID` is the **key** of the box's `PriceOptions` map (each option is\none way to pay — a gem price, a token price, a store SKU). Omit it and the server\ntakes the first option available on the caller's platform, which is what keeps a\nsingle-price box working unchanged; render the choice with\n`client.checkout.availableOptions(def.PriceOptions)`. An option paid in a store\nadditionally needs `payment` — the receipt from the store SDK; see the\n`checkout-system` skill. `count` opens that many boxes\nat once; the server clamps it to `[1, 100]` regardless of what you send, then\ncharges `count` times the selected option's price (grouped/summed per\ncurrency and item, not one charge per box) and rolls each box independently —\npity can trigger more than once mid-batch if `count` is large enough.\n\nOn success, `open()`:\n\n- applies any `data.TriggeredPity` entries into the cached per-rule pity\n counters (`client.data.user.state?.Lootbox?.Pity`), resetting\n `OpensSinceLastTrigger` to `0` and stamping `LastTriggeredAtUtc` — this also\n fires `user:lootboxUpdated`;\n- applies `data.Resources` (consumed price / granted rewards) to the cached\n currency and item balances via the shared resource-operation pipeline —\n this fires `user:inventoryUpdated`/`user:virtualCurrencyUpdated`/\n `user:eventTokenUpdated` as appropriate, **not** `user:lootboxUpdated`.\n\nRead updated balances and pity state from the cache as usual.\n\n## Reading state and reacting to changes\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LootboxDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\n// Pity counters (only present once an open() has triggered pity at least once,\n// or after a full client.user.getClientState() bootstrap):\nconst pity = client.data.user.state?.Lootbox?.Pity ?? {};\npity[\"box1:pity1\"]?.OpensSinceLastTrigger;\npity[\"box1:pity1\"]?.LastTriggeredAtUtc;\n```\n\nThe pity cache key is `` `${lootboxID}:${ruleID}` ``. There is no\n`getUserLootboxState()` — the module has no state-fetch method of its own, and\nthe local cache only ever resets a counter to `0` on a trigger; it does not\nlocally increment it on non-triggering opens. The **server** does persist the\ntrue incremented counter on every open, and that authoritative `Lootbox.Pity`\nmap comes down as part of the user-profile bootstrap\n(`client.user.getClientState()` → `UserState.Lootbox`). So: treat the\nlocally-patched cache as \"when did this rule last fire,\" and refresh via\n`getClientState()` when you need a live \"N opens until pity\" countdown.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `lootbox:definitionsLoaded` → `LootboxDefinitions`\n- `lootbox:opened` → `LootboxOpenResponse`\n\nThe coarse `user:lootboxUpdated` fires specifically when pity state is\nwritten (i.e. only on calls whose response included `TriggeredPity`) — an\n`open()` that didn't trigger any pity rule won't fire it, even though\nbalances still changed (via `user:inventoryUpdated` etc.). The umbrella\n`user:anyUpdated` fires on both paths, so prefer that for a generic\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"lootbox:opened\", (r) => {\n console.log(`Opened ${r.OpenedCount}x ${r.LootboxID}`);\n r.TriggeredPity?.forEach((p) => console.log(`Pity fired: ${p.RuleID}`));\n});\n// later: off();\n```\n\n## Recipes\n\n### Load the catalog and preview a box's odds\n\n```ts\nawait client.lootbox.getDefinitions();\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\n\nfor (const [lootboxID, def] of Object.entries(defs?.Definitions ?? {})) {\n def.PriceOptions; // Record<OptionID, { OptionID, Name, Cost, AllowedPlatforms }>\n def.RewardSlots; // [{ SlotID, MinRolls, MaxRolls, Pool: [{ Reward, Weight, AmountRange }] }]\n def.PityRules; // [{ RuleID, Threshold, Pool }]\n}\n```\n\nEach `RewardSlot` rolls a random number of times uniformly in\n`[MinRolls, MaxRolls]`; each roll independently picks one entry from `Pool`\nweighted by `Weight` (optionally randomizing the granted `Amount` within\n`AmountRange`). Use this to show odds/rates in a UI, but the actual roll\nalways happens server-side — never let the client compute or pre-determine\nthe outcome.\n\n### Open a single box\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 1, \"gems\");\nif (!res.ok) return showError(res.error); // e.g. can't afford\nres.data.Resources; // aggregated grant (already applied to cache)\nres.data.Results; // per-box ResourceOperation breakdown (one entry, for count=1)\n```\n\n### Open in bulk and surface pity\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 10, \"gems\");\nif (!res.ok) return showError(res.error);\n\nconsole.log(`Opened ${res.data.OpenedCount} boxes`);\nfor (const trigger of res.data.TriggeredPity ?? []) {\n showPityToast(trigger.RuleID, trigger.BoxIndex); // BoxIndex = which box in Results triggered it\n}\n// balances/items already reflected in client.data.user.*\n```\n\nThe charge is atomic across the whole batch (one merged debit for all `count`\nboxes), but each box still rolls independently — some boxes in the batch can\ntrigger pity while others don't, and with a large `count` a single rule can\ntrigger more than once.\n\n### Display a pity progress bar\n\n```ts\nawait client.user.getClientState(); // refresh authoritative pity counters\nconst defs = client.data.config.getSection<LootboxDefinitions>(\"Lootbox\");\nconst counter =\n client.data.user.state?.Lootbox?.Pity?.[\"box1:guaranteed_legendary\"];\nconst threshold = defs?.Definitions?.[\"box1\"]?.PityRules?.find(\n (r) => r.RuleID === \"guaranteed_legendary\",\n)?.Threshold;\n\nconst opensSince = counter?.OpensSinceLastTrigger ?? 0;\nconst remaining = threshold ? threshold - opensSince : undefined; // opens left until guaranteed\n```\n\nDon't derive `remaining` from the locally-patched cache after an `open()`\ncall unless that call's response included this exact `RuleID` in\n`TriggeredPity` (which resets it to `0`) — otherwise the local cache is stale\nfor non-triggering opens and you should re-fetch via `getClientState()`.\n\n### Show the \"what did I get\" reveal for one open() call\n\n```ts\nconst res = await client.lootbox.open(\"box1\", 5, 2);\nif (!res.ok) return showError(res.error);\n\nres.data.Results?.forEach((box, i) => {\n const items = box.Grant?.Standard?.Entries ?? []; // this box's granted currencies/items\n const wasPityBox = res.data.TriggeredPity?.some((p) => p.BoxIndex === i);\n renderBoxReveal(items, wasPityBox);\n});\n```\n\n`Results[i]` already has the pity reward folded in for the box that triggered\nit, and is pre-filtered for the player's premium tier — so `Results` sums to\n`Resources`. Use `Results` for the per-box reveal animation, and the cache\n(post-`open()`) for running totals/balances.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Open\" can\n charge twice. Disable the control while a call is in flight.\n- **`selectedOptionID` is required and numeric**, unlike Craft's string\n `selectedOptionID` — don't confuse the two modules' option-id types.\n- **Pity is a plain opens-counter, not \"opens since last rare.\"** It counts\n every open of that `LootboxID` regardless of what was rolled from\n `RewardSlots`, and fires in addition to (never instead of) the normal roll.\n It's also keyed per box _and_ per rule (`lootboxID:ruleID`), so a box with\n both a soft-pity and a hard-pity rule tracks them fully independently.\n- **A stale/removed item in a reward pool can't break an open.** The backend\n pre-filters every pool against the title's active item catalogs before\n rolling (`RewardSlotHelpers.SanitizePool`); pool entries that only grant a\n since-deleted item are dropped and their weight redistributes to the rest.\n You don't need client-side defenses against a \"broken\" roll.\n- **`Results` is a list of `ResourceOperation`, not a list of named items** —\n if you need a flattened list of \"what did I get,\" derive it from\n `data.Resources.Grant.Standard.Entries` (and/or walk `Results`) rather than\n expecting a pre-flattened reward array.\n- **An optional `RewardMultiplier` can scale rewards with no visible signal.**\n If a lootbox config has one set, opened rewards are already scaled\n server-side before you see them — there's no getter to preview the current\n multiplier (unlike Reward's `getMilestoneRewardMultiplier()`), so don't\n build a \"boosted rewards\" indicator that tries to recompute it; see\n [references/data-model.md](references/data-model.md).\n- **`user:lootboxUpdated` only fires on a pity write**, not on every\n successful open — a box with no `PityRules` (or one that just didn't\n trigger) updates balances via `user:inventoryUpdated`/\n `user:virtualCurrencyUpdated`/`user:eventTokenUpdated` instead. Use\n `user:anyUpdated` if you want one hook that covers both.\n- **Render from the cache for balances/pity, from the response for the\n \"reward reveal\" animation** — the response is the only place you get the\n full roll breakdown for a single `open()` call as a discrete unit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config field,\nthe weighted-roll and pity-threshold formulas transcribed from the backend,\nthe catalog pre-filter, cost scaling for `count > 1`, and the\nreward-progression multiplier shape. Read it when building config-driven UI\n(odds previews, pity countdowns) or when you need to reason precisely about a\nbatch-open edge case.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Lootbox data model — reference\n\nFull shape of the config (`LootboxDefinitions`), the pity/roll formulas\ntranscribed from the backend, and the reward-progression multiplier overlay.\nAll of these are **strictly typed in the SDK** — `LootboxDefinitions` and every\nnested block (`LootboxDefinition`, `LootboxPriceOption`, `LootboxRewardSlot`,\n`LootboxRewardRoll`, `LootboxAmountRange`, `LootboxPityRule`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LootboxDefinitions>(\"Lootbox\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds later\nstill round-trips. Field names are PascalCase (straight from the backend\nJSON).\n\n## Contents\n\n- [Player state](#player-state) — the cached `Lootbox.Pity` map\n- [Config: LootboxDefinitions](#config-lootboxdefinitions) — what `getDefinitions()` returns\n- [LootboxDefinition](#lootboxdefinition)\n- [Price options](#price-options)\n- [Reward slots + weighted-roll math](#reward-slots--weighted-roll-math)\n- [Pity rules + threshold math](#pity-rules--threshold-math)\n- [Catalog pre-filter (SanitizePool)](#catalog-pre-filter-sanitizepool)\n- [Reward-progression multiplier (RewardMultiplier)](#reward-progression-multiplier-rewardmultiplier)\n- [Cost scaling for count > 1](#cost-scaling-for-count--1)\n- [Open response shape](#open-response-shape)\n\n---\n\n## Player state\n\nThere is no `getUserLootboxState()` — the Lootbox module doesn't expose a\nstate-fetch method of its own. What the SDK caches locally lives at\n`client.data.user.state?.Lootbox`:\n\n```ts\ninterface UserLootboxState {\n Pity?: Record<string, UserLootboxPityCounter>; // key = `${LootboxID}:${RuleID}`\n}\ninterface UserLootboxPityCounter {\n OpensSinceLastTrigger: number; // 0..Threshold-1\n LastTriggeredAtUtc: string; // ISO timestamp, last time this rule fired\n}\n```\n\nThe SDK only **writes** this map from `open()`'s `TriggeredPity` — and only\nwhen a trigger actually happened, always resetting `OpensSinceLastTrigger` to\n`0` (`LootboxService.ts` → `ctx.data.user.applyLootboxPityTriggers`, then\n`UserData.applyLootboxPityTriggers` in `cache/UserData.ts`). It never\nincrements the counter locally on a non-triggering open. The **backend**,\nhowever, persists the true incremented counter on every single open\n(`LootboxHelpers.ComputePityApplication`, see below) — that authoritative\n`Lootbox.Pity` map comes down whole as part of `UserState` from\n`client.user.getClientState()`. Call that to get an accurate \"N opens until\npity\" countdown; don't trust the locally-patched cache for anything beyond\n\"did rule X fire, and when.\"\n\n---\n\n## Config: LootboxDefinitions\n\nReturned by `getDefinitions()` as `{ LootboxDefinitions }`; cached via\n`client.data.config.getSection<LootboxDefinitions>(\"Lootbox\")`.\n\n```ts\ninterface LootboxDefinitions {\n Definitions?: Record<string, LootboxDefinition> | null; // key = LootboxID\n}\n```\n\n---\n\n## LootboxDefinition\n\nOne box template in the title catalog (`LootboxDefinition.cs`).\n\n```ts\ninterface LootboxDefinition {\n LootboxID: string;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<string, LootboxPriceOption> | null; // key = numeric PriceOptionID as string\n RewardSlots?: LootboxRewardSlot[] | null;\n PityRules?: LootboxPityRule[] | null;\n RewardMultiplier?: RewardProgressionMultiplierSpec | null; // see below; not a separately exported type\n}\n```\n\n`PriceOptions` is keyed by the **stringified** `PriceOptionID` in the wire\nJSON (a `Dictionary<string, LootboxPriceOption>` on the backend), but you pass\n`selectedOptionID` to `open()` as a **number** — the SDK/backend do the\nstring/number bridging for you (`args.SelectedOptionID.ToString()` in\n`Lootbox.cs`'s `OpenLootbox`).\n\n---\n\n## Price options\n\n```ts\ninterface LootboxPriceOption {\n PriceOptionID?: number;\n RequiredResources?: ResourceConsume; // consume-only: what this option charges\n}\n```\n\n`RequiredResources` is a plain `ResourceConsume` (`Standard` + optional\n`PremiumDiscounts`). The backend requires at least one of: a non-empty\n`Standard.Entries`, a non-empty `Standard.EventTokens`, or a non-empty\n`PremiumDiscounts` list — an option with all three empty rejects with\n`\"Price option has empty RequiredResources.\"` (a box gated entirely behind a\n100%-off premium discount is valid: F2P players simply can't afford it and get\na normal insufficient-funds rejection).\n\n---\n\n## Reward slots + weighted-roll math\n\n```ts\ninterface LootboxRewardSlot {\n SlotID?: string; // analytics/UI/debug id, not roll logic\n MinRolls?: number;\n MaxRolls?: number;\n Pool?: LootboxRewardRoll[];\n}\ninterface LootboxRewardRoll {\n Reward?: ResourceGrant; // grant-only: Standard.Entries / Standard.EventTokens / PremiumTiers\n Weight?: number;\n AmountRange?: LootboxAmountRange; // { Min: number; Max: number }\n}\n```\n\nPer box opened, **every slot rolls independently** (`RewardSlotHelpers.RollSlots`\nin `Services/RewardSlotHelpers.cs`):\n\n1. **Roll count** for the slot is uniform-random in `[MinRolls, MaxRolls]`\n inclusive (`min = max(0, MinRolls)`, `max = max(min, MaxRolls)`). Set\n `MinRolls = MaxRolls = 1` for a guaranteed single roll; `MinRolls = 0` to\n make the whole slot optional.\n2. Each individual roll picks **one** entry from `Pool` by weight: sum all\n `Weight` values (entries with `Weight <= 0` or no `Reward` are skipped),\n draw a uniform random integer in `[0, totalWeight)` via the platform's\n `SecureRandom`, and walk the cumulative weights to find the hit — a\n standard weighted pick, not a percentage table you need to normalize\n yourself.\n3. If the picked entry has `AmountRange`, the final `Amount` is a uniform\n random integer in `[Min, Max]` (inclusive; `max` is clamped to be `>= min`)\n and **replaces** `Amount` on every entry/token inside that roll's `Reward`\n — not just one. This is why the backend comment recommends one resource per\n `AmountRange` entry: a `Reward` with two different currencies sharing one\n `AmountRange` would apply the _same_ rolled number to both.\n4. All rolls across all slots (plus any pity rolls, see below) are merged into\n one `ResourceOperation` by summing same-key entries (same\n `Type`+`CurrencyID`/`ItemID`+`CatalogID`) and same-address event tokens.\n\nThere is no \"duplicate protection\" or per-roll independence guarantee beyond\nwhat `Pool` weights encode — two rolls in the same box can land on the same\npool entry.\n\n---\n\n## Pity rules + threshold math\n\n```ts\ninterface LootboxPityRule {\n RuleID?: string; // stable — renaming resets every player's counter\n Threshold?: number; // must be >= 1\n Pool?: LootboxRewardRoll[]; // same weighted-roll shape as a reward slot's Pool\n}\n```\n\nThis is a **plain running counter of opens**, not \"opens since last rare drop\"\n— it counts every open of this `LootboxID` regardless of what was rolled, and\nis completely independent of `RewardSlots`. Pity rewards are granted **in\naddition to** the normal slot rolls, not instead of them.\n\nPer-rule math for one `open(lootboxID, count, ...)` call\n(`LootboxHelpers.ComputePityApplication` in `Services/LootboxHelpers.cs`):\n\n```\nkey = `${lootboxID}:${RuleID}`\ncurrentCounter = cached counter for key, or 0 if absent\ntotalSteps = currentCounter + count\ntriggers = floor(totalSteps / Threshold) // how many times this rule fires\nnewCounter = totalSteps % Threshold // counter value after this open\n```\n\n- `triggers` can be **more than 1** in a single call when `count` is large\n relative to `Threshold` (e.g. opening 25 boxes against `Threshold = 10`\n starting from counter 8 triggers twice: at step 10 and step 20, ending at\n counter 3).\n- Each trigger does **one independent weighted roll** over the rule's own\n `Pool` (same algorithm as a reward slot roll, including `AmountRange`).\n- Each trigger's `BoxIndex` (0-based, into `Results`/the batch) is computed as\n `(Threshold - 1 - currentCounter) + i * Threshold` for the `i`-th trigger\n (0-based) of that rule within this call — i.e. the exact box in the batch\n that pushed the counter over the threshold. Always in `[0, count - 1]`.\n- Multiple pity rules on the same box are **fully independent**: each tracks\n its own counter under its own `key` and can trigger on different boxes\n within the same batch (e.g. a `Threshold = 10` \"bonus sticker\" rule and a\n `Threshold = 90` \"guaranteed legendary\" rule).\n- A rule with `Threshold <= 0`, no `RuleID`, or an empty `Pool` is skipped\n entirely (never triggers, never patches a counter) — treat it as\n misconfigured rather than \"always trigger\" or \"never trigger by design.\"\n\nThe counter is persisted via a Mongo patch in the **same atomic transaction**\nas the resource grant/consume (`extraPatches` passed into\n`ResourceService.ApplyResourceOperationAtomicAsync`) — a failed/insufficient-funds\nopen never advances the pity counter, and a successful open's counter update\ncan never be \"lost\" relative to the reward it unlocked.\n\n---\n\n## Catalog pre-filter (SanitizePool)\n\nBefore rolling, both `RollSlots` and pity's `RollWithWeight` **pre-filter**\neach `Pool` through the title's active item catalogs\n(`RewardSlotHelpers.SanitizePool`, shared with the Collection module's bonus\nslots):\n\n- Any `Reward.Standard.Entries` item entry that doesn't resolve in\n `ItemDefinitions.Catalogs` (via the same `ItemCatalogResolver` used\n elsewhere, strict match with fallback for a moved-catalog item) is stripped\n from that pool entry's grant.\n- If a pool entry's `Reward` has **no resource left** after stripping (no\n surviving item, currency, or event token in `Standard`, and none in any\n `PremiumTiers` bundle), the whole entry is dropped from the pool — its\n `Weight` is simply excluded from `totalWeight`, so it doesn't dilute the\n remaining valid entries and doesn't produce an empty-reward roll.\n- Currency and event-token entries are **never** stripped — only `Item`-type\n entries are checked against the catalog.\n- If no `ItemDefinitions`/`Catalogs` are supplied at all, the pool is used\n as-is (backward-compatible no-op).\n\nNet effect for you as a consumer: a stale/removed item reference in a\nlootbox's config can never crash or nullify an open — worst case, that one\nweighted slice of the pool silently stops being reachable until the config is\nfixed. You don't need to defend against \"got an empty reward\" client-side.\n\n---\n\n## Reward-progression multiplier (RewardMultiplier)\n\n```ts\ninterface RewardProgressionMultiplierSpec {\n Source?: string; // \"BoardStageLevel\" | \"BoardRank\" | \"BoardCyclesCompleted\"\n // | \"CharacterLevel\" | \"SeasonTier\" | \"EventTokenTotalEarned\"\n // | \"VirtualCurrencyBalance\" | \"PlayerLevel\"\n SourceKey?: string; // e.g. which currency/event-token id, when Source needs a key\n CurveType?: \"Tiered\" | \"Linear\";\n Tiers?: { AtProgress?: number; Multiplier?: number }[]; // used when CurveType === \"Tiered\"\n TierMode?: \"Step\" | \"Linear\"; // interpolation between tiers\n BaseMultiplier?: number;\n PerUnit?: number; // used when CurveType === \"Linear\": multiplier grows per unit of progress\n Anchor?: number;\n MinMultiplier?: number;\n MaxMultiplier?: number;\n IncludeRewards?: ResourceBundle; // restrict which reward entries the multiplier scales\n ExcludeRewards?: ResourceBundle; // takes priority over IncludeRewards\n}\n```\n\nThis is the same platform-wide progression-multiplier overlay used by other\nmodules (Reward's `MilestoneRewardMultiplier`, Season tier rewards, etc.) —\n**not a separate type exported from `@idosgames/core`'s public index**; it\nonly appears structurally as the `RewardMultiplier` field's type inside\n`LootboxDefinition`. Read it off the resolved definitions object, don't try to\n`import type { RewardProgressionMultiplierSpec }` directly.\n\nUnlike Reward's version, Lootbox has **no getter** for the resolved\nmultiplier — there's nothing like `getMilestoneRewardMultiplier()` here. The\nbackend evaluates it internally on every `open()` and applies it silently:\n\n- `null`/absent `RewardMultiplier` → multiplier is always `1.0`, i.e. no-op.\n- Otherwise the backend reads the player's current progress for\n `Source`/`SourceKey`, evaluates the curve (`Tiered`: step or linear\n interpolation between `Tiers` breakpoints, clamped below the first/at-or-above\n the last; `Linear`: `BaseMultiplier + PerUnit * max(0, progress - Anchor)`),\n clamps to `[MinMultiplier, MaxMultiplier]` (`MaxMultiplier <= 0` means\n unbounded above), and scales matching reward entries' `Amount` with\n ceiling-rounding (shared `ModifierService` — same rounding rule used\n platform-wide, not reimplemented per module).\n- The multiplier applies to **both** normal `RewardSlots` rolls and pity\n rewards, scaled **per box** before merging (so `Results[i]` for each box in\n a batch already reflects the multiplier). It never touches the **cost**\n (`PriceOptions`) — only what's granted.\n- `IncludeRewards`/`ExcludeRewards` let the title scope the multiplier to\n specific currencies/items/event-tokens instead of the whole grant; an empty\n `IncludeRewards` means \"everything,\" and `ExcludeRewards` wins on conflict.\n\nBecause this all happens server-side with no exposed getter, there is no\nclient-side way to preview the exact multiplier before opening — if you want\nto show \"your rewards are boosted,\" drive that off whatever domain state\nbacks `Source` (e.g. the player's board stage, character level) rather than\ntrying to recompute the curve.\n\n---\n\n## Cost scaling for count > 1\n\n`open(lootboxID, count, selectedOptionID)` charges `count` times the selected\noption's `RequiredResources.Standard`, computed by grouping+summing\n(`BuildScaledCost` in `Lootbox.cs`): every `VirtualCurrency` entry keyed by\n`CurrencyID` and every `Item` entry keyed by `(CatalogID, ItemID)` has its\n`Amount` multiplied by `count` and duplicate keys merged before charging —\n`PremiumDiscounts` are not pre-scaled here; they're applied automatically\ninside `ResourceService`'s premium-discount filtering on the final merged\ncost. `count` is clamped server-side to `[1, 100]` regardless of what you\nsend.\n\n---\n\n## Open response shape\n\n```ts\ninterface LootboxOpenResponse {\n ServerTimeUtc: string;\n LootboxID: string;\n OpenedCount?: number; // == the clamped count actually processed\n SelectedOptionID?: number;\n Resources?: ResourceOperation; // aggregated grant (all boxes + pity) and the total consume (cost)\n Results?: ResourceOperation[]; // one entry per box opened, in order; pity rewards folded into the box that triggered them\n TriggeredPity?: LootboxPityTriggerResponse[]; // null if no rule fired this call\n}\ninterface LootboxPityTriggerResponse {\n RuleID: string;\n BoxIndex?: number; // 0-based index into Results for the box that crossed the threshold\n}\n```\n\n`Results[i].Grant` is filtered per-box for the player's active premium tier\nbefore being returned (`ResourceService.FilterByPremium`), so its totals sum\nto `Resources.Grant` — both reflect what the player is actually entitled to,\nnot the raw unfiltered config. `Results[i]`'s event tokens are plain\n`EventTokenOperation` (no `Requested`/`Applied`/`NewBalance` — those only\nexist on the aggregated `Resources`), so read balances/streaks from\n`Resources`, not from `Results`.\n"
|
|
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"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|