@idosgames/mcp 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +15 -3
- package/registry/index.json +106 -22
- package/registry/modules/board-game.json +31 -10
- package/registry/modules/game-hud.json +99 -0
- package/registry/modules/idle-rpg.json +35 -10
- package/registry/modules/voxelcraft.json +7 -2
- package/registry/skills/character-system.json +1 -1
- package/registry/skills/craft-system.json +2 -2
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +6 -0
- package/registry/skills/item-system.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-compose-modules",
|
|
3
|
-
"description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the cross-module
|
|
4
|
-
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n cross-module
|
|
3
|
+
"description": "Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the shared HUD (game-hud, sharedUi roles, shouldDraw), typed cross-module events (defineTopic/shape), or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
|
|
4
|
+
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Merge several iDosGames modules into one game — combine genres (e.g. board-game + idle-rpg), share\n progress across modes, and add always-on chrome. Use this whenever a developer wants to COMBINE or\n MERGE multiple iDosGames templates/modules into a single title, switch between game modes, share\n currency/inventory across modules, or asks how the Mode Router, the nav-bar, activeOnly panels, the\n shared HUD (game-hud, sharedUi roles, shouldDraw), typed cross-module events (defineTopic/shape),\n or host-level shared state work. Builds on idosgames-getting-started\n (scaffolding) and idosgames-module-contract (a single module).\n---\n\n# Composing modules into one game\n\nThe whole point of the architecture: a developer plugs in several modules and merges them. The host\nhandles coexistence — you just register the modules and (optionally) wire shared state.\n\n## Register several modules\n\n```ts\n// src/modules.ts\nexport const modules: Module[] = [\n boardGameModule, // Three tycoon\n idleRpgModule, // Phaser idle\n];\n```\n\nEach game module registers a route → the host renders a **nav-bar** (`🎲 Board | ⚔️ Idle RPG`) and\n**mode-switches**: only the active mode's scene is mounted and ticking; the rest are suspended\n(their RAF stops). This is why two different engines (Three + Phaser) can live in one project — they\nnever render at the same time. Modules of the same engine family may later share a renderer\n(composition), but the module code doesn't change either way.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode — same `client.currency`, same\n cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams between them —\n which is what keeps a module reusable in another game. Code several of THIS game's modules need\n (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a module\n (see idosgames-project-structure).\n\n## Shared chrome: roles + game-hud\n\nBalances, the crypto wallet, the status line and \"Log out\" / player ID are **shared-UI roles**\n(`currency-bar | wallet | status | account`). A module that draws one for EVERY mode declares it\nstatically on the module object:\n\n```ts\nexport const gameHudModule = defineModule({\n id: \"game-hud\",\n meta: { name: \"Game HUD\", type: \"app\", engine: \"dom\" },\n sharedUi: { provides: [\"currency-bar\", \"wallet\", \"status\", \"account\"] },\n setup(ctx) {\n ctx.registerPanel({\n id: \"hud\",\n slot: \"hud\",\n activeOnly: false,\n component: GameHud,\n });\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider in\n`src/modules.ts` wins, a conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`.\nA template draws its own copy only while nobody took the role. So:\n\n- **Mixing two or more templates → install `game-hud`** (catalog, `type: feature`). Do NOT edit the\n templates to remove their wallets/balances/status: they hide those themselves, and an edit would\n mark them customized (no more catalog updates). The platform's AI editor installs game-hud\n automatically when a project starts from two or more templates.\n- A template installed alone stays a complete game — no owner, so it draws everything.\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n- When a module takes `account`, the host stops drawing its own bottom-left Log out / ID row.\n- A custom HUD replaces game-hud the same way: declare the roles, draw them.\n\n**Layout is the host's job.** It measures the `hud` slot and the nav and moves the `overlay` and\n`sidebar` layers between them, so a template's overlay never slides under the HUD or the nav — no\ntemplate changes. Anything drawn outside those layers (a scene's own DOM HUD) uses the CSS\nvariables on the host root: `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)` (voxelcraft's hotbar\ndoes this) and `--idos-safe-top`. Both are 0 with no HUD and a single mode. HUD panels are wrapped\nin `display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let\nclicks through to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference merge\n\n`host-starter` + `board-game` + `idle-rpg` + `game-hud`:\n\n- nav-bar switches modes; one engine scene mounted at a time;\n- game-hud is pinned across all modes: one balance bar, one wallet, one status line, the account\n chip — the templates' own copies are hidden, and both modes' status messages land in the HUD;\n- board's footer and idle's dock sit between the HUD and the nav;\n- currency changed in one mode is immediately visible in the others; levelling a hero in Idle RPG\n shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-getting-started",
|
|
3
|
-
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
|
|
4
|
-
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them
|
|
3
|
+
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as the project grows). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
|
|
4
|
+
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them. Mixing two or more templates → also add `game-hud`: one balance bar, wallet and status line\n for every mode, and the templates hide their own copies by themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-module-contract",
|
|
3
|
-
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.",
|
|
4
|
-
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (cross-module bus) · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
3
|
+
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
+
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, game-hud), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID). A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`).\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "idosgames-project-structure",
|
|
3
|
+
"description": "The layout standard for an iDosGames game project (host shell + composable modules) and how it grows without turning into a mess: where a new feature goes (a catalog module, an existing module, or a new module), the folder layout inside a module, module boundaries (no cross-module imports), the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated, idos.modules.lock.json and customized catalog modules, file size, and project documentation (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing src/shared/, moving code between files or folders, adding a system that spans several modules, or writing project documentation.",
|
|
4
|
+
"content": "---\nname: idosgames-project-structure\ndescription: >-\n The layout standard for an iDosGames game project (host shell + composable modules) and how it\n grows without turning into a mess: where a new feature goes (a catalog module, an existing module,\n or a new module), the folder layout inside a module, module boundaries (no cross-module imports),\n the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated,\n idos.modules.lock.json and customized catalog modules, file size, and project documentation\n (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing\n src/shared/, moving code between files or folders, adding a system that spans several modules, or\n writing project documentation.\n---\n\n# Project structure (iDosGames game projects)\n\n## The project at a glance\n\n```\nIDOS.md project guide every agent reads first — short, evergreen\nAGENTS.md, CLAUDE.md pointers to IDOS.md for external tools (Cursor, Codex, Claude Code…)\nidos.modules.lock.json platform-owned: catalog modules, their versions and file fingerprints\ndocs/feature-history/ one file per game system + README.md index\npackage.json · vite.config.ts · tsconfig.json · index.html\nsrc/\n main.tsx the host: ONE SDK client + mountHost(...)\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts · env.ts title / build-key resolution\n LoginScreen.tsx the login screen — restyle freely\n shared/ optional: code two or more of THIS game's modules need\n ui/ types/ utils/\n modules/\n <id>/ one folder per module: catalog templates, catalog features, the game's own\n```\n\nEverything the game does lives in a module. Ready-made catalog modules and the game's own modules\nshare the one `src/modules/` folder — where a module came from is recorded in\n`idos.modules.lock.json`, not in its path, because a catalog template becomes the creator's code the\nmoment they start changing it. `src/shared/` is the one place for code several of the game's own\nmodules use; there is no project-level `utils/` or `components/` besides it.\n\n## Where does a new feature go?\n\nDecide in this order:\n\n1. **The catalog already has it** → install that module (the AI editor's InstallModule, or\n `get_module` over MCP) and adapt it. Don't rebuild what exists.\n2. **It extends an existing module's gameplay** (a new tile type in the board game, a new enemy in\n the idle RPG) → change that module, inside its folder.\n3. **It is its own mode, screen or system** (a shop, a clan screen, a mini-game, a quest board) → a\n **new module** `src/modules/<feature-id>/`.\n4. **It is chrome for every mode.** Balances, the wallet, the status line, Log out / player ID are\n shared-UI roles → a module that **provides** them (`sharedUi: { provides: [...] }` in module.ts):\n install `game-hud` from the catalog, or change it; templates hide their own copies by\n themselves. Other UI every mode shows (a global menu) → a panel with `activeOnly: false` in a\n no-scene, no-route module (`type: \"app\"`, `engine: \"dom\"`). See **idosgames-compose-modules**.\n5. **It is code two or more of the game's modules need** (a shared type, the game's UI kit, a\n formatting helper) → `src/shared/` (see below).\n\nBetween 2 and 3: if it would get its own nav tab, or could be switched off on its own, it is its own\nmodule.\n\n## Inside a module\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ id, meta, setup(ctx) { … } })\n module.meta.json manifest: type, summary, provides, tags, version, author\n components/ React panels and UI pieces\n game/ engine and simulation (engine subfolders are fine: game/phaser/, game/three/)\n data/ static tables and tuning (levels, tiles, item lists)\n react/ hooks, contexts, the scene↔panel controller bridge\n```\n\n- The folder id is kebab-case `[a-z0-9-]`, at most 64 characters. The export name is derived from it\n — `daily-quests` → `dailyQuestsModule` — and the platform registers the module by that exact name.\n- Create only the folders you need; a small module can be `index.ts` + `module.ts` + one component.\n- `voxelcraft` is a ported vanilla game with its own layout — leave it as it is. New modules follow\n the layout above.\n\n### module.meta.json\n\n```json\n{\n \"id\": \"daily-quests\",\n \"type\": \"feature\",\n \"summary\": \"One-line pitch shown in the Modules dialog.\",\n \"description\": \"What it does, in a few sentences.\",\n \"provides\": [\"daily quest board\", \"streak rewards\"],\n \"tags\": [\"quests\", \"retention\"],\n \"version\": \"0.1.0\",\n \"author\": { \"name\": \"…\" },\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"daily-quests:quest-completed@1\",\n \"when\": \"a quest's reward was claimed\",\n \"payload\": { \"questId\": \"string\" }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"progress 'level a hero' quests\"\n }\n ]\n }\n}\n```\n\n`type` is `template` (a complete game to start from) or `feature` (a capability added to a game).\n`events` declares every `defineTopic(...)` the module uses — its own topics under `emits` (with the\nsame payload descriptor it passes to `shape()`), other modules' under `listens`; omit it when the\nmodule has none.\n`provides` is what an agent matches a request against — keep it accurate. Every module carries this\nfile, the game's own included: it is how a module shows up in the Modules dialog, and what lets it be\npublished to the shared catalog for other creators later.\n\n### Register it\n\n`src/modules.ts` has exactly this shape — imports plus the array, nothing else:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nimport { dailyQuestsModule } from \"./modules/daily-quests\";\n\nexport const modules: Module[] = [boardGameModule, dailyQuestsModule];\n```\n\nThe platform regenerates this file whenever modules are installed or removed; any other code in it is\nlost. Array order is mount order (and nav order).\n\n## Module boundaries\n\nA module imports only:\n\n- files inside its own folder;\n- `src/shared/` — the game's shared code;\n- npm packages (`@idosgames/*`, `react`, `three`, `phaser`, …).\n\nNever another module's files (`../board-game/…`) and never host files (`../../main`, `../../config`).\nModules are mixed into different games — a module that reaches into its neighbours breaks the moment\none of them is removed or replaced. Cooperate instead through:\n\n- **Durable shared state** (currency, inventory, characters, progress) → the ONE SDK client every\n module receives as `ctx.client`. Gold earned in one mode is spendable in another with no plumbing.\n- **Live signals** → `ctx.events` with typed topics `<module-id>:<event>@<major>`\n (`defineTopic(\"idle-rpg:character-upgraded@1\", shape({ … }))`), declared in `module.meta.json`.\n The emitter doesn't know who listens; a listener keeps its own copy of the topic. Topics shared by\n several of THIS game's own modules can live in `src/shared/events/` and be imported on both sides.\n- **Code several modules need** → `src/shared/`.\n- **Logic that must be shared and trusted** → it belongs on the server (SDK services, CloudCode),\n not in a client file.\n\n## src/shared/ — the game's shared code\n\nFor code that two or more of THIS game's own modules need.\n\n- **Created on demand.** Code only one module needs stays inside that module; move it to\n `src/shared/` when a second module needs it, not in advance.\n- **One-way dependency.** Modules import from `src/shared/`; `src/shared/` NEVER imports a module.\n Otherwise every module using the shared code silently drags another module in with it.\n- **No game state, no game logic.** Types, constants, the game's UI components and theme, pure\n helpers. Progress and data go through the SDK client, signals through `ctx.events`.\n- **Organised by purpose:** `src/shared/ui/`, `src/shared/types/`, `src/shared/utils/` — not one pile.\n- **Catalog modules never depend on it.** A module that ships in the catalog must install into any\n game, so it is fully self-contained. A game's own module that imports `src/shared/` is tied to this\n game — fine for your own game; when you publish such a module, its shared code is copied into it\n (the Modules dialog marks these modules \"uses shared code\").\n\n## Catalog modules you change\n\nA module installed from the catalog is source in your project — change it freely. At install the\nplatform records its file fingerprints in `idos.modules.lock.json`; once any file differs, the module\ncounts as **customized**, and updating it from the catalog is refused until the user confirms\noverwriting their changes in the Modules dialog. An agent never forces that overwrite. Write down\nnon-obvious customizations in `docs/feature-history/` so the next person knows why the module differs\nfrom the catalog.\n\n## Files\n\n- Keep files focused. When a change adds a new responsibility to a file past ~400 lines, move that\n part into its own file — as part of that change, not as a separate drive-by refactor.\n- `src/idos.title.ts` and `idos.modules.lock.json` are platform-owned: never edit them.\n\n## Documentation\n\n- **IDOS.md** — the always-loaded guide: what the game is (\"This game\"), layout, conventions,\n \"never do X\" constraints, lasting user preferences. Short and evergreen; change a line when a fact\n changes. The \"Installed modules\" block is maintained by the platform.\n- **docs/feature-history/<slug>.md** — one file per game system or feature: what was built, why,\n and the decisions and constraints the code does not show. Update the system's file when it\n changes, and give every file ONE line in `docs/feature-history/README.md`:\n `- [Title](slug.md) — one-line gist`.\n- Never append a feature write-up to IDOS.md. It is loaded on every run, so every paragraph there is\n paid for by all future work — that is exactly how instruction files bloat.\n- Feature-history entries are not loaded automatically: open the relevant one before changing that\n system.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -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
|
}
|