@idosgames/mcp 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "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.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",
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?: Record<string, 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 PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\n// BaseCost = the Cost of the selected PriceOptions option\n```\n\n— identical semantics to the Character module's stat-cost scaling, and the same shared\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\nfrom `current + 1` through the resolved target — it is not a single jump priced\noff the destination level alone. If every scaled amount rounds to `0`, or the\nselected option is empty, the upgrade is rejected as misconfigured rather than\ntreated as free.\n\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\nfor the first option available on the caller's platform.\n\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\nwith `firstStep = 1`, so level 1 is the plain base):\n\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\n before aggregation into the character's total gear-percent.\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\n balance any double-counting themselves via weights).\n\nAn **unset** curve means that quantity does not grow with level — only the base value\napplies at every level. There is no field here whose neutral value is `1`: empty is the\nneutral, always. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\n Selection?:\n \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\" | \"SameLevelOnly\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\n and so on. This is the classic tier merge, and it is the mode you want when\n the design reads \"two of the same tier make one of the next\".\n\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\n _order_, not a restriction: once the cheap copies run out it will burn a\n leveled one, and it burns it **whole** (an instance cannot be partially\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\n the upgrade is refused instead, with an error naming the level that is\n short. Overshoot is impossible whenever the weight curve is integral,\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\n upgrade.\n\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: string | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
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?: Record<string, string>; // arbitrary key/value pairs for fields that don't fit typed blocks\n IsStackable?: boolean; // true = plain quantity in Items; false/absent = UnstackableItems instance\n IsTradable?: boolean; // gates Marketplace tradability alongside MarketplaceTradabilityPolicy\n Weight?: number; // weight in randomized drops (craft/lootbox/packs) — unrelated to Upgrade\n AssetPaths?: Record<string, string>; // \"icon\",\"model\",\"thumbnail\",\"preview_video\",\"sfx_use\",...\n NFT?: NFTModel; // blockchain binding, if any\n Stats?: ItemStats;\n Equipment?: ItemEquipment;\n Upgrade?: ItemUpgrade;\n Metadata?: ItemMetadata;\n ExpirationDurationSeconds?: number; // instance TTL from AcquiredAt, if any\n}\n```\n\n`IsStackable` is the single fact that decides which half of `InventoryV2` an\nowned copy lives in — see [Player state](#player-state-inventoryv2) below.\n`Upgrade` being present/absent is independent of `Equipment` — a non-equippable\nconsumable can still have upgrade tiers, and an equippable item can be\nnon-upgradable.\n\n---\n\n## ItemStats\n\nStat modifiers/Power the item contributes when equipped. Applied in two\nlayers — flat bonuses added to the base stat first, then percent bonuses\nmultiply the (base + flat) total. Consumed by the Character module's Power\ncomputation (see character-system skill) — never recomputed client-side.\n\n```ts\ninterface ItemStats {\n FlatBonuses?: Record<string, number>; // statID -> flat add (layer 1)\n PercentBonuses?: Record<string, number>; // statID -> fraction of 1.0, e.g. 0.10 = +10% (layer 2)\n Power?: number; // explicit flat Power contribution, added to CharacterModel.Power on equip\n}\n```\n\nBoth `FlatBonuses` and `PercentBonuses` scale with the item instance's\nupgrade `Level` — see [ItemUpgrade](#itemupgrade--cost-formula) below.\n\n---\n\n## ItemEquipment\n\nThe item-side half of the two-sided equip rule matrix (the character-side\nhalf, `CharacterEquipmentSlot`, is documented in the character-system skill's\nreference doc — both must pass for an equip to succeed).\n\n```ts\ninterface ItemEquipment {\n MinCharacterLevel?: number; // character rank must be >= this; 0 = no requirement\n UseRequirements?: Record<string, number>; // statID -> required character stat level\n AllowedCharacterIDs?: string[]; // null/empty = any character\n AllowedSlotIDs?: string[]; // which SlotIDs this item can go into\n}\n```\n\n---\n\n## ItemUpgrade + cost formula\n\nPer-instance level-upgrade config, consumed by `client.item.upgradeLevel` /\n`upgradeLevelsBatch`.\n\n```ts\ninterface ItemUpgrade {\n MaxLevel?: number; // hard cap; <=0 is clamped to 1 server-side (1 = already maxed, cannot upgrade)\n PriceOptions?: Record<string, PriceOption>; // ways to pay the step from level 1 to level 2\n CostCurve?: ScalarCurveSpec; // cost growth; step = target level, from 1\n FlatBonusCurve?: ScalarCurveSpec; // ItemStats.FlatBonuses growth over the level\n PercentBonusCurve?: ScalarCurveSpec; // ItemStats.PercentBonuses growth over the level\n PowerCurve?: ScalarCurveSpec; // ItemStats.Power growth over the level\n Fodder?: ItemUpgradeFodder; // same-item fodder payment settings, if enabled\n}\n```\n\n**Cost of reaching level `N`** (`N` = target level, the level being paid for,\nnot the step count):\n\n```\nAmount(N) = roundUp(BaseCost.Amount * CostCurve(N)) // firstStep = 1\n// BaseCost = the Cost of the selected PriceOptions option\n```\n\n— identical semantics to the Character module's stat-cost scaling, and the same shared\n`ScalarCurveSpec`. `firstStep = 1` means the level-1→2 step costs exactly the base cost,\nunscaled. An unset curve is the identity: the price is the same at every level. Rounding\nis **UP**, once, at the end — the platform has a single rounding convention. A multi-level upgrade\n(`Levels` / `TargetLevel`) charges the **sum** of this formula for every level\nfrom `current + 1` through the resolved target — it is not a single jump priced\noff the destination level alone. If every scaled amount rounds to `0`, or the\nselected option is empty, the upgrade is rejected as misconfigured rather than\ntreated as free.\n\n⚠ **An upgrade can never be paid in a store**: the price grows by a formula per\nlevel while a store SKU is a fixed tier, so a `Purchase` entry here is rejected.\n`upgradeLevel`'s third argument picks the option (`PriceOption.OptionID`); omit it\nfor the first option available on the caller's platform.\n\nThe option's optional `PremiumDiscounts`/`PremiumTiers` are carried\nthrough unchanged and resolved by the shared premium pipeline per level before\nthe per-level bundles are summed — see the currency-system skill for\n`ResourceConsume`'s premium fields.\n\n**Stat/Power scaling at instance level `L`** (every one is a `ScalarCurveSpec` evaluated\nwith `firstStep = 1`, so level 1 is the plain base):\n\n- Flat bonuses: `FlatBonusCurve` multiplies each `ItemStats.FlatBonuses` value.\n- Percent bonuses: `PercentBonusCurve` multiplies each `ItemStats.PercentBonuses` value,\n before aggregation into the character's total gear-percent.\n- Effective Power: `roundUp(ItemStats.Power * PowerCurve(L))`, added into\n `CharacterModel.Power` alongside stat-based Power (the two are simple sums — designers\n balance any double-counting themselves via weights).\n\nAn **unset** curve means that quantity does not grow with level — only the base value\napplies at every level. There is no field here whose neutral value is `1`: empty is the\nneutral, always. These three are read-only\ninputs to server computations (Power, PvP stat calc); the SDK never\nrecomputes them for you.\n\n---\n\n## Fodder valuation + selection modes\n\n`ItemUpgrade.Fodder` only governs how a **same-item** copy (a fodder instance\nwith the same `ItemID`/`CatalogID` as the instance being upgraded) is valued\nand picked when the upgrade's own cost is expressed in copies of itself. Any\nother cost entries (currencies, other items, event tokens) are charged\nnormally through the regular resource pipeline regardless of `Fodder` config.\n`Fodder: null/absent` is the legacy default: `FlatCount` valuation +\n`ProtectLeveled` selection.\n\n```ts\ninterface ItemUpgradeFodder {\n ValuationMode?: \"FlatCount\" | \"Merge\" | \"InvestmentRefund\";\n WeightCurve?: ScalarCurveSpec; // used when ValuationMode === \"Merge\"; base 1, step = copy level from 1\n Selection?:\n \"ProtectLeveled\" | \"CheapestFirst\" | \"ClientSelected\" | \"SameLevelOnly\";\n}\n```\n\n### Valuation — the `W(L)` formula (value of a fodder copy at level `L`)\n\nThe server expresses the self-item portion of the upgrade cost as a **target\nvalue** to cover, `W(targetLevel) - W(currentLevel)` (never negative), then\nburns fodder copies until their summed `W(level)` meets or exceeds that\ntarget (overshoot is allowed — you can't burn a fraction of one instance).\n\n| Mode | `W(L)` formula | Notes |\n| ------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `FlatCount` | `W(L) = 1` | Every copy is worth exactly 1 unit regardless of its own level. Legacy default. |\n| `Merge` | `W(L) = roundUp(WeightCurve(base 1, step L, firstStep 1))` | The FULL curve counts — shape, table points and bounds, not just its growth. The classic rule \"`R` copies of level `L` ≈ one copy of level `L+1`\" is `{ Shape: \"Geometric\", GrowthRate: R - 1 }` (R=2 → 1); an unset curve makes every copy worth 1. Weights are VALUE POINTS, not copies, so a fractional or table weight is meaningful. |\n| `InvestmentRefund` | `W(L) = roundUp(1 + Σ_{k=2}^{L} BaseSelfAmount * CostCurve(k))` | Accumulated in full precision and rounded **once** at the end, so the value invested in a copy matches the price of the same upgrade. `BaseSelfAmount` is the self-item `Amount` found inside the selected option's `Cost` (0 if the base cost has no self-item entry). |\n\nAll three are floored at a minimum of `1` (a level-1 copy is always worth at\nleast 1 unit). `W(L)` is evaluated purely from config — you can reproduce it\nclient-side for a cost preview, but the server is what actually enforces\ncoverage.\n\n### Selection — which instances get burned\n\nOnly relevant when the caller doesn't already specify exact fodder for every\nunit needed (or when supply must be chosen automatically):\n\n- **`ProtectLeveled`** (legacy default) — only instances at `Level <= 1` are\n eligible; anything the player has already leveled up is never auto-selected\n as fodder. `FodderInstanceIDs` you pass are ignored for selection purposes\n in the sense that the pool is still filtered this way in `FlatCount` mode\n (where fodder isn't weighted at all — see below).\n- **`CheapestFirst`** — eligible instances (any level, still filtered to\n same-item/same-catalog, not equipped, not expired, not already claimed by\n another item in the same batch) are sorted by `W(level)` ascending, then by\n acquisition time, then by ID, and burned cheapest-first until the target\n value is covered. Leveled copies are eligible here and burn last (they're\n worth more per unit, so they're a poor early pick under this greedy order).\n- **`SameLevelOnly`** — only instances **at the upgraded item's own level**\n are eligible: level 2 is fed by level-1 copies, level 3 by level-2 copies,\n and so on. This is the classic tier merge, and it is the mode you want when\n the design reads \"two of the same tier make one of the next\".\n\n Prefer it over `CheapestFirst` for merge economies. `CheapestFirst` is an\n _order_, not a restriction: once the cheap copies run out it will burn a\n leveled one, and it burns it **whole** (an instance cannot be partially\n consumed), so a copy worth `W(2) = 2` pays a cost of `1` and the remainder\n is destroyed. Under `SameLevelOnly` that copy is not a candidate at all —\n the upgrade is refused instead, with an error naming the level that is\n short. Overshoot is impossible whenever the weight curve is integral,\n because the cost of leaving level `L` is exactly `W(L)` — one copy per\n upgrade.\n\n- **`ClientSelected`** — the server does **not** auto-pick anything. Every\n unit needed must come from the `FodderInstanceIDs` you pass; each ID is\n validated individually (must exist, must be the same item/catalog, must not\n be equipped or expired, must not already be claimed elsewhere in the same\n batch) and rejected by name if any check fails. If the combined `W(level)`\n of your supplied instances doesn't cover the target, the whole upgrade is\n rejected — nothing is partially burned.\n\n**Important:** `FlatCount` valuation only ever applies when `Fodder` is\n`null`/absent (the legacy path) or explicitly configured as `FlatCount` — in\nthat mode the self-item cost is settled by the _regular_ resource-consume\npipeline, not by the weighted fodder mechanism at all: one unit of the cost is\nimplicitly the instance being upgraded itself (it \"becomes\" the new level\nrather than being burned), and the rest come from plain inventory count, with\nno `FodderConsumedEntry` reporting for that portion. `FodderConsumed` on the\nresponse is populated **only** for `Merge`/`InvestmentRefund` (weighted)\nupgrades — it stays empty/absent for `FlatCount` upgrades even if you pass\n`fodderInstanceIDs`, and passing fodder IDs when the item has no matching\nself-item cost entry, or fodder that's a different item, is rejected.\n\n---\n\n## ItemMetadata\n\nRarity/collection/authorship metadata used by the Collection (\"Albums\")\nsubsystem and general UI.\n\n```ts\ninterface ItemMetadata {\n RarityID?: string; // \"Common\",\"Rare\",\"Epic\",\"Legendary\",\"1Star\"..\"5Star\",...\n CollectionID?: string; // ties the item into a Collection set/album page\n AuthorID?: string; // e.g. UGC/creator attribution\n}\n```\n\n---\n\n## NFTModel\n\nBlockchain binding for tokenized items — may span multiple networks (e.g. an\nitem mirrored on both an EVM chain and Solana).\n\n```ts\ninterface NFTModel {\n Networks?: Record<string, NFTNetworkBinding>; // key = network id, e.g. \"ethereum\",\"polygon\",\"solana\"\n MetadataUrl?: string; // JSON metadata URL (IPFS/Arweave), shared across networks\n}\n\ninterface NFTNetworkBinding {\n ContractAddress?: string; // EVM contract or Solana mint address\n TokenID?: string;\n TokenStandard?: string; // e.g. \"ERC-721\",\"ERC-1155\",\"SPL\",\"Metaplex\"\n}\n```\n\nSee the blockchain-system skill for the wallet/mint/transfer flows that\npopulate and consume this binding.\n\n---\n\n## Player state: InventoryV2\n\nCached at `client.data.user.state?.InventoryV2`, populated at login (via\n`ClientState`) and re-fetched wholesale by `client.item.upgradeLevel` /\n`upgradeLevelsBatch` (and other inventory-affecting calls).\n\n```ts\ninterface UserInventoryState {\n Version?: number;\n VirtualCurrencies?: Record<string, UserVirtualCurrencyState>;\n CryptoCurrencies?: Record<string, UserCryptoCurrencyState>;\n Items?: Record<string, ItemTotals>; // stackable items — key = ItemID\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // key = ItemInstanceID\n ConversionDaily?: Record<string, ConversionDailyCounter>;\n}\n\ninterface ItemTotals {\n StackableAmount: number;\n UnstackableAmount: number;\n TotalAmount: number;\n}\n\ninterface UnstackableItemInstanceState {\n ItemInstanceID: string;\n ItemID: string;\n CatalogID?: string | null;\n Quantity?: number; // pristine \"pack\" size; see note below. Default 1.\n RemainingUses?: number; // consumable-with-charges items\n Level?: number; // the field ItemService.upgradeLevel raises. Default 1.\n AcquiredAt: string;\n ExpiresAt?: string | null; // set from AcquiredAt + ItemDefinition.ExpirationDurationSeconds\n EquippedSlot?: { CharacterID?: string; SlotID?: string } | null; // authoritative equip location\n CustomData?: Record<string, string> | null;\n}\n```\n\n`VirtualCurrencies`/`CryptoCurrencies` are documented fully in the\ncurrency-system skill; they ride along in the same inventory snapshot but\naren't item-related.\n\n`ItemTotals.TotalAmount` sums stackable + unstackable counts for the same\n`ItemID` — useful for a single \"how many do I have\" readout regardless of\nwhich half of the inventory backs it.\n\n**`Quantity` and pristine packs.** An unstackable instance with `Quantity > 1`\nis a merged \"pack\" of identical, untouched copies — it's only allowed to have\n`Quantity > 1` while it's _pristine_: `Level == 1`, `RemainingUses == 1`,\n`EquippedSlot == null`, and empty `CustomData`. Backend code calls this\ninvariant \"bundle-able\". The moment any per-instance field needs to change on\none copy — e.g. leveling one copy of a stack of five identical swords — the\nserver **splits** it: it creates a new instance (fresh `ItemInstanceID`) with\n`Quantity: 1` carrying the mutation (the new `Level`), and decrements the\noriginal pack's `Quantity` by one. You never request a split explicitly; it's\nan implementation detail of how `upgradeLevel` mutates a stacked pristine\ninstance, but it explains why `UpgradeItemLevelResponse.ItemInstanceID` can\ncome back as a **different** id than the one you called with — always read\nthe instance id off the response (or the refreshed cache), don't assume it's\nunchanged. The backend also opportunistically re-merges pristine fragments of\nthe same `(ItemID, CatalogID, ExpiresAt)` back together in the background;\nyou don't need to do anything to trigger or handle that.\n\n---\n\n## Requests, responses, and actions\n\n```ts\ninterface ItemRequest extends BaseRequest {\n ItemInstanceID?: string;\n Levels?: number;\n TargetLevel?: number;\n FodderInstanceIDs?: string[];\n /** UpgradeLevelsBatch: per-instance upgrades (deduped by ItemInstanceID). */\n Upgrades?: ItemUpgradeRef[];\n}\n```\n\n`Levels`/`TargetLevel` exist on the wire request (the backend's single\n`UpgradeLevel` action itself supports a multi-level jump), but the SDK's\n`ItemService.upgradeLevel(itemInstanceID, fodderInstanceIDs?)` method does\n**not** expose them — it only ever raises by one level per call. To move\nseveral levels in one call (on one or many instances), use\n`upgradeLevelsBatch`, which does expose them via `ItemUpgradeRef`:\n\n```ts\ninterface ItemUpgradeRef {\n ItemInstanceID?: string;\n Levels?: number; // steps to raise; default 1 if both Levels/TargetLevel absent\n TargetLevel?: number; // absolute target — wins over Levels, clamped to MaxLevel\n FodderInstanceIDs?: string[];\n}\n\ninterface FodderConsumedEntry {\n ItemInstanceID: string;\n Units: number; // how many copies of this instance/pack were burned\n Level: number; // the fodder instance's level at time of consumption\n}\n\ninterface UpgradeItemLevelResponse {\n ServerTimeUtc: string;\n ItemInstanceID: string; // may differ from the instance you called with — see Quantity/split note above\n ItemID: string;\n CatalogID?: string | null; // resolved/self-healed catalog — may differ from what you last read\n Level: number; // new level after the upgrade\n Resources?: ResourceOperation | null; // cost charged, already applied to cached balances\n FodderConsumed?: FodderConsumedEntry[] | null; // populated only for Merge/InvestmentRefund; empty/absent for FlatCount\n}\n\ntype UpgradeLevelsBatchResponse = BatchItemResult<UpgradeItemLevelResponse>[];\n```\n\n`ItemAction` enum (server-side action names; not needed to call the SDK, but\nuseful when reading logs/errors that echo the action):\n\n```ts\nconst ItemAction = {\n UpgradeLevel: \"UpgradeLevel\",\n UpgradeLevelsBatch: \"UpgradeLevelsBatch\",\n} as const;\n```\n\n`Resources` follows the shared `ResourceOperation` (`{ Grant?, Consume? }`)\nshape used across the whole SDK — see the currency-system skill for the full\n`ResourceConsume`/`ResourceGrant`/`ResourceEntry` breakdown, including how\n`PremiumDiscounts` can reduce a displayed base cost.\n\n### Server-side limits (verified against the backend)\n\n- **Batch size**: at most 50 entries per `upgradeLevelsBatch` call\n (`BatchSupport.MaxBatchSize`). Entries beyond the 50th (after trimming\n empties and de-duping by `ItemInstanceID`) are silently dropped — they don't\n appear in the result array at all. Chunk larger sets yourself.\n- **Dedup**: `Upgrades` is deduped by `ItemInstanceID` server-side; a repeated\n id in the same call only processes once.\n- **Invalid IDs**: an `ItemInstanceID` containing `.` or `$` is rejected per\n entry with `\"ItemInstanceID '{id}' contains invalid characters ('.' or '$').\"`\n (single call fails outright; batch reports it as a failed item).\n- **Atomicity**: both the single and batch charge/patch happen inside one\n Mongo transaction — either the whole thing (cost + level + any fodder burns\n - owner Power recompute) applies, or none of it does.\n- **Idempotency**: the backend replays the same result for a repeated call\n with the same resolved `RelatedEntityID` (`upgrade_item_{instanceID}_{nextLevel}`\n server-side reason key, so re-running the _same target level_ twice is safe\n to retry). The TS `upgradeLevel` method, however, mints a fresh\n `RelatedEntityID` (`upgrade_item_{instanceID}_{uuid}`) on every call — so\n from the SDK's side, two separate calls are always two separate operations;\n see the Gotchas section in SKILL.md.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "voxelcraft-worlds",
3
+ "description": "Build VoxelCraft worlds from a recipe (WorldSpec): from up to 4 reference pictures and a text description, with InApp AI (client.ai, title feature \"voxel-world\") or without it, or written by hand / by an agent. The AI can recreate WHATEVER the pictures show — build objects out of coloured blocks (a plane, a car, a ship, a statue: models made of boxes, cylinders, cones, spheres, lines, with mirror symmetry), rebuild landscapes (a hand-drawn terrain sketch, rivers, biome zones) and combine them into scenes. Covers the WorldSpec JSON format, setting a project's start world (src/worlds/startWorld.ts), the \"Create world\" screen and \"My worlds\" autosave, configuring the \"voxel-world\" AI feature for a title (system prompt, vision model, images, limits, price), and the agent actions generateWorld / loadWorldSpec. Use this whenever the user works on the voxelcraft module and wants a world from a picture, an object from a photo built in blocks, a landscape recreated, a generated / custom / themed voxel map, AI world generation, a start world for their game, or touches WorldSpec, SpecGenerator, VoxelModel, rasterizeModel, parseWorldSpec, buildSpecWithoutAI, CreateWorldUI, createClientWorldAI or VOXEL_WORLD_SYSTEM_PROMPT — even if they don't name the module. Also covers sharing worlds through the Workshop (content type \"voxelcraft.world\").",
4
+ "content": "---\nname: voxelcraft-worlds\ndescription: >-\n Build VoxelCraft worlds from a recipe (WorldSpec): from up to 4 reference\n pictures and a text description, with InApp AI (client.ai, title feature\n \"voxel-world\") or without it, or written by hand / by an agent. The AI can\n recreate WHATEVER the pictures show — build objects out of coloured blocks\n (a plane, a car, a ship, a statue: models made of boxes, cylinders, cones,\n spheres, lines, with mirror symmetry), rebuild landscapes (a hand-drawn\n terrain sketch, rivers, biome zones) and combine them into scenes. Covers the\n WorldSpec JSON format, setting a project's start world\n (src/worlds/startWorld.ts), the \"Create world\" screen and \"My worlds\"\n autosave, configuring the \"voxel-world\" AI feature for a title (system\n prompt, vision model, images, limits, price), and the agent actions\n generateWorld / loadWorldSpec. Use this whenever the user works on the\n voxelcraft module and wants a world from a picture, an object from a photo\n built in blocks, a landscape recreated, a generated / custom / themed voxel\n map, AI world generation, a start world for their game, or touches\n WorldSpec, SpecGenerator, VoxelModel, rasterizeModel, parseWorldSpec,\n buildSpecWithoutAI, CreateWorldUI, createClientWorldAI or\n VOXEL_WORLD_SYSTEM_PROMPT — even if they don't name the module. Also covers\n sharing worlds through the Workshop (content type \"voxelcraft.world\").\n---\n\n# VoxelCraft worlds (WorldSpec)\n\nA VoxelCraft world is built by a **deterministic generator** (`SpecGenerator`)\nfrom a compact JSON **recipe** — the WorldSpec. The same recipe always gives the\nsame world, block for block. Nothing writes the world's blocks directly;\neverything that makes a world writes a recipe:\n\n| Path | Where | What it can do |\n| ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |\n| **AI** — pictures + description | `createClientWorldAI(client)` → `client.ai.generateText(\"voxel-world\", …)` | anything the pictures show: objects built from blocks, landscapes, scenes |\n| **Without AI** | `buildSpecWithoutAI` — first picture read in the browser (`ImageAnalyzer`), text by keywords (ru/en) | terrain from a top-down map, style from a mood picture, prefab structures by keyword — **no objects from pictures** |\n| **By hand / by an agent** | write the JSON yourself | everything the format allows |\n\nAll paths meet in `parseWorldSpec()` — the only door into the game. It fills\ndefaults, clamps numbers, drops unknown structures, shapes and blocks. A recipe\nnever crashes the generator, wherever it came from.\n\n## The WorldSpec format\n\nWorld coordinates `x`, `z`, `x2`, `z2`, `radius`, river points are **fractions\nof the world (0..1)**: `x=0` west, `x=1` east, `z=0` north, `z=1` south. Every\nfield is optional.\n\n```jsonc\n{\n \"version\": 1,\n \"name\": \"Frost Keep\", // <= 60 chars\n \"description\": \"…\",\n \"seed\": 2026, // number or string; omitted = hash of name\n \"size\": { \"mode\": \"island\", \"widthChunks\": 16, \"depthChunks\": 16 },\n // mode: island | bounded (land to the edges, sea outside) | infinite\n // 4..48 chunks per side (16 blocks each); default island 16×16 = 256×256 blocks\n \"terrain\": {\n \"shape\": \"mountains\", // island | archipelago | continent | valley | mountains | plateau | flat\n \"baseHeight\": 30, // 6..48 — average land height (the world is 64 blocks high)\n \"amplitude\": 20,\n \"roughness\": 0.6,\n \"waterLevel\": 25,\n \"sketch\": { \"rows\": [\"0012345\", \"…\"] },\n // optional TOP-DOWN elevation map drawn by the AI: 4..32 rows (north→south) of 4..32 digits\n // (west→east): 0 deep water, 1 shallow, 2 shore, 3 lowland, 4 plain, 5 hills, 6 high hills,\n // 7 mountains, 8 high mountains, 9 peaks. Smoothed + natural detail; with a sketch the recipe\n // draws its own coastline (no island mask). A top-down PICTURE's heightmap wins over it.\n // heightmap / surfaceMap — filled by the game from a top-down picture; don't write by hand\n },\n \"zones\": [\n // <= 12; each point belongs to the nearest zone (weighted by radius)\n { \"x\": 0.5, \"z\": 0.5, \"radius\": 0.4, \"biome\": \"snow\" },\n // biome: meadow | forest | birch_forest | taiga | snow | desert | badlands | beach |\n // swamp | rocky | savanna | jungle\n // overrides: surface, subsurface (any solid block — even wool), trees 0..1,\n // treeType oak|birch|spruce|palm|dead|none, grass 0..1, flowers 0..1, height -20..20\n ],\n \"structures\": [\n // <= 40\n { \"type\": \"castle\", \"x\": 0.5, \"z\": 0.5, \"size\": \"large\", \"rotation\": 180 },\n // house | tower | castle | village | wall | road | bridge | ruins | pond | rock |\n // tree_cluster | pyramid | well | farm | model | river\n // size small|medium|large; rotation 0|90|180|270 (buildings: door north|east|south|west)\n { \"type\": \"road\", \"x\": 0.25, \"z\": 0.72, \"x2\": 0.5, \"z2\": 0.5 }, // wall/road/bridge: x2,z2\n {\n \"type\": \"river\",\n \"path\": [\n [0.8, 0.2],\n [0.5, 0.5],\n [0.1, 0.95],\n ],\n }, // 2..16 points, source → mouth\n {\n \"type\": \"model\",\n \"model\": \"airplane\",\n \"x\": 0.5,\n \"z\": 0.55,\n \"rotation\": 90,\n \"elevation\": 0,\n },\n ],\n \"models\": { \"airplane\": {/* see below */} }, // <= 4 models, each can be placed many times\n \"npcs\": [\n { \"name\": \"Björn\", \"role\": \"smith\", \"x\": 0.26, \"z\": 0.7, \"persona\": \"…\" },\n ],\n \"palette\": {\n \"wall\": \"planks\",\n \"roof\": \"dark_planks\",\n \"floor\": \"planks\",\n \"path\": \"dirt_path\",\n \"accent\": \"stone_bricks\",\n },\n \"ambience\": { \"timeOfDay\": \"dawn\" }, // dawn | day | sunset | night\n}\n```\n\n### Models — objects built from blocks\n\nThe AI builds an object (a plane, a car, a ship, a statue, a creature) from\n**shapes**, not block by block — a language model reasons well about\n\"fuselage = cylinder, wings = flat boxes, symmetric\", and a shape list stays\nshort. `rasterizeModel()` turns it into blocks.\n\n```jsonc\n\"airplane\": {\n \"size\": [40, 12, 33], // [length x, height y, width z], <= 64 × 40 × 64\n // model coords: x from the FRONT (x=0, the nose) back, y UP (y=0 on the ground), z left→right;\n // the middle plane is z = (width - 1) / 2. Later shapes overwrite earlier; \"air\" carves.\n \"parts\": [ // <= 200\n { \"shape\": \"cylinder\", \"axis\": \"x\", \"center\": [4, 4, 16], \"radius\": 2.5, \"length\": 30, \"block\": \"white_wool\" },\n // center = middle of the START cap; runs `length` blocks along +axis (negative = −axis)\n { \"shape\": \"cone\", \"axis\": \"x\", \"center\": [3, 4, 16], \"radius\": 2.5, \"radius2\": 0.6, \"length\": -4, \"block\": \"white_wool\" },\n { \"shape\": \"box\", \"from\": [14, 3, 1], \"to\": [19, 3, 13], \"block\": \"white_wool\", \"mirror\": \"z\" },\n // \"mirror\": \"x\" | \"z\" also draws the mirror copy across the middle — wings, wheels, lights\n { \"shape\": \"sphere\", \"center\": [x, y, z], \"radii\": [rx, ry, rz], \"block\": \"…\", \"hollow\": true },\n { \"shape\": \"line\", \"from\": [x, y, z], \"to\": [x, y, z], \"radius\": 0, \"block\": \"…\" },\n { \"shape\": \"box\", \"from\": [33, 9, 16], \"to\": [34, 11, 16], \"block\": \"air\" }\n ],\n \"voxels\": [[9, 5, 14, \"light_blue_wool\"]] // <= 400 single blocks for details\n}\n```\n\nPlaced by a `model` structure: centred at `x, z`, turned by `rotation`, its\nbottom on the ground under the centre (on water — on the surface), `elevation`\nlifts it (a plane in flight). The footprint is cleared (trees and bumps don't\ngrow through it). Model blocks written with Minecraft names are forgiven:\n`red_concrete`, `cyan_terracotta`, `minecraft:lime_wool` → our wool of that colour;\n`*_stained_glass` → glass. Anything unknown drops that shape.\n\n### Blocks\n\ngrass, dirt, stone, cobblestone, mossy_cobblestone, sand, red_sand, gravel,\nclay, snow, ice, sandstone, terracotta, stone_bricks, bricks, planks,\ndark_planks, oak_log, spruce_log, birch_log, glass, dirt_path, leaves,\nspruce_leaves, birch_leaves, and wool in 12 colours (white, red, orange,\nyellow, green, blue, light_blue, purple, pink, brown, gray, black — `red_wool`…).\n\nGood to know: a **village** already has houses facing a well, paths and (large)\na farm; a **bridge** needs water under it (`valley` shape, a river, a pond);\na **river** always flows downhill from its first point and cuts its bed through\nhills; keep structures inside `0.2..0.8` on an island; the player spawns on dry\nland nearest the centre, away from structures.\n\n## The project's start world\n\n`src/worlds/startWorld.ts` (in a project: `src/modules/voxelcraft/src/worlds/startWorld.ts`):\n\n```ts\nexport const START_WORLD: unknown = { name: \"Frost Keep\", terrain: { shape: \"mountains\" }, … };\n```\n\n`null` (default) = the classic seed-1337 noise world. A player with a saved\nworld continues it (\"My worlds\"). Easiest way to get a recipe: in the game,\n**Create world → Download recipe**, then paste the JSON as an object.\n\n## In the game\n\n- **Create world** (pause overlay): up to **4 pictures** (drop, click, Ctrl+V —\n e.g. one car from several angles), description, size (island 256 / large 512\n / infinite / custom), how to read the pictures:\n - **Образец для ИИ** (default) — the AI decides from the description what the\n pictures are (an object to build, a landscape to recreate, a style);\n - **Карта сверху** — the first picture is a top-down map: blue = water, land\n rises from the shore, pixel colour → nearest block (works without AI too);\n - **Настроение** — only climate, colours, materials.\n Then with or without AI, a **mini-map preview** (red dot = spawn), play /\n download / load recipe.\n- **My worlds**: every world autosaves to IndexedDB (every 30 s of play, on\n pause, before switching worlds); the last one reopens on the next launch.\n\n## AI: the \"voxel-world\" feature of the title\n\nThe AI option appears only when the title has a Text feature with key\n`voxel-world` (`client.ai.getDefinitions()`). Configure it in the dashboard\n(InApp AI page) or via the title-config MCP `save_ai`:\n\n- `Modality: \"Text\"` (every generation is a queued job the game polls — a world\n with models is a long answer, minutes; there is no Async switch any more).\n- `Model`: from `GetInAppAIModels`, with **image** among its input modalities\n (otherwise image requests are refused before any charge). Model quality =\n object quality: a strong vision model builds far better planes and cars.\n- `Behavior.SystemInstructions` = **`VOXEL_WORLD_SYSTEM_PROMPT`** from\n `src/ai/worldSpecPrompt.ts`, verbatim (a test keeps it in sync with the\n format). `HistoryMessages: 0`. **`MaxTokens` ≈ 48000** — models are the long part, and a\n reasoning model (GLM 5.3 Flash) spends part of the limit on thinking: at 16000 the\n first live run was cut off, the second took ~18000 output tokens (~1.6 credits).\n- `AllowImages: true`, **`MaxInputImages: 4`** or more — the game sends as many\n JPEGs (≤ 1568 px) as the feature allows; there is no platform cap, only what\n the model accepts.\n- `Safety.LockBehavior: true`, `MaxPromptChars: 1000` (the game trims the description).\n- `Limits` (`DailyCap`, `CooldownSeconds`) and `PriceOptions`.\n\n⚠ No JSON mode in the engine, and an answer cut off by `MaxTokens` comes back\n`completed` **and is charged**. The game detects a cut answer and builds the\nworld without AI instead — keep `MaxTokens` generous.\n\n`createClientWorldAI(client)` (`src/ai/worldAi.ts`): status from definitions,\none `relatedEntityID` per click (a dropped connection is retried with the same\nkey — no double charge), polling of the job, a job abandoned by closing\nthe screen is resumed next time, refusals mapped to player-facing messages, the\nplayer's chosen size enforced over the model's.\n\n## Sharing worlds in the Workshop\n\nVoxelCraft registers the content type **`voxelcraft.world`** (`src/workshop.ts`,\n`ctx.content.registerType` in `module.ts`). With the `workshop` module installed, players publish\nworlds from \"My worlds\" and open other players' worlds; the Workshop switches to the `voxelcraft`\nmode after opening.\n\n- `capture` publishes the world's **SaveData JSON** (role `main`, `application/json`) — the source\n plus the player's diffs, not the blocks — and a **webp preview** for recipe worlds (noise worlds\n have none). Metadata: `kind`, `seed`.\n- `open` parses the save, opens it in the running game (`openSharedWorld`) or stores it as a new\n local world.\n- Title config (dashboard → LiveOps → Workshop → Content types, or the title-config MCP):\n `ContentTypes[\"voxelcraft.world\"] = { DisplayName, Files: { main: { AllowedMimeTypes:\n[\"application/json\"], MaxBytes: … } } }`. Don't set `ThumbnailRequired` — noise worlds can't\n capture a preview. How to charge / gate access: **workshop-system**.\n\n## Agent debug surface\n\n`state().world`: `id`, `name`, `source` (`noise` | `spec`), `seed`, `size`,\n`shape`, `biomes`, `structures`, `fromPicture`, `spawnHint`; `createWorldOpen`.\nActions: `generateWorld { text, size?, seed? }` (without AI),\n`loadWorldSpec { spec }` (from a recipe object; returns `warnings`). The screen\nitself is plain DOM — read it and click it like a player.\n\n## Don't break\n\n- **Generators are part of the save format.** A save is `source + diffs`; the\n world is regenerated and the diffs land on it. Changing the output of\n `TerrainGenerator`, `SpecGenerator`, the structure builders or\n `rasterizeModel` corrupts every saved world — and every world **published in\n the Workshop**, which is the same SaveData. Both generators have golden\n fingerprint tests — a red one means \"you broke saves\", not \"update the numbers\".\n- **Block ids are append-only** (`registry/Blocks.ts`); new tiles go to the END\n of `TILE_ORDER` (`gfx/TextureAtlas.ts`).\n- The module takes **only types** from `@idosgames/core`; the client comes from\n the host (`ctx.client`).\n",
5
+ "references": []
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "workshop-system",
3
+ "description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module.",
4
+ "content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `like`/`unlike`, `favorite`/`unfavorite`, `getMyFavorites`, `follow`/`unfollow`,\n `getCreatorProfile`, `getCollection`, `report`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired | reacted`.\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module lists your local items, publishes them, and after `open` switches to `modeId`. A type\nwith no handler is still browsable and acquirable — it just can't be published or opened from the\ngame. Contract details: **idosgames-module-contract**.\n\n## Don't\n\n- Don't gate access on the client — show `getContent`'s option availability and let `acquire` decide.\n- Don't write a \"license\" of your own for `WhileHeld` content: access must follow the player's\n inventory.\n- Don't cache signed URLs or put private files in the public config.\n",
5
+ "references": []
6
+ }