@idosgames/mcp 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +11 -71
  4. package/registry/index.json +109 -109
  5. package/registry/modules/ai.json +4 -4
  6. package/registry/modules/board-game.json +3 -3
  7. package/registry/modules/boosts.json +6 -6
  8. package/registry/modules/character.json +33 -5
  9. package/registry/modules/chat.json +25 -5
  10. package/registry/modules/collections.json +22 -6
  11. package/registry/modules/coop-events.json +6 -6
  12. package/registry/modules/craft.json +6 -6
  13. package/registry/modules/deals.json +2 -2
  14. package/registry/modules/events.json +5 -5
  15. package/registry/modules/friends.json +25 -5
  16. package/registry/modules/idle-rpg.json +2 -2
  17. package/registry/modules/inventory.json +2 -2
  18. package/registry/modules/invite.json +5 -5
  19. package/registry/modules/leaderboard.json +21 -5
  20. package/registry/modules/lootboxes.json +6 -6
  21. package/registry/modules/mailbox.json +33 -5
  22. package/registry/modules/marketplace.json +34 -6
  23. package/registry/modules/mini-game.json +2 -2
  24. package/registry/modules/multiplayer.json +34 -10
  25. package/registry/modules/premium.json +6 -6
  26. package/registry/modules/profile.json +4 -4
  27. package/registry/modules/pvp.json +33 -5
  28. package/registry/modules/quests.json +5 -5
  29. package/registry/modules/rewards.json +6 -6
  30. package/registry/modules/season.json +5 -5
  31. package/registry/modules/store.json +4 -4
  32. package/registry/modules/tutorials.json +2 -2
  33. package/registry/modules/voxelcraft.json +264 -12
  34. package/registry/modules/workshop.json +49 -5
  35. package/registry/skills/authentication.json +1 -1
  36. package/registry/skills/blockchain-system.json +1 -1
  37. package/registry/skills/checkout-system.json +1 -1
  38. package/registry/skills/cloud-code.json +2 -2
  39. package/registry/skills/currency-system.json +1 -1
  40. package/registry/skills/idosgames-compose-modules.json +1 -1
  41. package/registry/skills/idosgames-getting-started.json +1 -1
  42. package/registry/skills/idosgames-module-contract.json +2 -2
  43. package/registry/skills/idosgames-project-structure.json +1 -1
  44. package/registry/skills/idosgames-ui-kit.json +2 -2
  45. package/registry/skills/marketplace-system.json +1 -1
  46. package/registry/skills/match-system.json +1 -1
  47. package/registry/skills/multiplayer-realtime.json +2 -2
  48. package/registry/skills/voxelcraft-worlds.json +2 -2
  49. package/registry/skills/workshop-system.json +2 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-getting-started",
3
3
  "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and the games (board-game, idle-rpg, voxelcraft, mini-game, …). 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 base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, mini-game, …). 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, …). Its\n **central cache** `client.cache` decides every read: the login brings the title config (by version)\n and the player's state (by each container's `Rev`) in ONE call; afterwards read state through\n `client.cache.ensureState([...containers])`, definitions through `getDefinitions()` (served from the\n config cache), server views through their service (cached by policy) — never re-fetch on start-up.\n The server views the game reads ride on that same login call (`ClientState.Views`): the client\n option `loginViews` — `\"auto\"` (default, what the game read last time), `\"all\"`, `\"none\"` or a list.\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. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby in `src/base/`, `src/app.config.ts` (start in the lobby or\n a game), an empty `src/modules.ts` → an empty lobby.\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}/`. The base's systems are the modules with `inBase: true` (profile,\n inventory, invite, friends, quests, mailbox); add the game(s) with the modules each game `uses`, and any\n other system (shop, VIP, leaderboards…) only when the app needs it.\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** — sign-in → the lobby: the systems' screens are its tabs (a tab hides itself while the\n title has nothing for it) and \"Play\" launches a game on the whole screen with a small button back\n — or, for a game whose route says `inLobby: true` (idle-rpg), straight into that game inside the\n lobby, the lobby's balances and tabs around it. The lobby draws the balances and the wallet; games hide their own copies by\n 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`, `kanban:read`, `kanban:write`, `build:write`. 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 **Shipping the game itself** (permission `build:write`) also goes through this server — the\n platform hosts it on the Title's own address (`{titleid}.idos.games`) and its idosgames.com page:\n\n 1. `begin_build_upload { title_id }` → `build_id`, a one-hour `upload_url`, `asset_base`.\n 2. Build the game as static files with that base: `vite build --base=<asset_base>` (a relative\n `./` base also works — Unity WebGL builds already are). Use the LIVE title id, never `-DEV`.\n 3. Zip the CONTENTS of the output folder (`index.html` at the zip root; a single `dist/` top folder\n is accepted) and upload it: `curl -T build.zip \"<upload_url>\"` — a plain PUT, no headers.\n 4. `finish_build_upload { title_id, build_id, deploy }` — unpacks it into a new version (it shows up\n in the dashboard's version history) and returns `TestUrl` (DEV address). `deploy: true` puts it\n live for players right away; without it, check `TestUrl` and call it again with `deploy: true`.\n Still unpacking when it returns → poll `get_build`. `list_builds` shows recent versions.\n\n A game that is live but whose Title is not published yet has no idosgames.com page — the\n response says so; the publisher publishes it from the dashboard.\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",
4
+ "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, mini-game, …). 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, …). Its\n **central cache** `client.cache` decides every read: the login brings the title config (by version)\n and the player's state (by each container's `Rev`) in ONE call; afterwards read state through\n `client.cache.ensureState([...containers])`, definitions through `getDefinitions()` (served from the\n config cache), server views through their service (cached by policy) — never re-fetch on start-up.\n The server views the game reads ride on that same login call (`ClientState.Views`): the client\n option `loginViews` — `\"auto\"` (default, what the game read last time), `\"all\"`, `\"none\"` or a list.\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. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby come from `@idosgames/app-shell` (mounted in `src/main.tsx`), `src/app.config.ts`\n (start in the lobby or a game, guest sign-in), an empty `src/modules.ts` → an empty lobby.\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}/`. The base's systems are the modules with `inBase: true` (profile,\n inventory, invite, friends, quests, mailbox); add the game(s) with the modules each game `uses`, and any\n other system (shop, VIP, leaderboards…) only when the app needs it.\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** — sign-in → the lobby: the systems' screens are its tabs (a tab hides itself while the\n title has nothing for it) and \"Play\" launches a game on the whole screen with a small button back\n — or, for a game whose route says `inLobby: true` (idle-rpg), straight into that game inside the\n lobby, the lobby's balances and tabs around it. The lobby draws the balances and the wallet; games hide their own copies by\n 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`, `kanban:read`, `kanban:write`, `build:write`. 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 **Shipping the game itself** (permission `build:write`) also goes through this server — the\n platform hosts it on the Title's own address (`{titleid}.idos.games`) and its idosgames.com page:\n\n 1. `begin_build_upload { title_id }` → `build_id`, a one-hour `upload_url`, `asset_base`.\n 2. Build the game as static files with that base: `vite build --base=<asset_base>` (a relative\n `./` base also works — Unity WebGL builds already are). Use the LIVE title id, never `-DEV`.\n 3. Zip the CONTENTS of the output folder (`index.html` at the zip root; a single `dist/` top folder\n is accepted) and upload it: `curl -T build.zip \"<upload_url>\"` — a plain PUT, no headers.\n 4. `finish_build_upload { title_id, build_id, deploy }` — unpacks it into a new version (it shows up\n in the dashboard's version history) and returns `TestUrl` (DEV address). `deploy: true` puts it\n live for players right away; without it, check `TestUrl` and call it again with `deploy: true`.\n Still unpacking when it returns → poll `get_build`. `list_builds` shows recent versions.\n\n A game that is live but whose Title is not published yet has no idosgames.com page — the\n response says so; the publisher publishes it from the dashboard.\n\n ⚠ **The build must be the game itself.** On the Title's idosgames.com page the game may embed\n (`<iframe>`) only platform hosts — never another website, with no setting to allow one. A\n \"wrapper\" build that signs in and then frames the publisher's own site shows **\"Content\n unavailable\"** there instead of the game: build and upload the real game. Network calls\n (`fetch`/WebSocket) from the embedded game reach platform hosts plus the Title's\n `Blockchain.IframeConnectHosts` (`save_blockchain`; wallet-connection hosts are always refused).\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, mini-game, store, character, workshop…), or asks about defineModule, registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable), ctx.modes, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.multiplayer / registerGame (playing online through the lobby's rooms system), ctx.navigate, 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, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.navigate, 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: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (`src/base/` — sign-in and the lobby):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby's \"Play\" launches it on the whole\n screen with a small \"To the lobby\" button over it; with `inLobby: true` on the route (idle-rpg) it\n instead lives INSIDE the lobby — the base keeps its balances and tabs around the game through the\n host's `header`/`footer` panel slots (the canvas and every other slot are laid out between them,\n so a game needs nothing for it) and the app starts in it;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\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\n### Loading — never a still screen\n\nA scene's FIRST start goes behind the app's loading screen by itself: the host shows it (themed,\n\"Loading «<mode>»…\"), lets it paint, THEN calls `mount` — so heavy synchronous work in `mount`\n(generating terrain, building meshes) never looks like a frozen app. The screen goes away after the\nfirst frame, or — when the scene has more to do — when its optional `ready(): Promise<void>` resolves\n(voxelcraft: the last played world restored from storage). Never draw your own start-up spinner.\n\nLong work AFTER the start goes through `ctx.loading` — the same screen:\n\n- `await ctx.loading.track(fetchWorld(), \"Loading the world…\")` — async work;\n- `await ctx.loading.run(() => buildWorld(seed), \"Building the world…\")` — work that BLOCKS the page:\n the screen is shown and painted first (calling heavy code right after `begin()` freezes the page\n before the screen appears);\n- `const task = ctx.loading.begin(\"…\"); task.progress(0.4, \"Textures…\"); task.done();` — with progress.\n\n### Notifications — `ctx.notifications`\n\nThe app's one system of toasts and alert windows (the UI kit draws them, themed): never draw a banner\nof your own. `ctx.notifications.toast({ level, title, message, icon, image, source, key, actions })`\n— `level`: `info | success | warning | error | reward`; `source` defaults to the module's id (the\ntoast shows that screen's name and a tap opens it). `await ctx.notifications.alert({ level, title,\nmessage, actions: [{ id: \"go\", label: \"Open\", primary: true, openFeature: \"season\" }, { label: \"Later\" }] })`\nresolves with the pressed action's id (`null` — closed). React screens: `useNotify()` from the kit.\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### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\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), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. 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`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\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## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\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",
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, mini-game, store, character, workshop…), or asks about defineModule, registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling), ctx.features (register / open / setBadge / setAvailable), ctx.modes, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.multiplayer / registerGame (playing online through the lobby's rooms system), ctx.navigate, 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, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling),\n ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.navigate, 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: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (sign-in and the lobby, from `@idosgames/app-shell`):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby's \"Play\" launches it on the whole\n screen with a small \"To the lobby\" button over it; with `inLobby: true` on the route (idle-rpg) it\n instead lives INSIDE the lobby — the base keeps its balances and tabs around the game through the\n host's `header`/`footer` panel slots (the canvas and every other slot are laid out between them,\n so a game needs nothing for it) and the app starts in it;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\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\n### Loading — never a still screen\n\nA scene's FIRST start goes behind the app's loading screen by itself: the host shows it (themed,\n\"Loading «<mode>»…\"), lets it paint, THEN calls `mount` — so heavy synchronous work in `mount`\n(generating terrain, building meshes) never looks like a frozen app. The screen goes away after the\nfirst frame, or — when the scene has more to do — when its optional `ready(): Promise<void>` resolves\n(voxelcraft: the last played world restored from storage). Never draw your own start-up spinner.\n\nLong work AFTER the start goes through `ctx.loading` — the same screen:\n\n- `await ctx.loading.track(fetchWorld(), \"Loading the world…\")` — async work;\n- `await ctx.loading.run(() => buildWorld(seed), \"Building the world…\")` — work that BLOCKS the page:\n the screen is shown and painted first (calling heavy code right after `begin()` freezes the page\n before the screen appears);\n- `const task = ctx.loading.begin(\"…\"); task.progress(0.4, \"Textures…\"); task.done();` — with progress.\n\n### Notifications — `ctx.notifications`\n\nThe app's one system of toasts and alert windows (the UI kit draws them, themed): never draw a banner\nof your own. `ctx.notifications.toast({ level, title, message, icon, image, source, key, actions })`\n— `level`: `info | success | warning | error | reward`; `source` defaults to the module's id (the\ntoast shows that screen's name and a tap opens it). `await ctx.notifications.alert({ level, title,\nmessage, actions: [{ id: \"go\", label: \"Open\", primary: true, openFeature: \"season\" }, { label: \"Later\" }] })`\nresolves with the pressed action's id (`null` — closed). React screens: `useNotify()` from the kit.\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### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\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), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. 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`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\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## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-project-structure",
3
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([baseModule, ...modules])\n app.config.ts what happens after sign-in (start) and the lobby's tab order\n ui.config.ts the look, animations and sounds of every screen\n base/ THE BASE: login/ (sign-in), lobby/ (tabs, balances, wallet, profile, \"Play\"),\n module.ts (baseModule) — ordinary project code\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts title / environment resolution — platform-owned, never edit\n env.ts build-time values (.env.local)\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: the catalog's systems and games, the game's own\n```\n\nSign-in and the lobby are the base; everything else the game does lives in a module — a **system**\n(its screen is a lobby tab: shop, heroes, leaderboards…) or a **game** (its mode gets a \"Play\"\nbutton). 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 the lobby, sign-in or chrome everybody shares.** Balances, the wallet, the profile, the\n tabs and \"Play\" are the base (`src/base/`) — change it there; games hide their own copies of the\n balances and wallet by themselves. Other UI shown over every mode → a panel with\n `activeOnly: false`. 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 lobby tab or \"Play\" button, or could be switched off on its\nown, it is its own module.\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`, `src/config.ts` and `idos.modules.lock.json` are platform-owned: never edit\n them. The platform's build packs its own `idos.title.ts` and `config.ts` over the project's, so\n an edit there never reaches the game anyway.\n- Never read the title or the environment from the URL: a link is editable by anyone. Use\n `client.titleID` (or `ctx.titleId` in a module's `setup()`).\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: ONLY what a later session must take\n into account and cannot see in the code — why it is designed this way, a non-obvious constraint, a\n decision the user made, a rule for how the system must be changed. Test: \"would a new session\n working on this system go wrong without knowing this?\" If not, write nothing. Ordinary edits (a\n restyle, a bug fix, a text or balance tweak, a routine new screen) get no entry, and it is never a\n log of what was done. Update the system's file when such a fact changes, and give every file ONE\n line in `docs/feature-history/README.md`: `- [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",
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([createBaseModule(…), ...modules])\n app.config.ts what happens after sign-in (start) and the lobby's tab order\n ui.config.ts the look, animations and sounds of every screen\n walletLogin.tsx, the wallet stack of the project: sign-in / \"link wallet\" buttons and the lobby's\n walletButton.tsx wallet button (the base itself is in @idosgames/app-shell, not in the project)\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts title / environment resolution — platform-owned, never edit\n env.ts build-time values (.env.local)\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: the catalog's systems and games, the game's own\n```\n\nSign-in and the lobby are the base; everything else the game does lives in a module — a **system**\n(its screen is a lobby tab: shop, heroes, leaderboards…) or a **game** (its mode gets a \"Play\"\nbutton). 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 the lobby, sign-in or chrome everybody shares.** Balances, the wallet, the profile, the\n tabs and \"Play\" are the base, part of `@idosgames/app-shell` (not project code): its settings are\n `src/app.config.ts` and the look is `src/ui.config.ts`; games hide their own copies of the\n balances and wallet by themselves. Other UI shown over every mode → a panel with\n `activeOnly: false`. 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 lobby tab or \"Play\" button, or could be switched off on its\nown, it is its own module.\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`, `src/config.ts` and `idos.modules.lock.json` are platform-owned: never edit\n them. The platform's build packs its own `idos.title.ts` and `config.ts` over the project's, so\n an edit there never reaches the game anyway.\n- Never read the title or the environment from the URL: a link is editable by anyone. Use\n `client.titleID` (or `ctx.titleId` in a module's `setup()`).\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: ONLY what a later session must take\n into account and cannot see in the code — why it is designed this way, a non-obvious constraint, a\n decision the user made, a rule for how the system must be changed. Test: \"would a new session\n working on this system go wrong without knowing this?\" If not, write nothing. Ordinary edits (a\n restyle, a bug fix, a text or balance tweak, a routine new screen) get no entry, and it is never a\n log of what was done. Update the system's file when such a fact changes, and give every file ONE\n line in `docs/feature-history/README.md`: `- [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
5
  "references": []
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-ui-kit",
3
- "description": "Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready system modules (store, character, inventory, …): change colours, animations and sounds in ONE file (src/ui.config.ts), install a shop / VIP / heroes / inventory / chests / boosts / leaderboard / daily rewards / quests / collections / craft / marketplace / offers / events / team event / season / friends / chat / invite / arena / multiplayer rooms / profile / tutorials / workshop / AI studio system instead of writing it, open another module's screen with ctx.features.open, register a new screen (a lobby tab) with ctx.features.register and hide it while the title has no data (setAvailable), split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this whenever the user asks to change how the game's interface looks, sounds or animates, to add or build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features, FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.",
4
- "content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready\n system modules (store, character, inventory, …): change colours, animations and sounds in ONE file (src/ui.config.ts),\n install a shop / VIP / heroes / inventory / chests / boosts / leaderboard / daily rewards / quests /\n collections / craft / marketplace / offers / events / team event / season / friends / chat / invite /\n arena / multiplayer rooms / profile / tutorials / workshop / AI studio system instead of writing it, open another module's screen with ctx.features.open, register a new screen (a lobby\n tab) with ctx.features.register and hide it while the title has no data (setAvailable), split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a\n phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for\n tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this\n whenever the user asks to change how the game's interface looks, sounds or animates, to add or\n build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a\n settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the system modules\n\nThe app's interface is the base's lobby (`src/base/`) plus **system modules**, all on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- system modules — the platform's systems, each a whole `feature` module (its screen = a lobby\n tab, its client logic, its demo settings). The base installs six of them into every new project\n (`inBase`: profile, inventory, invite, friends, quests, mailbox); the rest are installed only when the app\n needs them (a game's `uses`, or a task that needs what they `provide`). ONE system per player-facing module of the engine\n (`API/Client/v2/<Module>`) — the owner's rule since 29.09.2026. The module id is the feature id:\n\n| Module | Engine module | Screen |\n| ------------- | -------------- | ------------------------------------------------------------- |\n| `store` | Store+Purchase | shop: stores as tabs, offers, purchase popup |\n| `premium` | Premium | VIP passes: benefits, timer, free trial, buy / extend |\n| `character` | Character | heroes: gear slots, \"Equip best\", stats, ranks, skins |\n| `inventory` | Item | items by rarity, item card (always shown, even with no items) |\n| `lootboxes` | Lootbox | chests: every way to open, ×1 / ×10, opening animation |\n| `boosts` | TimedBoost | running boosts + global happy hours, activate a boost |\n| `leaderboard` | Leaderboard | tables, my place, rank rewards, last cycle's prize |\n| `rewards` | Reward | daily login calendar, greeting popup |\n| `quests` | Quest | quests by cycle, details, claim all, points track |\n| `collections` | Collection | album of sets, packs, chests for points, card trades |\n| `craft` | Craft | recipes, contract popup |\n| `marketplace` | Marketplace | lots, auctions, selling, trades |\n| `deals` | DealOffer | special offers, auto-popup of a new deal |\n| `events` | TimedEvent | limited-time events, milestones, VIP prize |\n| `coop-events` | CoopEvent | team event: join a group, spin to build, team prize |\n| `season` | Season | season tiers, VIP prize |\n| `friends` | Social | friends, requests, suggestions, news |\n| `chat` | Chat | channels, private chats |\n| `invite` | Referral | own code and link, friend's code, rewards (always shown) |\n| `pvp` | Match | duels, strategy, fight replay |\n| `multiplayer` | Multiplayer | rooms: list, create / join by code, ready, chat, host \"Play\" |\n| `profile` | User | name, sound, language, consent, delete account |\n| `tutorials` | Tutorial | hint overlay + replay list |\n| `workshop` | Workshop | players' and the publisher's content: catalog, publish |\n| `ai` | AI | AI studio: the title's AI features for players, history |\n\nNot a system on purpose: Currency, Authentication, Blockchain (the base — balances, sign-in,\nwallet); GameLoop (the `board-game` game); CommunityMarketing (idosgames.com only); Mailbox and\nAdvertising (no client handler / no web ad network); Analytics, Experiment, Push, Localization,\nCloudCode, DataCollections, TitleCustomData, UserCustomData (services the systems and games call).\n\nHand-offs between systems go through `ctx.features.open(id, args)`: inventory → `character`\n(`{ itemInstanceID }`) and → `lootboxes` (`{ itemID }` of a chest or key); events / season →\n`premium` (`{ premiumID }`). Multiplayer: the `multiplayer` system owns the rooms; a game registers\nin `ctx.multiplayer` and takes the room in `start(room)` (skill **multiplayer-realtime**); the lobby\nshows \"With friends\" next to such a game's \"Play\".\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`). The app does not need one → remove it\nfrom `src/modules.ts`, its tab is gone. The lobby itself (tabs, balances, wallet, profile, \"Play\") is\nthe base, `src/base/lobby/`.\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- **One theme for the WHOLE app:** sign-in (`src/base/login/`), the lobby, every system and the host's\n own screens all draw from these tokens. A new look — even a light theme — is an edit of this file\n ONLY: never recolour `LoginScreen.tsx`, the logo, icons or `index.html` by hand (the logo and the\n sign-in icons are painted with the theme's colours, the page background follows `bgBottom`).\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `redDeep`, `shadow`, `backdrop`,\n `radius`, `buttonRadius`, `font`, `rarity` (rarity id → frame colour).\n- **Derived tokens — usually leave them out.** They are computed from the colours above: set a light\n `panel` and dark `text`, and the dark text outline disappears (`textOutline`), lines and recesses\n turn faint-dark instead of white/black (`line`, `well`, `wellSoft`, `wellShadow`, `edge`), cards get a\n soft shadow (`panelShadow`), and buttons of a pastel tone get dark text (`onGreen`, `onGold`,\n `onBlue`, `onRed`). Set one only to override the computed value.\n- A light theme is just colours:\n `theme: { bgTop: \"#f4fcf8\", bgBottom: \"#d9f0e6\", panel: \"#ffffff\", panelDeep: \"#f1faf5\",\npanelEdge: \"#bfe6d3\", text: \"#1f3b30\", textDim: \"#5f7d71\", green: \"#7ee0b5\", greenDeep: \"#4cc493\" }`.\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (the profile module draws\n them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA module registers a **feature**; whoever draws the menu shows it — the base's lobby as tabs (phone:\n4 + \"More\", tablet: more, desktop: a side rail; the base takes the shared-UI role `\"menu\"`), or the\nhost's floating ☰ when nobody takes the role. Tab order and main tabs can be overridden in\n`src/app.config.ts` (`lobby.order`, `lobby.primary`).\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n available: false, // hidden until the title has something for it — see below\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n // The title has forge recipes → show the tab; the reason is for the AI Coder's agent only:\n ctx.features.setAvailable(\"forge\", hasRecipes(configSection(ctx.client, \"Craft\")), \"no forge recipes\");\n}\n```\n\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. It returns `false` when the module is not installed or its\n tab is hidden (`ctx.features.get(\"store\")?.available`). From a game (the lobby is off screen) the\n host shows the screen in a window.\n- A screen that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows — from the client config\n (`configSection(client, \"Store\")`) or a read it makes anyway (a badge watcher); no extra request\n at start-up: every module's first reads arrive together and brush the per-player rate limit.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, a badge watcher, an overlay) is\n a panel: `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`.\n- **A badge must go out the moment the reward is taken.** Count it in the always-on watcher from the\n CURRENT cache on every change — `client.on(\"user:anyUpdated\", update)` reading\n `client.data.user.state` — not in an effect keyed on a state object (the cache patches it in place,\n so such an effect does not re-run and the badge stays lit). A badge counted from data the screen\n re-reads (a storefront, active events) also listens to that read's event\n (`store:storefrontLoaded`…), and a store that several places reload runs one read at a time and keeps\n what it has when a read fails — see `modules/events` / `modules/deals`.\n- **Never re-read at start-up what the login already brought** — read through the central cache\n (`client.cache`, core ≥ 0.21). State containers: `client.cache.ensureState([\"Quest\"])` (no request\n while the container's `Rev` is confirmed; the server only when it must compute something — a new\n quest cycle, a hero given by default); a screen that wants changes made elsewhere passes\n `{ maxAgeMs: 60_000 }`. Definitions: `client.x.getDefinitions()` answers from the title config cache.\n Server views (storefront, active events/deals/season, unread counters, leaderboard progress) are\n cached by their service — call them freely, and take the data FROM THE ANSWER: the login usually\n brings them before a watcher subscribes, and a read the cache answers emits no `*Loaded` event. A game's own server data: `defineView` + `client.cache.read`.\n- A section the engine does not put into the client config (`TimedBoost`, `Marketplace`, `Match`,\n `AI`) is read with the service's own `getDefinitions()` — it lands in the same config section and is\n kept under the config version, so the next login does not ask again.\n- Auto-popups (`<Popup queued>`) wait while a game mode is on screen and show in the lobby.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**.\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a bottom sheet on a phone and a centred window elsewhere (`variant=\"center\"` forces\n centred); it animates in and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby's balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- **Notifications — `useNotify()`** (the same system as a module's `ctx.notifications`):\n `notify.toast({ level: \"success\", title: \"Saved\" })` — a toast at the top that goes by itself;\n `await notify.alert({ level: \"warning\", title, message, actions })` — a window the player must\n answer, queued one at a time. Levels `info | success | warning | error | reward` pick the colour,\n icon, sound and how long a toast stays (errors longest). `icon` (a glyph, emoji or URL), `image` (a\n thumbnail / a big picture in the window), `source` (a feature id — its name on the card, a tap opens\n it), `key` (the same key replaces instead of stacking; identical toasts merge into \"×2\"),\n `duration` (`0` — until closed), `actions`. Refusals: `notify.toast({ level: \"error\", message:\nerrorText(res.error) })`; never re-check server rules. `useToast()(text, kind)` is the old short\n form of the same thing.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n- ⚠ **No colour literals in a screen** (`\"#fff\"`, `\"rgba(0,0,0,.3)\"`, `\"#5a3fd0\"`): they break the\n moment the theme changes (white text on a light panel, a black stripe under a pastel button). Use\n the tokens: text `v.text` / `v.textDim` (or `...outlined`), surfaces `panel` / `well` / `v.wellSoft`,\n lines `v.line`, dimming `v.backdrop`. A coloured banner, \"your row\", a highlighted day →\n `...toneSurface(\"blue\" | \"green\" | \"gold\" | \"red\")`: it fills with that tone AND switches the text\n inside it to the tone's own readable colour. Text on a filled button — `ON_TONE[tone]`. A blend:\n `` `color-mix(in srgb, ${v.gold} 20%, transparent)` ``.\n\n## Tutorial hints\n\nThe `tutorials` module highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs\nare `tab:{featureID}`, its profile button `lobby:profile`, its wallet `lobby:wallet`, a game's \"Play\"\nbutton `play:{modeID}`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n",
3
+ "description": "Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready system modules: change colours, animations and sounds in ONE file (src/ui.config.ts), install a ready system (shop, VIP, heroes, inventory, chests, quests, leaderboard, daily rewards, season, friends, chat, profile, workshop, AI studio …) instead of writing it, open or register a screen (ctx.features.open / register, setAvailable), split a dense screen into popups, play UI sounds, celebrate a reward, mark tutorial anchors and read sound / motion prefs. Use this whenever the user asks to change how the game's interface looks, sounds or animates, or to add a screen, menu, popup, reward animation, bottom navigation or settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.",
4
+ "content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui)\n and the ready system modules: change colours, animations and sounds in ONE\n file (src/ui.config.ts), install a ready system (shop, VIP, heroes, inventory,\n chests, quests, leaderboard, daily rewards, season, friends, chat, profile,\n workshop, AI studio …) instead of writing it, open or register a screen\n (ctx.features.open / register, setAvailable), split a dense screen into\n popups, play UI sounds, celebrate a reward, mark tutorial anchors and read\n sound / motion prefs. Use this whenever the user asks to change how the\n game's interface looks, sounds or animates, or to add a screen, menu, popup,\n reward animation, bottom navigation or settings screen, or touches\n @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the system modules\n\nThe app's interface is the base's lobby (from `@idosgames/app-shell`) plus **system modules**, all on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- system modules — the platform's systems, each a whole `feature` module (its screen = a lobby\n tab, its client logic, its demo settings). The base installs six of them into every new project\n (`inBase`: profile, inventory, invite, friends, quests, mailbox); the rest are installed only when the app\n needs them (a game's `uses`, or a task that needs what they `provide`). ONE system per player-facing module of the engine\n (`API/Client/v2/<Module>`) — the owner's rule since 29.09.2026. The module id is the feature id:\n\n| Module | Engine module | Screen |\n| ------------- | -------------- | ------------------------------------------------------------- |\n| `store` | Store+Purchase | shop: stores as tabs, offers, purchase popup |\n| `premium` | Premium | VIP passes: benefits, timer, free trial, buy / extend |\n| `character` | Character | heroes: gear slots, \"Equip best\", stats, ranks, skins |\n| `inventory` | Item | items by rarity, item card (always shown, even with no items) |\n| `lootboxes` | Lootbox | chests: every way to open, ×1 / ×10, opening animation |\n| `boosts` | TimedBoost | running boosts + global happy hours, activate a boost |\n| `leaderboard` | Leaderboard | tables, my place, rank rewards, last cycle's prize |\n| `rewards` | Reward | daily login calendar, greeting popup |\n| `quests` | Quest | quests by cycle, details, claim all, points track |\n| `collections` | Collection | album of sets, packs, chests for points, card trades |\n| `craft` | Craft | recipes, contract popup |\n| `marketplace` | Marketplace | lots, auctions, selling, trades |\n| `deals` | DealOffer | special offers, auto-popup of a new deal |\n| `events` | TimedEvent | limited-time events, milestones, VIP prize |\n| `coop-events` | CoopEvent | team event: join a group, spin to build, team prize |\n| `season` | Season | season tiers, VIP prize |\n| `friends` | Social | friends, requests, suggestions, news |\n| `chat` | Chat | channels, private chats |\n| `invite` | Referral | own code and link, friend's code, rewards (always shown) |\n| `pvp` | Match | duels, strategy, fight replay |\n| `multiplayer` | Multiplayer | rooms: list, create / join by code, ready, chat, host \"Play\" |\n| `profile` | User | name, sound, language, consent, delete account |\n| `tutorials` | Tutorial | hint overlay + replay list |\n| `workshop` | Workshop | players' and the publisher's content: catalog, publish |\n| `ai` | AI | AI studio: the title's AI features for players, history |\n\nNot a system on purpose: Currency, Authentication, Blockchain (the base — balances, sign-in,\nwallet); GameLoop (the `board-game` game); CommunityMarketing (idosgames.com only); Mailbox and\nAdvertising (no client handler / no web ad network); Analytics, Experiment, Push, Localization,\nCloudCode, DataCollections, TitleCustomData, UserCustomData (services the systems and games call).\n\nHand-offs between systems go through `ctx.features.open(id, args)`: inventory → `character`\n(`{ itemInstanceID }`) and → `lootboxes` (`{ itemID }` of a chest or key); events / season →\n`premium` (`{ premiumID }`). Multiplayer: the `multiplayer` system owns the rooms; a game registers\nin `ctx.multiplayer` and takes the room in `start(room)` (skill **multiplayer-realtime**); the lobby\nshows \"With friends\" next to such a game's \"Play\".\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`). The app does not need one → remove it\nfrom `src/modules.ts`, its tab is gone. The lobby itself (tabs, balances, wallet, profile, \"Play\") is\nthe base, `@idosgames/app-shell` (configured by `src/app.config.ts`, styled by `src/ui.config.ts`).\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- **One theme for the WHOLE app:** sign-in (the SDK's `DefaultLoginScreen`, mounted by\n `src/main.tsx`), the lobby, every system and the host's own screens all draw from\n these tokens. A new look — even a light theme — is an edit of this file ONLY: never recolour the\n sign-in, the logo, icons or `index.html` by hand (the logo and the sign-in icons are painted with the\n theme's colours, the page background follows `bgBottom`).\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `redDeep`, `shadow`, `backdrop`,\n `radius`, `buttonRadius`, `font`, `rarity` (rarity id → frame colour).\n- **Derived tokens — usually leave them out.** They are computed from the colours above: set a light\n `panel` and dark `text`, and the dark text outline disappears (`textOutline`), lines and recesses\n turn faint-dark instead of white/black (`line`, `well`, `wellSoft`, `wellShadow`, `edge`), cards get a\n soft shadow (`panelShadow`), and buttons of a pastel tone get dark text (`onGreen`, `onGold`,\n `onBlue`, `onRed`). Set one only to override the computed value.\n- A light theme is just colours:\n `theme: { bgTop: \"#f4fcf8\", bgBottom: \"#d9f0e6\", panel: \"#ffffff\", panelDeep: \"#f1faf5\",\npanelEdge: \"#bfe6d3\", text: \"#1f3b30\", textDim: \"#5f7d71\", green: \"#7ee0b5\", greenDeep: \"#4cc493\" }`.\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (the profile module draws\n them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA module registers a **feature**; whoever draws the menu shows it — the base's lobby as tabs (phone:\n4 + \"More\", tablet: more, desktop: a side rail; the base takes the shared-UI role `\"menu\"`), or the\nhost's floating ☰ when nobody takes the role. Tab order and main tabs can be overridden in\n`src/app.config.ts` (`lobby.order`, `lobby.primary`).\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n available: false, // hidden until the title has something for it — see below\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n // The title has forge recipes → show the tab; the reason is for the AI Coder's agent only:\n ctx.features.setAvailable(\"forge\", hasRecipes(configSection(ctx.client, \"Craft\")), \"no forge recipes\");\n}\n```\n\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. It returns `false` when the module is not installed or its\n tab is hidden (`ctx.features.get(\"store\")?.available`). From a game (the lobby is off screen) the\n host shows the screen in a window.\n- A screen that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows — from the client config\n (`configSection(client, \"Store\")`) or a read it makes anyway (a badge watcher); no extra request\n at start-up: every module's first reads arrive together and brush the per-player rate limit.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, an overlay) is a panel:\n `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`. Background work with\n NOTHING to draw - keeping a badge in step, a poll - is NOT a panel: use `ctx.effect(() => { …; return\ncleanup })` / `ctx.every(ms, run)` in `setup()` (cleaned up on logout).\n- **A badge must go out the moment the reward is taken.** Count it in a `ctx.effect` watcher from the\n CURRENT cache on every change — `client.on(\"user:anyUpdated\", update)` reading\n `client.data.user.state` — not in an effect keyed on a state object (the cache patches it in place,\n so such an effect does not re-run and the badge stays lit). A badge counted from data the screen\n re-reads (a storefront, active events) also listens to that read's event\n (`store:storefrontLoaded`…), and a store that several places reload runs one read at a time and keeps\n what it has when a read fails — see `modules/events` / `modules/deals`.\n- **Never re-read at start-up what the login already brought** — read through the central cache\n (`client.cache`, core ≥ 0.21). State containers: `client.cache.ensureState([\"Quest\"])` (no request\n while the container's `Rev` is confirmed; the server only when it must compute something — a new\n quest cycle, a hero given by default); a screen that wants changes made elsewhere passes\n `{ maxAgeMs: 60_000 }`. Definitions: `client.x.getDefinitions()` answers from the title config cache.\n Server views (storefront, active events/deals/season, unread counters, leaderboard progress) are\n cached by their service — call them freely, and take the data FROM THE ANSWER: the login usually\n brings them before a watcher subscribes, and a read the cache answers emits no `*Loaded` event. A game's own server data: `defineView` + `client.cache.read`.\n- A section the engine does not put into the client config (`TimedBoost`, `Marketplace`, `Match`,\n `AI`) is read with the service's own `getDefinitions()` — it lands in the same config section and is\n kept under the config version, so the next login does not ask again.\n- Auto-popups (`<Popup queued>`) wait while a game mode is on screen and show in the lobby.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**. Form controls: `TextInput` / `TextArea` (the kit's text field in the theme's colours - pass `style` only for width/size) and `Field` (a label above a control); do not hand-style an `<input>`.\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a bottom sheet on a phone and a centred window elsewhere (`variant=\"center\"` forces\n centred); it animates in and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby's balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- **Notifications — `useNotify()`** (the same system as a module's `ctx.notifications`):\n `notify.toast({ level: \"success\", title: \"Saved\" })` — a toast at the top that goes by itself;\n `await notify.alert({ level: \"warning\", title, message, actions })` — a window the player must\n answer, queued one at a time. Levels `info | success | warning | error | reward` pick the colour,\n icon, sound and how long a toast stays (errors longest). `icon` (a glyph, emoji or URL), `image` (a\n thumbnail / a big picture in the window), `source` (a feature id — its name on the card, a tap opens\n it), `key` (the same key replaces instead of stacking; identical toasts merge into \"×2\"),\n `duration` (`0` — until closed), `actions`. Refusals: `notify.toast({ level: \"error\", message:\nerrorText(res.error) })`; never re-check server rules. `useToast()(text, kind)` is the old short\n form of the same thing.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- A crypto balance is ONE number — the backed crypto plus its IOU (`{ID}_IOU`, the unbacked part of\n rewards; it spends like the crypto): `cryptoBalance(state, id, catalog.cryptoCurrencies)` →\n `{ backed, unbacked, total }`, `<CryptoBalanceChip currencyID=\"Main\" />` (the total, the split in a\n hover/tap popover; the lobby's top row shows it). Never draw `{ID}_IOU` as a currency of its own\n (`iouCurrencies(...)` lists them), never round a crypto amount to whole numbers (`formatCryptoAmount`).\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n- ⚠ **No colour literals in a screen** (`\"#fff\"`, `\"rgba(0,0,0,.3)\"`, `\"#5a3fd0\"`): they break the\n moment the theme changes (white text on a light panel, a black stripe under a pastel button). Use\n the tokens: text `v.text` / `v.textDim` (or `...outlined`), surfaces `panel` / `well` / `v.wellSoft`,\n lines `v.line`, dimming `v.backdrop`. A coloured banner, \"your row\", a highlighted day →\n `...toneSurface(\"blue\" | \"green\" | \"gold\" | \"red\")`: it fills with that tone AND switches the text\n inside it to the tone's own readable colour. Text on a filled button — `ON_TONE[tone]`. A blend:\n `` `color-mix(in srgb, ${v.gold} 20%, transparent)` ``.\n\n## Tutorial hints\n\nThe `tutorials` module highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs\nare `tab:{featureID}`, its profile button `lobby:profile`, its wallet `lobby:wallet`, a game's \"Play\"\nbutton `play:{modeID}`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n",
5
5
  "references": []
6
6
  }
@@ -5,7 +5,7 @@
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Marketplace data model — reference\n\nFull shape of the config (`MarketplaceDefinitions`), the offer/state views the\nclient actually sees, the request shape, and the escrow/settlement/commission\nmodel in detail. All of these are **strictly typed in the SDK** —\n`MarketplaceDefinitions` and every nested block, `MarketplaceOfferView`,\n`MarketplaceAuctionState`, the various response types — are exported from\n`@idosgames/core`, so `getDefinitions()` /\n`getSection<MarketplaceDefinitions>(\"Marketplace\")` and every service call give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\n## Contents\n\n- [What the client never sees](#what-the-client-never-sees)\n- [Config: MarketplaceDefinitions](#config-marketplacedefinitions)\n- [Per-listing-type settings](#per-listing-type-settings)\n- [Offer view: MarketplaceOfferView](#offer-view-marketplaceofferview)\n- [Auction state](#auction-state)\n- [Auction bid-step formula](#auction-bid-step-formula)\n- [User state: UserMarketplaceState (rate limits)](#user-state-usermarketplacestate-rate-limits)\n- [Responses](#responses)\n- [Request shape](#request-shape)\n- [Settlement & escrow model](#settlement--escrow-model)\n- [Commission model](#commission-model)\n- [Server-side limits, locking, and idempotency](#server-side-limits-locking-and-idempotency)\n\n---\n\n## What the client never sees\n\nThe backend's internal `MarketplaceOfferDocument` (raw escrow bookkeeping,\ncommission snapshot) and the `MarketplaceTradeLogDocument` /\n`MarketplaceLedgerDocument` never reach the client. Everything documented here\nis the **public projection**: `MarketplaceOfferView` (what browsing/my-state/\nhistory return instead of the raw document) and `MarketplaceHistoryEntryView`.\nOne exception: `MarketplaceAuctionState` is shared **verbatim** between the\ninternal document and the public view — the backend assigns the same object to\nboth (`MarketplaceHelpers.BuildOfferView`, `Auction = doc.Auction`) — so what's\nbelow is exactly what's stored.\n\n---\n\n## Config: MarketplaceDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MarketplaceDefinitions>(\"Marketplace\")`.\n\n```ts\ninterface MarketplaceDefinitions {\n Enabled?: boolean; // master switch; default false — must be enabled explicitly\n Gate?: SegmentGate; // segment-based access gate (see other modules' Gate usage)\n Schedule?: ScheduleSpec; // time-window the marketplace is open; null = always open\n PricePolicy?: MarketplacePricePolicy;\n Commission?: MarketplaceCommissionPolicy;\n Tradability?: MarketplaceTradabilityPolicy;\n Listings?: MarketplaceListingSettings;\n Auctions?: MarketplaceAuctionSettings;\n BuyOrders?: MarketplaceBuyOrderSettings;\n DirectTrades?: MarketplaceDirectTradeSettings;\n}\n```\n\n`getDefinitions()`'s response also carries two computed flags alongside\n`Definitions`:\n\n```ts\ninterface MarketplaceGetDefinitionsResponse {\n Definitions?: MarketplaceDefinitions | null;\n IsOpenNow?: boolean | null; // Enabled && Schedule evaluated against server time\n GatePassed?: boolean | null; // Gate evaluated against this player's segment\n}\n```\n\nCheck both before showing \"create offer\" UI — `Enabled` alone doesn't account\nfor the schedule window or the segment gate. **`Gate`/`Schedule` are only\nenforced on trading actions** (create/buy/bid/fill/accept) — `claimBack`,\ncancels, and a decline are never blocked by either, confirmed in\n`MarketplaceV2.CheckTradeGate`'s call sites (`Marketplace.cs`): it's invoked\nfrom `CreateSellOffer`, `Buy`, `PlaceBid`, `CreateBuyOrder`, `FillBuyOrder`,\n`CreateDirectTrade`, and the accept branch of `RespondDirectTrade` — never\nfrom `CancelOffer`, `ClaimBack`, or the decline branch.\n\n`SegmentGate`, `ScheduleSpec`, and `LimitSpec` (used throughout the per-type\nsettings below) are generic cross-module shapes shared with other live-ops\nsystems (segment/schedule gating, anti-farm counters) — this reference only\nlists the fields Marketplace itself reads.\n\n---\n\n## Per-listing-type settings\n\n### PricePolicy — what a price/bid can be made of\n\n```ts\ninterface MarketplacePricePolicy {\n AllowVirtualCurrency?: boolean; // default true; ignored when Allowed is non-empty\n AllowItems?: boolean; // default false; ignored when Allowed is non-empty\n AllowEventTokens?: boolean; // default false; ignored when Allowed is non-empty\n Allowed?: EntryResourceRule[]; // authoritative whitelist when non-empty (kind + id + min/max amount)\n MaxPositions?: number; // default 5; cap on distinct resource entries + event tokens in one price; 0 = unlimited\n}\n```\n\nMirrors the entry-policy pattern from the Match module (`MatchEntrySettings`) —\nsame `EntryResourceRule` shape (`Kind`, `CurrencyID`/`CatalogID`/`ItemID`,\n`TokenType`/`EntityID`, `MinAmount`/`MaxAmount`). A `createListing`/\n`createAuction`/`createBuyOrder`/`createDirectTrade`'s price (or an auction's\nstarting-bid axis) is validated against this policy on create\n(`MarketplaceHelpers.ValidatePricePolicy`, `Marketplace.cs`):\n\nTwo rules apply **unconditionally**, regardless of whitelist/flags\n(`MarketplaceHelpers.cs`):\n\n- A virtual-currency price position is rejected unless that currency's own\n `VirtualCurrencyDefinition.Permissions.IsTradable === true` — error:\n `\"Currency '{id}' is not tradable between players (Permissions.IsTradable=false).\"`\n- An **unstackable item can never be used as a price position** (only as\n goods) — error: `\"Unstackable items cannot be used as a price.\"` — because\n granting it back would have to re-mint a new instance identity.\n\nWhen `Allowed` is non-empty it is authoritative — every price position must\nmatch one of its rules (including `MinAmount`/`MaxAmount`); the\n`AllowVirtualCurrency`/`AllowItems`/`AllowEventTokens` flags are only consulted\nwhen `Allowed` is empty.\n\n### Commission — the cut taken at settlement\n\n```ts\ninterface MarketplaceCommissionPolicy {\n Percent?: number; // default 0.05 (5%)\n MinPerPosition?: number; // floor per priced resource entry; default 0 (no minimum)\n Sink?: \"Burn\" | \"Ledger\"; // default \"Burn\"\n LedgerAccountID?: string; // target account when Sink === \"Ledger\"; empty falls back to \"default\"\n PerCatalogOverrides?: Record<string, MarketplaceCommissionOverride>;\n ApplyToDirectTrades?: boolean; // default false — gifts/trades are commission-exempt by default\n}\n\ninterface MarketplaceCommissionOverride {\n Percent?: number;\n MinPerPosition?: number;\n}\n```\n\n`PerCatalogOverrides` is keyed by the **goods'** `CatalogID` (not the price's) —\na catalog present here replaces the top-level `Percent`/`MinPerPosition` for\noffers whose goods come from that catalog\n(`MarketplaceHelpers.ResolveCommission`). See\n[Commission model](#commission-model) below for the exact per-position fee\nformula.\n\n### Tradability — what's allowed on the market at all\n\n```ts\ninterface MarketplaceTradabilityPolicy {\n AllowedCatalogIDs?: string[]; // null/absent = no catalog allow-list (all allowed unless denied)\n DeniedCatalogIDs?: string[];\n DeniedItemIDs?: string[];\n AllowUnstackableWithState?: boolean; // default true; allow listing instances with state (Level>1, RemainingUses>1, CustomData)\n}\n```\n\nBase requirement, checked first regardless of policy:\n`ItemDefinition.IsTradable` must be `true`\n(`MarketplaceHelpers.ResolveTradableItem`) — error:\n`\"Item '{itemID}' is not tradable (ItemDefinition.IsTradable=false).\"` The item\nis resolved with the same strict→fallback catalog resolver used elsewhere\n(self-heals `CatalogID` if the item moved catalogs), so the response's\n`GoodsCatalogID` can differ from what you originally passed as `catalogID`.\n\nWhen `AllowUnstackableWithState` is `false`, only \"pristine\" instances\n(`Level <= 1`, `RemainingUses <= 1`, no `CustomData`) may be escrowed as goods\n— error: `\"Instance '{id}' has state (level/custom data) — selling stateful\ninstances is disabled.\"` Equipped or expired instances are always rejected\nregardless of this flag (`\"Instance '{id}' is equipped — unequip it\nfirst.\"` / `\"Instance '{id}' has expired.\"`).\n\n### Listings\n\n```ts\ninterface MarketplaceListingSettings {\n Enabled?: boolean; // default true (when the module itself is enabled)\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n MaxActiveListings?: number; // default 10; per-player cap counting Listing+Auction together; 0 = unlimited\n CreateLimits?: LimitSpec;\n BuyLimits?: LimitSpec;\n ListingFeeOptions?: Record<string, PriceOption>; // ways to pay the fee charged in addition to escrowing the goods; only the Standard part is used, and the fee is never paid in a store (P2P + refundable)\n RefundListingFeeOnCancel?: boolean; // default false — fee is forfeit on cancel; never refunded on expiry either way\n}\n```\n\n`MaxActiveListings` is checked against a single count query across **both**\n`Listing` and `Auction` offer types created by the player\n(`MarketplaceDBService.CountActiveByCreatorAsync(..., Listing, Auction)`) — a\ntitle with `MaxActiveListings: 10` caps the player at 10 combined open\nlistings+auctions, not 10 of each.\n\n### Auctions\n\n```ts\ninterface MarketplaceAuctionSettings {\n Enabled?: boolean; // default true\n MinDurationHours?: number; // default 1\n MaxDurationHours?: number; // default 168\n AntiSnipeWindowSeconds?: number; // default 120; 0 = disabled\n AntiSnipeExtensionSeconds?: number; // default 120\n MaxAntiSnipeExtensions?: number; // default 10\n MinBidStepPercent?: number; // default 0.05 (5%)\n MinBidStepAbsolute?: number; // default 1\n BidLimits?: LimitSpec;\n}\n```\n\nAnti-snipe: a bid placed within `AntiSnipeWindowSeconds` of the auction's\ncurrent `ExpiresAt` pushes the deadline out by `AntiSnipeExtensionSeconds`,\nup to `MaxAntiSnipeExtensions` times total per auction —\n`MarketplaceAuctionState.ExtensionCount` tracks how many have already\nhappened (`Marketplace.cs`, `PlaceBid`). See\n[Auction bid-step formula](#auction-bid-step-formula) for the minimum-bid math.\n\n### Buy orders\n\n```ts\ninterface MarketplaceBuyOrderSettings {\n Enabled?: boolean; // default true\n MaxActiveOrders?: number; // default 5; per-player cap; 0 = unlimited\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n CreateLimits?: LimitSpec;\n FillLimits?: LimitSpec;\n}\n```\n\n### Direct trades\n\n```ts\ninterface MarketplaceDirectTradeSettings {\n Enabled?: boolean; // default true\n OfferExpirationHours?: number; // default 168\n MaxPendingOutgoing?: number; // default 10; caps my own not-yet-responded-to outgoing trades; 0 = unlimited\n AllowGifts?: boolean; // default true; false = requestedBundle becomes mandatory\n CreateLimits?: LimitSpec;\n}\n```\n\n---\n\n## Offer view: MarketplaceOfferView\n\nWhat browsing, my-state, and single-offer reads return — the public\nprojection of an offer, regardless of type:\n\n```ts\ninterface MarketplaceOfferView {\n OfferID?: string;\n OfferType?: MarketOfferType; // \"Listing\" | \"Auction\" | \"BuyOrder\" | \"DirectTrade\"\n Status?: MarketOfferStatus; // \"Active\" | \"Completed\" | \"Cancelled\" | \"Declined\" | \"Expired\"\n CreatorUserID?: string;\n CreatorPublicData?: UserPublicDataModel; // display name/avatar for storefront rendering\n TargetUserID?: string; // set only for DirectTrade\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n GoodsInstances?: UnstackableItemInstanceState[]; // populated when the goods are specific unstackable instances\n Price?: ResourceBundle; // Listing/BuyOrder asking price, or DirectTrade's requestedBundle\n Auction?: MarketplaceAuctionState; // populated only for OfferType === \"Auction\"\n CreatedAt?: string; // ISO timestamp\n ExpiresAt?: string;\n}\n```\n\n`Status: \"Expired\"` is set **lazily** on the read path itself\n(`MarketplaceHelpers.BuildOfferView`): an `Active` document whose `ExpiresAt`\nhas passed is projected as `\"Expired\"` in the view without writing anything to\nthe database — the authoritative DB flip only happens when the offer is\nactually claimed/finalized. Don't assume an offer past its `ExpiresAt` has\nalready flipped in storage just because a read showed `\"Expired\"`.\n\n`GoodsInstances` is `null` for `BuyOrder` offers even if the eventual goods\nwill be unstackable (a buy order names an `ItemID`/`CatalogID`, not specific\ninstances — the filler supplies fresh ones). For the other three offer types\nit's populated when the goods are unstackable, using the same\n`UnstackableItemInstanceState` shape as `InventoryV2.UnstackableItems` (see the\nitem-system skill) — `ItemInstanceID`, `Level`, `ExpiresAt`, `EquippedSlot`\n(always `null` in escrow), etc.\n\n---\n\n## Auction state\n\n```ts\ninterface MarketplaceAuctionState {\n BidAxisType?: ResourceEntryType; // \"VirtualCurrency\" | \"Item\" — see currency-system skill\n BidCurrencyID?: string; // set when BidAxisType is a currency\n BidCatalogID?: string; // set when BidAxisType is an item\n BidItemID?: string;\n StartingBid?: number;\n CurrentBid?: number; // 0 = no bids yet\n CurrentBidderID?: string;\n BidCount?: number;\n ExtensionCount?: number; // anti-snipe extensions used so far\n PendingRefunds?: MarketplaceBidRefund[]; // outbid players awaiting refund\n}\n\ninterface MarketplaceBidRefund {\n BidIndex?: number;\n UserID?: string;\n Amount?: number;\n Settled?: boolean; // false = still owed; drained by claimBack, getMyState, or a later bid/claim on the same offer\n SettledAt?: string;\n}\n```\n\nBidding uses a **single comparable axis** — exactly one VC or one stackable\nitem type — so bids can be strictly ordered. `PendingRefunds` is the outbox of\nplayers who were outbid: `Settled: false` entries are what `claimBack()` (no\n`offerID`) drains across all of a player's auctions in one call. The backend\nalso opportunistically drains refunds on `getMyState()` (for the calling\nplayer's own refunds) and after any `placeBid`/`claimAuction` on the same\noffer (for all pending refunds on it) — so a stale refund is retried on the\nvery next interaction with that auction, not just via an explicit\n`claimBack`.\n\n## Auction bid-step formula\n\nTranscribed from `MarketplaceV2.PlaceBid` (`Marketplace.cs`):\n\n- **First bid** on an auction (`CurrentBid <= 0`) must be\n `>= max(1, StartingBid)`.\n- **Every subsequent bid** must be at least\n `CurrentBid + step`, where\n `step = max(stepFromPercent, max(1, MinBidStepAbsolute))` and\n `stepFromPercent = ceil(CurrentBid * MinBidStepPercent)` when\n `MinBidStepPercent > 0`, else `1`.\n\nA rejected bid's error message already contains the computed minimum —\n`\"Bid must be at least {minAcceptable}.\"` — so a client doesn't need to\nreimplement this to show a useful error, only to build a live \"next valid bid\"\npreview in the UI.\n\nA bid within `AntiSnipeWindowSeconds` of the current `ExpiresAt` (and under\n`MaxAntiSnipeExtensions`) also pushes `ExpiresAt` out by\n`AntiSnipeExtensionSeconds` — reflected in `MarketplacePlaceBidResponse.ExpiresAt`\nfor that call, so re-render your countdown from the response rather than a\nvalue computed before the bid.\n\n---\n\n## User state: UserMarketplaceState (rate limits)\n\nReturned inside `getMyState()`'s `Limits` field and mirrored to\n`client.data.user.state?.Marketplace`:\n\n```ts\ninterface UserMarketplaceState {\n Create?: MarketplaceActionCounter; // listings + auctions + buy orders + direct trades created\n Buy?: MarketplaceActionCounter; // Buy, and the buyer/creator side of FillBuyOrder\n Sell?: MarketplaceActionCounter; // the seller/filler side of Buy and FillBuyOrder\n Bid?: MarketplaceActionCounter;\n}\n\ninterface MarketplaceActionCounter {\n LastAt?: string; // ISO timestamp of the last action\n DailyCount?: number;\n DailyResetUtc?: string; // when DailyCount next resets (next UTC midnight after LastAt)\n}\n```\n\nThese mirror whichever `CreateLimits`/`BuyLimits`/`FillLimits`/`BidLimits`\n(`LimitSpec` — `CooldownSeconds` + `DailyCap`) the relevant per-type settings\nconfigured — use them to grey out a \"Create listing\" button once `DailyCount`\nhits the config's `DailyCap`. `Sell` is incremented for whichever party ends\nup supplying goods (the listing seller on `Buy`, the filler on\n`FillBuyOrder`) even though the action that triggered it was initiated by the\ncounterparty — read the two counters as \"goods flowed out\" (`Sell`) vs\n\"goods flowed in\" (`Buy`), not \"I clicked buy\" vs \"I clicked sell\".\n\n---\n\n## Responses\n\n### MarketplaceGroupedOffersResponse / MarketplaceBrowseResponse\n\n```ts\ninterface MarketplaceGroupedOfferView {\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n OfferCount?: number;\n}\ninterface MarketplaceGroupedOffersResponse {\n Groups?: MarketplaceGroupedOfferView[];\n}\n\ninterface MarketplaceBrowseResponse {\n Offers?: MarketplaceOfferView[];\n ContinuationToken?: string; // pass back in to page further\n}\n```\n\n`getGroupedOffers()` is a storefront summary (one row per item + how many\n`Listing`/`Auction` offers exist) — it carries **no price field at all** (not\neven a min/max), confirmed against\n`MarketplaceDBService.GetGroupedActiveOffersAsync`'s aggregation, which\nprojects only the count. Drill into a specific item with `getOffersByItem` to\nget the actual `MarketplaceOfferView[]` (with `Price`/`Auction`) to buy/bid\nfrom.\n\n### MarketplaceCreateOfferResponse\n\n```ts\ninterface MarketplaceCreateOfferResponse {\n OfferID?: string;\n Offer?: MarketplaceOfferView;\n Resources?: ResourceOperation; // the creator's own escrow charge (goods/payment + listing fee)\n}\n```\n\n### MarketplaceSettlementResponse\n\nReturned by cancel/buy/claimAuction/fillBuyOrder/respondDirectTrade/claimBack.\n**Exactly one** of `Settlement` or `Resources` is populated, depending on the\naction:\n\n```ts\ninterface MarketplaceSettlementResponse {\n OfferID?: string;\n Status?: MarketOfferStatus;\n Settlement?: ResourceDualPartyResult; // dual-party: buy, fillBuyOrder, respondDirectTrade accept-with-price\n Resources?: ResourceOperation; // single-party: cancel*, claimBack, claimAuction, respondDirectTrade accept-gift/decline\n CommissionTaken?: ResourceBundle; // populated when the Commission policy took a cut\n}\n```\n\n`CommissionTaken` is populated on `buy`, `fillBuyOrder`,\n`respondDirectTrade` (commission-applicable swap), and the **seller's**\n`claimAuction` call — it is `null` on the **winner's** `claimAuction` call\n(the winner's `Resources` is a pure goods grant with nothing to report a\ncommission on), confirmed in `MarketplaceV2.ClaimAuction`:\n`CommissionTaken = isSeller ? fee : null`.\n\nSee [Settlement & escrow model](#settlement--escrow-model) below for exactly\nwhich branch each caller sees, including the direct-trade-decline special\ncase.\n\n### MarketplacePlaceBidResponse\n\n```ts\ninterface MarketplacePlaceBidResponse {\n OfferID?: string;\n CurrentBid?: number;\n BidCount?: number;\n ExpiresAt?: string; // may have moved out due to anti-snipe\n Resources?: ResourceOperation; // the caller's own bid-escrow charge\n}\n```\n\n### MarketplaceMyStateResponse\n\n```ts\ninterface MarketplaceMyStateResponse {\n MyOffers?: MarketplaceOfferView[]; // my active listings/auctions (Status === \"Active\" only)\n MyLeadingBids?: MarketplaceOfferView[]; // active auctions where I'm currently winning\n MyBuyOrders?: MarketplaceOfferView[];\n IncomingTrades?: MarketplaceOfferView[]; // direct trades targeting me, awaiting my response\n OutgoingTrades?: MarketplaceOfferView[]; // direct trades I sent, awaiting their response\n Claimables?: MarketplaceOfferView[]; // ended auctions / expired offers with escrow to claim\n DrainedRefunds?: MarketplaceBidRefund[]; // refunds settled by THIS call, if any were pending\n Limits?: UserMarketplaceState;\n}\n```\n\n`Claimables` is a de-duplicated union (by `OfferID`) of five server-side\nchecks (`MarketplaceV2.GetMyState`): my expired listings/auctions with no\nbidder and unreturned escrow, my ended auctions with a bidder whose proceeds\naren't claimed yet, my expired buy orders with unreturned escrow, my expired\noutgoing direct trades with unreturned escrow, and auctions I'm leading whose\n`ExpiresAt` has passed but I haven't claimed goods on yet. It's the list to\ndrive a \"you have items/currency to collect\" badge — the direct signal for\nwhen to prompt `claimAuction`/`claimBack`. `DrainedRefunds` reflects only\nrefunds this specific call happened to settle (a side effect of `getMyState`\nopportunistically draining the caller's own outbox) — it is not a running\nhistory of all past refunds.\n\n### MarketplaceHistoryResponse\n\n```ts\ninterface MarketplaceHistoryEntryView {\n OfferID?: string;\n OfferType?: MarketOfferType;\n FinalStatus?: MarketOfferStatus;\n SellerUserID?: string;\n BuyerUserID?: string;\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n PricePaid?: ResourceBundle;\n CommissionTaken?: ResourceBundle;\n CompletedAt?: string;\n}\ninterface MarketplaceHistoryResponse {\n Entries?: MarketplaceHistoryEntryView[];\n ContinuationToken?: string;\n}\n```\n\nA read-only settled-trade ledger, one entry per completed/cancelled/declined/\nexpired offer that reached a terminal write — this is the source for a \"trade\nhistory\" screen, distinct from `MyOffers` (which is current/active state).\nEntries come from an append-only log with a deterministic `LogID`\n(`\"log_{status}_{offerID}\"`), so a transaction retry can never duplicate a\nhistory row.\n\n---\n\n## Request shape\n\nEvery method builds a shared `MarketplaceRequest` internally (extends\n`BaseRequest`); you don't construct this yourself, but it's useful to know\nwhich params round-trip together on the wire:\n\n```ts\ninterface MarketplaceRequest extends BaseRequest {\n OfferID?: string;\n ItemID?: string;\n CatalogID?: string;\n GoodsAmount?: number;\n ItemInstanceIDs?: string[];\n PriceBundle?: ResourceBundle;\n RequestedBundle?: ResourceBundle;\n DurationHours?: number;\n BidAxisType?: ResourceEntryType;\n BidCurrencyID?: string;\n BidCatalogID?: string;\n BidItemID?: string;\n StartingBid?: number;\n BidAmount?: number;\n TargetUserID?: string;\n Accept?: boolean;\n PageSize?: number;\n ContinuationToken?: string;\n OfferTypeFilter?: MarketOfferType;\n}\n```\n\nOne field name is reused across two different roles depending on the action:\n`ItemInstanceIDs` on a create call means \"escrow these specific instances as\nthe goods\"; it's absent on read/bid/response calls. `PageSize` is clamped\nserver-side to `1..100` regardless of what the client sends\n(`MarketplaceDBService.FindHistoryAsync` and friends).\n\n`MarketplaceAction` enumerates the 20 backend action names this request shape\ndispatches on (`GetDefinitions`, `GetGroupedOffers`, `GetOffersByItem`,\n`GetOffer`, `GetBuyOrders`, `GetMyState`, `GetHistory`, `CreateListing`,\n`CancelListing`, `Buy`, `CreateAuction`, `PlaceBid`, `ClaimAuction`,\n`CreateBuyOrder`, `CancelBuyOrder`, `FillBuyOrder`, `CreateDirectTrade`,\n`RespondDirectTrade`, `CancelDirectTrade`, `ClaimBack`) — one more than the 19\npublic `MarketplaceService` methods because `getOffersByItem`/`getBuyOrders`\nboth exist as distinct client methods but the action list also separately\nnames every read; you won't call this directly, it's internal routing.\n\n---\n\n## Settlement & escrow model\n\nEvery create call escrows something immediately, in the same transaction as\nthe offer's insert:\n\n| Create call | What gets escrowed |\n| ------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `createListing` | The goods (`itemInstanceIDs` or `goodsAmount` of `itemID`), plus the selected `ListingFeeOptions` option if configured. |\n| `createAuction` | The goods, same as a listing (no separate fee field on auctions). |\n| `createBuyOrder` | The `priceBundle` (payment), not goods — inverted from a listing. |\n| `createDirectTrade` | The offered side (instances and/or bundle) — nothing from the target until they accept. |\n| `placeBid` | The bid amount — refunded to the previous leader via `PendingRefunds` when outbid. |\n\nSettlement then plays out one of three ways:\n\n1. **Instant dual-party** — `buy`, `fillBuyOrder`, and\n `respondDirectTrade(offerID, true)` when a `requestedBundle` was set: the\n backend computes both sides in one atomic step\n (`ResourceService.ApplyDualPartyAtomicAsync`) and returns\n `ResourceDualPartyResult` (`FromUserID`/`ToUserID` +\n `FromResult`/`ToResult`, each a `ResourceOperation`). The SDK applies\n **only the calling player's side** — it checks which of `FromUserID`/\n `ToUserID` matches the current session and applies that result, ignoring\n the other party's (there is no way to see the counterparty's resulting\n balances from this call).\n2. **Instant single-party grant** — `respondDirectTrade(offerID, true)` on a\n pure gift (no `requestedBundle`): only `Resources` is populated, applied\n directly to the accepter.\n3. **Lazy per-side claim** — auctions never auto-settle. After\n `ExpiresAt`, the winning bidder and the seller each call `claimAuction`\n independently; each call returns that caller's own `Resources` (goods to\n the winner — the bid amount was already paid at `placeBid` time; net\n proceeds to the seller). Nothing forces both sides to claim promptly — an\n unclaimed auction just sits in `Claimables`.\n\n**The direct-trade-decline special case**: `respondDirectTrade(offerID,\nfalse)` returns a `Resources` grant, but it is the **escrow being returned to\nthe offer's `CreatorUserID`** (the sender getting their goods back), not a\ngrant to the decliner. Confirmed against the backend source\n(`Marketplace.cs`): decline calls `ReturnEscrowToInitiatorAsync`, which runs\n`ResourceService.ApplyResourceOperationAtomicAsync` with\n`userID: offer.CreatorUserID` — never `service.UserID` (the caller). The SDK\nintentionally does **not** apply this `Resources` to the local cache in that\nbranch — applying it would incorrectly credit the decliner's own balances with\nresources that were actually returned to someone else's account. If you need\nto reflect \"trade declined\" in the decliner's UI, do it by removing the trade\nfrom `IncomingTrades`/showing `Status: \"Declined\"` — not by expecting a\nbalance change.\n\n`cancelListing`/`cancelBuyOrder`/`cancelDirectTrade` and `claimBack` are all\nsingle-party grants (`Resources` only) refunding the **caller's own** escrow —\nstraightforward `applyResourceOperation` cases, no dual-party ambiguity. A\ncancel is only legal on an offer the caller created, while it's still\n`Active`; an auction that already has a bidder cannot be cancelled at all\n(bids are already in escrow — the only exit is `claimAuction` after\n`ExpiresAt`).\n\n---\n\n## Commission model\n\nComputed per price position (each `ResourceEntry` and each `EventTokenOperation`\nin the price bundle independently) at settlement, transcribed verbatim from\n`MarketplaceHelpers.ComputeFee`/`SplitCommission` (`MarketplaceHelpers.cs`):\n\n```\nfee = min(amount, max(ceil(amount * Percent), MinPerPosition))\nnet = amount - fee\n```\n\n- `ceil` rounds the percentage cut up to the next whole unit.\n- `MinPerPosition` is a floor, not an addition — it only raises `fee` when the\n percentage cut would be smaller than the minimum.\n- `fee` is clamped to never exceed `amount` itself (so a price position with a\n huge `MinPerPosition` can zero out the seller's net for that position, but\n never make it negative).\n- A position where the resulting `net` is `0` is dropped entirely from the\n net bundle; likewise a `fee` of `0` is dropped from the fee bundle — so\n `CommissionTaken` only lists positions that actually had something taken.\n\nThe effective `(Percent, MinPerPosition)` pair is resolved once, at **offer\ncreation time**, and snapshotted onto the offer\n(`CommissionPercentSnapshot`/`CommissionMinSnapshot`/`CommissionSinkSnapshot`)\n— `PerCatalogOverrides[GoodsCatalogID]` if present, else the top-level\n`Percent`/`MinPerPosition` (`MarketplaceHelpers.ResolveCommission`). This\nmeans a later change to the title's commission config **never** affects an\nalready-listed offer; only new offers pick up the new rate. For a\n`DirectTrade`, the snapshot is `(0, 0)` unless\n`Commission.ApplyToDirectTrades` was `true` **and** the trade was a\nswap (not a gift) at creation time.\n\n`Sink`: `\"Burn\"` (default) destroys the commission outright — it's simply the\ndifference between what the buyer/payer consumed and what the seller was\ngranted, with no corresponding write anywhere. `\"Ledger\"` additionally credits\n`LedgerAccountID` (default `\"default\"` if empty) in the title's\n`MarketplaceLedgerDocument`, in the same transaction as the settlement — an\naccounting/reporting concept internal to the title's economy, with no\nclient-visible effect beyond `CommissionTaken` on the response. Either way,\nwhat the trading parties themselves receive is identical — the sink only\nchanges whether the fee is destroyed or recorded for the title's own\nbookkeeping.\n\n---\n\n## Server-side limits, locking, and idempotency\n\nVerified against `Marketplace.cs`/`MarketplaceHelpers.cs`/`MarketplaceDefinitions.cs`:\n\n- **Per-endpoint rate limit**: every `MarketplaceV2` action shares a 500ms\n IP-level rate limit (`RateLimitMilliseconds = 500` in `Marketplace.cs`,\n enforced by `ClientRun.Execute`) — a repeat call inside that window surfaces\n to the SDK as `reason: \"throttled\"`.\n- **Per-offer mutation lock**: `buy`, `placeBid`, `claimAuction`,\n `fillBuyOrder`, `respondDirectTrade`, every `cancel*`, and `claimBack(offerID)`\n additionally take a short-lived Redis lock keyed on the specific `OfferID`\n (200ms rate window, 5000ms hold, `Marketplace.cs`'s `AcquireOfferLockAsync`)\n — this is on top of the per-endpoint rate limit and is scoped to that one\n offer, not the whole action. A concurrent second call on the same offer\n (yours or another player racing you for the same listing) fails with either\n `\"Too many requests for this offer, try again.\"` or `\"This offer is being\nprocessed, try again.\"` — both are safe to retry. The Mongo-side\n precondition (`PatchOfferInSessionAsync`'s optimistic-concurrency filter) is\n the actual source of truth against double-sell/double-claim; the Redis lock\n is only a latency optimization, so correctness doesn't depend on the client\n handling this perfectly.\n- **Per-player anti-abuse caps** (checked before escrow, in addition to\n `LimitSpec` cooldown/daily-cap pairs): `MaxActiveListings` (Listing+Auction\n combined), `MaxActiveOrders` (BuyOrder), `MaxPendingOutgoing` (DirectTrade,\n counts only the sender's still-`Active` outgoing offers). Each rejection\n names the current count and the cap, e.g. `\"You have too many open offers\n(10/10).\"`\n- **Dedup / atomicity**: there is no batch/multi-item marketplace action —\n every method operates on exactly one offer per call, so there's no\n partial-success array to interpret (contrast with modules that expose\n `BatchItemResult[]`). Every create is one atomic operation (escrow debit +\n offer insert + limit-counter patch in a single transaction via\n `ResourceService.ApplyResourceOperationAtomicAsync`); every settlement is\n one atomic operation touching both parties via\n `ResourceService.ApplyDualPartyAtomicAsync`. Resources for a create call\n live on `MarketplaceCreateOfferResponse.Resources`; for a settlement, on\n `MarketplaceSettlementResponse.Settlement` (dual-party) or `.Resources`\n (single-party) — there is no separate top-level resources field to check.\n- **Idempotency**: create calls (`createListing`/`createAuction`/\n `createBuyOrder`/`createDirectTrade`) resolve their idempotency key via\n `ResourceService.ResolveRelatedEntityID(args.RelatedEntityID, \"mp_create_{userID}\" | \"mp_order_{userID}\" | \"mp_trade_{userID}\")`\n — passing a stable `relatedEntityID` on repeated client-side retries of the\n _same_ create prevents a duplicate offer/charge; omitting it means every\n call is treated as a new create. Settlement actions key off the offer\n itself (e.g. `\"MpBuy:{offerID}\"`, `\"MpBid:{offerID}:{bidIndex}\"`,\n `\"MpAuctionGoods:{offerID}\"`), so retrying the exact same settlement call is\n naturally idempotent without the client needing to supply anything.\n"
8
+ "content": "# Marketplace data model — reference\n\nFull shape of the config (`MarketplaceDefinitions`), the offer/state views the\nclient actually sees, the request shape, and the escrow/settlement/commission\nmodel in detail. All of these are **strictly typed in the SDK** —\n`MarketplaceDefinitions` and every nested block, `MarketplaceOfferView`,\n`MarketplaceAuctionState`, the various response types — are exported from\n`@idosgames/core`, so `getDefinitions()` /\n`getSection<MarketplaceDefinitions>(\"Marketplace\")` and every service call give\nyou concrete types, not `unknown`. The schemas keep `.passthrough()`, so a\nfield the backend adds later still round-trips. Field names are PascalCase\n(straight from the backend JSON).\n\n## Contents\n\n- [What the client never sees](#what-the-client-never-sees)\n- [Config: MarketplaceDefinitions](#config-marketplacedefinitions)\n- [Per-listing-type settings](#per-listing-type-settings)\n- [Offer view: MarketplaceOfferView](#offer-view-marketplaceofferview)\n- [Auction state](#auction-state)\n- [Auction bid-step formula](#auction-bid-step-formula)\n- [User state: UserMarketplaceState (rate limits)](#user-state-usermarketplacestate-rate-limits)\n- [Responses](#responses)\n- [Request shape](#request-shape)\n- [Settlement & escrow model](#settlement--escrow-model)\n- [Commission model](#commission-model)\n- [Server-side limits, locking, and idempotency](#server-side-limits-locking-and-idempotency)\n\n---\n\n## What the client never sees\n\nThe backend's internal `MarketplaceOfferDocument` (raw escrow bookkeeping,\ncommission snapshot) and the `MarketplaceTradeLogDocument` /\n`MarketplaceLedgerDocument` never reach the client. Everything documented here\nis the **public projection**: `MarketplaceOfferView` (what browsing/my-state/\nhistory return instead of the raw document) and `MarketplaceHistoryEntryView`.\nOne exception: `MarketplaceAuctionState` is shared **verbatim** between the\ninternal document and the public view — the backend assigns the same object to\nboth (`MarketplaceHelpers.BuildOfferView`, `Auction = doc.Auction`) — so what's\nbelow is exactly what's stored.\n\n---\n\n## Config: MarketplaceDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MarketplaceDefinitions>(\"Marketplace\")`.\n\n```ts\ninterface MarketplaceDefinitions {\n Enabled?: boolean; // master switch; default false — must be enabled explicitly\n Gate?: SegmentGate; // segment-based access gate (see other modules' Gate usage)\n Schedule?: ScheduleSpec; // time-window the marketplace is open; null = always open\n PricePolicy?: MarketplacePricePolicy;\n Commission?: MarketplaceCommissionPolicy;\n Tradability?: MarketplaceTradabilityPolicy;\n Listings?: MarketplaceListingSettings;\n Auctions?: MarketplaceAuctionSettings;\n BuyOrders?: MarketplaceBuyOrderSettings;\n DirectTrades?: MarketplaceDirectTradeSettings;\n}\n```\n\n`getDefinitions()`'s response also carries two computed flags alongside\n`Definitions`:\n\n```ts\ninterface MarketplaceGetDefinitionsResponse {\n Definitions?: MarketplaceDefinitions | null;\n IsOpenNow?: boolean | null; // Enabled && Schedule evaluated against server time\n GatePassed?: boolean | null; // Gate evaluated against this player's segment\n}\n```\n\nCheck both before showing \"create offer\" UI — `Enabled` alone doesn't account\nfor the schedule window or the segment gate. **`Gate`/`Schedule` are only\nenforced on trading actions** (create/buy/bid/fill/accept) — `claimBack`,\ncancels, and a decline are never blocked by either, confirmed in\n`MarketplaceV2.CheckTradeGate`'s call sites (`Marketplace.cs`): it's invoked\nfrom `CreateSellOffer`, `Buy`, `PlaceBid`, `CreateBuyOrder`, `FillBuyOrder`,\n`CreateDirectTrade`, and the accept branch of `RespondDirectTrade` — never\nfrom `CancelOffer`, `ClaimBack`, or the decline branch.\n\n`SegmentGate`, `ScheduleSpec`, and `LimitSpec` (used throughout the per-type\nsettings below) are generic cross-module shapes shared with other live-ops\nsystems (segment/schedule gating, anti-farm counters) — this reference only\nlists the fields Marketplace itself reads.\n\n---\n\n## Per-listing-type settings\n\n### PricePolicy — what a price/bid can be made of\n\n```ts\ninterface MarketplacePricePolicy {\n AllowVirtualCurrency?: boolean; // default true; ignored when Allowed is non-empty\n AllowItems?: boolean; // default false; ignored when Allowed is non-empty\n AllowEventTokens?: boolean; // default false; ignored when Allowed is non-empty\n AllowCryptoCurrency?: boolean; // default false; the title's crypto as a price (whole tokens); ignored when Allowed is non-empty\n Allowed?: EntryResourceRule[]; // authoritative whitelist when non-empty (kind + id + min/max amount)\n MaxPositions?: number; // default 5; cap on distinct resource entries + event tokens in one price; 0 = unlimited\n}\n```\n\nMirrors the entry-policy pattern from the Match module (`MatchEntrySettings`) —\nsame `EntryResourceRule` shape (`Kind`, `CurrencyID`/`CatalogID`/`ItemID`,\n`TokenType`/`EntityID`, `MinAmount`/`MaxAmount`). A `createListing`/\n`createAuction`/`createBuyOrder`/`createDirectTrade`'s price (or an auction's\nstarting-bid axis) is validated against this policy on create\n(`MarketplaceHelpers.ValidatePricePolicy`, `Marketplace.cs`):\n\nTwo rules apply **unconditionally**, regardless of whitelist/flags\n(`MarketplaceHelpers.cs`):\n\n- A virtual-currency price position is rejected unless that currency's own\n `VirtualCurrencyDefinition.Permissions.IsTradable === true` — error:\n `\"Currency '{id}' is not tradable between players (Permissions.IsTradable=false).\"`\n- An **unstackable item can never be used as a price position** (only as\n goods) — error: `\"Unstackable items cannot be used as a price.\"` — because\n granting it back would have to re-mint a new instance identity.\n\nWhen `Allowed` is non-empty it is authoritative — every price position must\nmatch one of its rules (including `MinAmount`/`MaxAmount`); the\n`AllowVirtualCurrency`/`AllowItems`/`AllowEventTokens`/`AllowCryptoCurrency` flags\nare only consulted when `Allowed` is empty.\n\n### Crypto prices (money between players)\n\nWith `AllowCryptoCurrency` (or a `Kind: \"CryptoCurrency\"` whitelist rule) a price,\na buy-order payment, an auction bid axis (`BidAxisType: \"CryptoCurrency\"`) and both\nsides of a direct trade may be the title's crypto — **whole tokens only**\n(`Amount`; a USD price `AmountUsd` is refused with `CRYPTO_USD_PRICE_NOT_ALLOWED`).\nThe receiver (seller, buy-order filler, gift/trade target) gets the amount **minus the\ndeveloper's trade fee** of the currency (`CryptoCurrencyDefinition.PlayerTradeFeePercent`,\n0–50%, rounded up, snapshotted into the offer at creation); the fee goes to the\ndeveloper's share of the title pool. The marketplace `Commission` (burn/ledger)\napplies only to the non-crypto positions — one position never pays both. Escrows,\ncancels, expiries and outbid refunds return crypto without a fee. Auto-matching and\nmarket orders stay virtual-currency only. A crypto listing fee is a plain spend for\nthe developer and is refused when `RefundListingFeeOnCancel` is on\n(`CRYPTO_LISTING_FEE_REFUNDABLE`). In the UI kit a crypto price line has\n`kind: \"crypto\"`; pass `InventoryV2.CryptoCurrencies` as the third argument of\n`canAfford`.\n\n### Commission — the cut taken at settlement\n\n```ts\ninterface MarketplaceCommissionPolicy {\n Percent?: number; // default 0.05 (5%)\n MinPerPosition?: number; // floor per priced resource entry; default 0 (no minimum)\n Sink?: \"Burn\" | \"Ledger\"; // default \"Burn\"\n LedgerAccountID?: string; // target account when Sink === \"Ledger\"; empty falls back to \"default\"\n PerCatalogOverrides?: Record<string, MarketplaceCommissionOverride>;\n ApplyToDirectTrades?: boolean; // default false — gifts/trades are commission-exempt by default\n}\n\ninterface MarketplaceCommissionOverride {\n Percent?: number;\n MinPerPosition?: number;\n}\n```\n\n`PerCatalogOverrides` is keyed by the **goods'** `CatalogID` (not the price's) —\na catalog present here replaces the top-level `Percent`/`MinPerPosition` for\noffers whose goods come from that catalog\n(`MarketplaceHelpers.ResolveCommission`). See\n[Commission model](#commission-model) below for the exact per-position fee\nformula.\n\n### Tradability — what's allowed on the market at all\n\n```ts\ninterface MarketplaceTradabilityPolicy {\n AllowedCatalogIDs?: string[]; // null/absent = no catalog allow-list (all allowed unless denied)\n DeniedCatalogIDs?: string[];\n DeniedItemIDs?: string[];\n AllowUnstackableWithState?: boolean; // default true; allow listing instances with state (Level>1, RemainingUses>1, CustomData)\n}\n```\n\nBase requirement, checked first regardless of policy:\n`ItemDefinition.IsTradable` must be `true`\n(`MarketplaceHelpers.ResolveTradableItem`) — error:\n`\"Item '{itemID}' is not tradable (ItemDefinition.IsTradable=false).\"` The item\nis resolved with the same strict→fallback catalog resolver used elsewhere\n(self-heals `CatalogID` if the item moved catalogs), so the response's\n`GoodsCatalogID` can differ from what you originally passed as `catalogID`.\n\nWhen `AllowUnstackableWithState` is `false`, only \"pristine\" instances\n(`Level <= 1`, `RemainingUses <= 1`, no `CustomData`) may be escrowed as goods\n— error: `\"Instance '{id}' has state (level/custom data) — selling stateful\ninstances is disabled.\"` Equipped or expired instances are always rejected\nregardless of this flag (`\"Instance '{id}' is equipped — unequip it\nfirst.\"` / `\"Instance '{id}' has expired.\"`).\n\n### Listings\n\n```ts\ninterface MarketplaceListingSettings {\n Enabled?: boolean; // default true (when the module itself is enabled)\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n MaxActiveListings?: number; // default 10; per-player cap counting Listing+Auction together; 0 = unlimited\n CreateLimits?: LimitSpec;\n BuyLimits?: LimitSpec;\n ListingFeeOptions?: Record<string, PriceOption>; // ways to pay the fee charged in addition to escrowing the goods; only the Standard part is used, and the fee is never paid in a store (P2P + refundable)\n RefundListingFeeOnCancel?: boolean; // default false — fee is forfeit on cancel; never refunded on expiry either way\n}\n```\n\n`MaxActiveListings` is checked against a single count query across **both**\n`Listing` and `Auction` offer types created by the player\n(`MarketplaceDBService.CountActiveByCreatorAsync(..., Listing, Auction)`) — a\ntitle with `MaxActiveListings: 10` caps the player at 10 combined open\nlistings+auctions, not 10 of each.\n\n### Auctions\n\n```ts\ninterface MarketplaceAuctionSettings {\n Enabled?: boolean; // default true\n MinDurationHours?: number; // default 1\n MaxDurationHours?: number; // default 168\n AntiSnipeWindowSeconds?: number; // default 120; 0 = disabled\n AntiSnipeExtensionSeconds?: number; // default 120\n MaxAntiSnipeExtensions?: number; // default 10\n MinBidStepPercent?: number; // default 0.05 (5%)\n MinBidStepAbsolute?: number; // default 1\n BidLimits?: LimitSpec;\n}\n```\n\nAnti-snipe: a bid placed within `AntiSnipeWindowSeconds` of the auction's\ncurrent `ExpiresAt` pushes the deadline out by `AntiSnipeExtensionSeconds`,\nup to `MaxAntiSnipeExtensions` times total per auction —\n`MarketplaceAuctionState.ExtensionCount` tracks how many have already\nhappened (`Marketplace.cs`, `PlaceBid`). See\n[Auction bid-step formula](#auction-bid-step-formula) for the minimum-bid math.\n\n### Buy orders\n\n```ts\ninterface MarketplaceBuyOrderSettings {\n Enabled?: boolean; // default true\n MaxActiveOrders?: number; // default 5; per-player cap; 0 = unlimited\n AllowedDurationsHours?: number[]; // empty/null = default {24, 72, 168}\n CreateLimits?: LimitSpec;\n FillLimits?: LimitSpec;\n}\n```\n\n### Direct trades\n\n```ts\ninterface MarketplaceDirectTradeSettings {\n Enabled?: boolean; // default true\n OfferExpirationHours?: number; // default 168\n MaxPendingOutgoing?: number; // default 10; caps my own not-yet-responded-to outgoing trades; 0 = unlimited\n AllowGifts?: boolean; // default true; false = requestedBundle becomes mandatory\n CreateLimits?: LimitSpec;\n}\n```\n\n---\n\n## Offer view: MarketplaceOfferView\n\nWhat browsing, my-state, and single-offer reads return — the public\nprojection of an offer, regardless of type:\n\n```ts\ninterface MarketplaceOfferView {\n OfferID?: string;\n OfferType?: MarketOfferType; // \"Listing\" | \"Auction\" | \"BuyOrder\" | \"DirectTrade\"\n Status?: MarketOfferStatus; // \"Active\" | \"Completed\" | \"Cancelled\" | \"Declined\" | \"Expired\"\n CreatorUserID?: string;\n CreatorPublicData?: UserPublicDataModel; // display name/avatar for storefront rendering\n TargetUserID?: string; // set only for DirectTrade\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n GoodsInstances?: UnstackableItemInstanceState[]; // populated when the goods are specific unstackable instances\n Price?: ResourceBundle; // Listing/BuyOrder asking price, or DirectTrade's requestedBundle\n Auction?: MarketplaceAuctionState; // populated only for OfferType === \"Auction\"\n CreatedAt?: string; // ISO timestamp\n ExpiresAt?: string;\n}\n```\n\n`Status: \"Expired\"` is set **lazily** on the read path itself\n(`MarketplaceHelpers.BuildOfferView`): an `Active` document whose `ExpiresAt`\nhas passed is projected as `\"Expired\"` in the view without writing anything to\nthe database — the authoritative DB flip only happens when the offer is\nactually claimed/finalized. Don't assume an offer past its `ExpiresAt` has\nalready flipped in storage just because a read showed `\"Expired\"`.\n\n`GoodsInstances` is `null` for `BuyOrder` offers even if the eventual goods\nwill be unstackable (a buy order names an `ItemID`/`CatalogID`, not specific\ninstances — the filler supplies fresh ones). For the other three offer types\nit's populated when the goods are unstackable, using the same\n`UnstackableItemInstanceState` shape as `InventoryV2.UnstackableItems` (see the\nitem-system skill) — `ItemInstanceID`, `Level`, `ExpiresAt`, `EquippedSlot`\n(always `null` in escrow), etc.\n\n---\n\n## Auction state\n\n```ts\ninterface MarketplaceAuctionState {\n BidAxisType?: ResourceEntryType; // \"VirtualCurrency\" | \"Item\" — see currency-system skill\n BidCurrencyID?: string; // set when BidAxisType is a currency\n BidCatalogID?: string; // set when BidAxisType is an item\n BidItemID?: string;\n StartingBid?: number;\n CurrentBid?: number; // 0 = no bids yet\n CurrentBidderID?: string;\n BidCount?: number;\n ExtensionCount?: number; // anti-snipe extensions used so far\n PendingRefunds?: MarketplaceBidRefund[]; // outbid players awaiting refund\n}\n\ninterface MarketplaceBidRefund {\n BidIndex?: number;\n UserID?: string;\n Amount?: number;\n Settled?: boolean; // false = still owed; drained by claimBack, getMyState, or a later bid/claim on the same offer\n SettledAt?: string;\n}\n```\n\nBidding uses a **single comparable axis** — exactly one VC or one stackable\nitem type — so bids can be strictly ordered. `PendingRefunds` is the outbox of\nplayers who were outbid: `Settled: false` entries are what `claimBack()` (no\n`offerID`) drains across all of a player's auctions in one call. The backend\nalso opportunistically drains refunds on `getMyState()` (for the calling\nplayer's own refunds) and after any `placeBid`/`claimAuction` on the same\noffer (for all pending refunds on it) — so a stale refund is retried on the\nvery next interaction with that auction, not just via an explicit\n`claimBack`.\n\n## Auction bid-step formula\n\nTranscribed from `MarketplaceV2.PlaceBid` (`Marketplace.cs`):\n\n- **First bid** on an auction (`CurrentBid <= 0`) must be\n `>= max(1, StartingBid)`.\n- **Every subsequent bid** must be at least\n `CurrentBid + step`, where\n `step = max(stepFromPercent, max(1, MinBidStepAbsolute))` and\n `stepFromPercent = ceil(CurrentBid * MinBidStepPercent)` when\n `MinBidStepPercent > 0`, else `1`.\n\nA rejected bid's error message already contains the computed minimum —\n`\"Bid must be at least {minAcceptable}.\"` — so a client doesn't need to\nreimplement this to show a useful error, only to build a live \"next valid bid\"\npreview in the UI.\n\nA bid within `AntiSnipeWindowSeconds` of the current `ExpiresAt` (and under\n`MaxAntiSnipeExtensions`) also pushes `ExpiresAt` out by\n`AntiSnipeExtensionSeconds` — reflected in `MarketplacePlaceBidResponse.ExpiresAt`\nfor that call, so re-render your countdown from the response rather than a\nvalue computed before the bid.\n\n---\n\n## User state: UserMarketplaceState (rate limits)\n\nReturned inside `getMyState()`'s `Limits` field and mirrored to\n`client.data.user.state?.Marketplace`:\n\n```ts\ninterface UserMarketplaceState {\n Create?: MarketplaceActionCounter; // listings + auctions + buy orders + direct trades created\n Buy?: MarketplaceActionCounter; // Buy, and the buyer/creator side of FillBuyOrder\n Sell?: MarketplaceActionCounter; // the seller/filler side of Buy and FillBuyOrder\n Bid?: MarketplaceActionCounter;\n}\n\ninterface MarketplaceActionCounter {\n LastAt?: string; // ISO timestamp of the last action\n DailyCount?: number;\n DailyResetUtc?: string; // when DailyCount next resets (next UTC midnight after LastAt)\n}\n```\n\nThese mirror whichever `CreateLimits`/`BuyLimits`/`FillLimits`/`BidLimits`\n(`LimitSpec` — `CooldownSeconds` + `DailyCap`) the relevant per-type settings\nconfigured — use them to grey out a \"Create listing\" button once `DailyCount`\nhits the config's `DailyCap`. `Sell` is incremented for whichever party ends\nup supplying goods (the listing seller on `Buy`, the filler on\n`FillBuyOrder`) even though the action that triggered it was initiated by the\ncounterparty — read the two counters as \"goods flowed out\" (`Sell`) vs\n\"goods flowed in\" (`Buy`), not \"I clicked buy\" vs \"I clicked sell\".\n\n---\n\n## Responses\n\n### MarketplaceGroupedOffersResponse / MarketplaceBrowseResponse\n\n```ts\ninterface MarketplaceGroupedOfferView {\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n OfferCount?: number;\n}\ninterface MarketplaceGroupedOffersResponse {\n Groups?: MarketplaceGroupedOfferView[];\n}\n\ninterface MarketplaceBrowseResponse {\n Offers?: MarketplaceOfferView[];\n ContinuationToken?: string; // pass back in to page further\n}\n```\n\n`getGroupedOffers()` is a storefront summary (one row per item + how many\n`Listing`/`Auction` offers exist) — it carries **no price field at all** (not\neven a min/max), confirmed against\n`MarketplaceDBService.GetGroupedActiveOffersAsync`'s aggregation, which\nprojects only the count. Drill into a specific item with `getOffersByItem` to\nget the actual `MarketplaceOfferView[]` (with `Price`/`Auction`) to buy/bid\nfrom.\n\n### MarketplaceCreateOfferResponse\n\n```ts\ninterface MarketplaceCreateOfferResponse {\n OfferID?: string;\n Offer?: MarketplaceOfferView;\n Resources?: ResourceOperation; // the creator's own escrow charge (goods/payment + listing fee)\n}\n```\n\n### MarketplaceSettlementResponse\n\nReturned by cancel/buy/claimAuction/fillBuyOrder/respondDirectTrade/claimBack.\n**Exactly one** of `Settlement` or `Resources` is populated, depending on the\naction:\n\n```ts\ninterface MarketplaceSettlementResponse {\n OfferID?: string;\n Status?: MarketOfferStatus;\n Settlement?: ResourceDualPartyResult; // dual-party: buy, fillBuyOrder, respondDirectTrade accept-with-price\n Resources?: ResourceOperation; // single-party: cancel*, claimBack, claimAuction, respondDirectTrade accept-gift/decline\n CommissionTaken?: ResourceBundle; // populated when the Commission policy took a cut\n}\n```\n\n`CommissionTaken` is populated on `buy`, `fillBuyOrder`,\n`respondDirectTrade` (commission-applicable swap), and the **seller's**\n`claimAuction` call — it is `null` on the **winner's** `claimAuction` call\n(the winner's `Resources` is a pure goods grant with nothing to report a\ncommission on), confirmed in `MarketplaceV2.ClaimAuction`:\n`CommissionTaken = isSeller ? fee : null`.\n\nSee [Settlement & escrow model](#settlement--escrow-model) below for exactly\nwhich branch each caller sees, including the direct-trade-decline special\ncase.\n\n### MarketplacePlaceBidResponse\n\n```ts\ninterface MarketplacePlaceBidResponse {\n OfferID?: string;\n CurrentBid?: number;\n BidCount?: number;\n ExpiresAt?: string; // may have moved out due to anti-snipe\n Resources?: ResourceOperation; // the caller's own bid-escrow charge\n}\n```\n\n### MarketplaceMyStateResponse\n\n```ts\ninterface MarketplaceMyStateResponse {\n MyOffers?: MarketplaceOfferView[]; // my active listings/auctions (Status === \"Active\" only)\n MyLeadingBids?: MarketplaceOfferView[]; // active auctions where I'm currently winning\n MyBuyOrders?: MarketplaceOfferView[];\n IncomingTrades?: MarketplaceOfferView[]; // direct trades targeting me, awaiting my response\n OutgoingTrades?: MarketplaceOfferView[]; // direct trades I sent, awaiting their response\n Claimables?: MarketplaceOfferView[]; // ended auctions / expired offers with escrow to claim\n DrainedRefunds?: MarketplaceBidRefund[]; // refunds settled by THIS call, if any were pending\n Limits?: UserMarketplaceState;\n}\n```\n\n`Claimables` is a de-duplicated union (by `OfferID`) of five server-side\nchecks (`MarketplaceV2.GetMyState`): my expired listings/auctions with no\nbidder and unreturned escrow, my ended auctions with a bidder whose proceeds\naren't claimed yet, my expired buy orders with unreturned escrow, my expired\noutgoing direct trades with unreturned escrow, and auctions I'm leading whose\n`ExpiresAt` has passed but I haven't claimed goods on yet. It's the list to\ndrive a \"you have items/currency to collect\" badge — the direct signal for\nwhen to prompt `claimAuction`/`claimBack`. `DrainedRefunds` reflects only\nrefunds this specific call happened to settle (a side effect of `getMyState`\nopportunistically draining the caller's own outbox) — it is not a running\nhistory of all past refunds.\n\n### MarketplaceHistoryResponse\n\n```ts\ninterface MarketplaceHistoryEntryView {\n OfferID?: string;\n OfferType?: MarketOfferType;\n FinalStatus?: MarketOfferStatus;\n SellerUserID?: string;\n BuyerUserID?: string;\n GoodsCatalogID?: string;\n GoodsItemID?: string;\n GoodsAmount?: number;\n PricePaid?: ResourceBundle;\n CommissionTaken?: ResourceBundle;\n CompletedAt?: string;\n}\ninterface MarketplaceHistoryResponse {\n Entries?: MarketplaceHistoryEntryView[];\n ContinuationToken?: string;\n}\n```\n\nA read-only settled-trade ledger, one entry per completed/cancelled/declined/\nexpired offer that reached a terminal write — this is the source for a \"trade\nhistory\" screen, distinct from `MyOffers` (which is current/active state).\nEntries come from an append-only log with a deterministic `LogID`\n(`\"log_{status}_{offerID}\"`), so a transaction retry can never duplicate a\nhistory row.\n\n---\n\n## Request shape\n\nEvery method builds a shared `MarketplaceRequest` internally (extends\n`BaseRequest`); you don't construct this yourself, but it's useful to know\nwhich params round-trip together on the wire:\n\n```ts\ninterface MarketplaceRequest extends BaseRequest {\n OfferID?: string;\n ItemID?: string;\n CatalogID?: string;\n GoodsAmount?: number;\n ItemInstanceIDs?: string[];\n PriceBundle?: ResourceBundle;\n RequestedBundle?: ResourceBundle;\n DurationHours?: number;\n BidAxisType?: ResourceEntryType;\n BidCurrencyID?: string;\n BidCatalogID?: string;\n BidItemID?: string;\n StartingBid?: number;\n BidAmount?: number;\n TargetUserID?: string;\n Accept?: boolean;\n PageSize?: number;\n ContinuationToken?: string;\n OfferTypeFilter?: MarketOfferType;\n}\n```\n\nOne field name is reused across two different roles depending on the action:\n`ItemInstanceIDs` on a create call means \"escrow these specific instances as\nthe goods\"; it's absent on read/bid/response calls. `PageSize` is clamped\nserver-side to `1..100` regardless of what the client sends\n(`MarketplaceDBService.FindHistoryAsync` and friends).\n\n`MarketplaceAction` enumerates the 20 backend action names this request shape\ndispatches on (`GetDefinitions`, `GetGroupedOffers`, `GetOffersByItem`,\n`GetOffer`, `GetBuyOrders`, `GetMyState`, `GetHistory`, `CreateListing`,\n`CancelListing`, `Buy`, `CreateAuction`, `PlaceBid`, `ClaimAuction`,\n`CreateBuyOrder`, `CancelBuyOrder`, `FillBuyOrder`, `CreateDirectTrade`,\n`RespondDirectTrade`, `CancelDirectTrade`, `ClaimBack`) — one more than the 19\npublic `MarketplaceService` methods because `getOffersByItem`/`getBuyOrders`\nboth exist as distinct client methods but the action list also separately\nnames every read; you won't call this directly, it's internal routing.\n\n---\n\n## Settlement & escrow model\n\nEvery create call escrows something immediately, in the same transaction as\nthe offer's insert:\n\n| Create call | What gets escrowed |\n| ------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| `createListing` | The goods (`itemInstanceIDs` or `goodsAmount` of `itemID`), plus the selected `ListingFeeOptions` option if configured. |\n| `createAuction` | The goods, same as a listing (no separate fee field on auctions). |\n| `createBuyOrder` | The `priceBundle` (payment), not goods — inverted from a listing. |\n| `createDirectTrade` | The offered side (instances and/or bundle) — nothing from the target until they accept. |\n| `placeBid` | The bid amount — refunded to the previous leader via `PendingRefunds` when outbid. |\n\nSettlement then plays out one of three ways:\n\n1. **Instant dual-party** — `buy`, `fillBuyOrder`, and\n `respondDirectTrade(offerID, true)` when a `requestedBundle` was set: the\n backend computes both sides in one atomic step\n (`ResourceService.ApplyDualPartyAtomicAsync`) and returns\n `ResourceDualPartyResult` (`FromUserID`/`ToUserID` +\n `FromResult`/`ToResult`, each a `ResourceOperation`). The SDK applies\n **only the calling player's side** — it checks which of `FromUserID`/\n `ToUserID` matches the current session and applies that result, ignoring\n the other party's (there is no way to see the counterparty's resulting\n balances from this call).\n2. **Instant single-party grant** — `respondDirectTrade(offerID, true)` on a\n pure gift (no `requestedBundle`): only `Resources` is populated, applied\n directly to the accepter.\n3. **Lazy per-side claim** — auctions never auto-settle. After\n `ExpiresAt`, the winning bidder and the seller each call `claimAuction`\n independently; each call returns that caller's own `Resources` (goods to\n the winner — the bid amount was already paid at `placeBid` time; net\n proceeds to the seller). Nothing forces both sides to claim promptly — an\n unclaimed auction just sits in `Claimables`.\n\n**The direct-trade-decline special case**: `respondDirectTrade(offerID,\nfalse)` returns a `Resources` grant, but it is the **escrow being returned to\nthe offer's `CreatorUserID`** (the sender getting their goods back), not a\ngrant to the decliner. Confirmed against the backend source\n(`Marketplace.cs`): decline calls `ReturnEscrowToInitiatorAsync`, which runs\n`ResourceService.ApplyResourceOperationAtomicAsync` with\n`userID: offer.CreatorUserID` — never `service.UserID` (the caller). The SDK\nintentionally does **not** apply this `Resources` to the local cache in that\nbranch — applying it would incorrectly credit the decliner's own balances with\nresources that were actually returned to someone else's account. If you need\nto reflect \"trade declined\" in the decliner's UI, do it by removing the trade\nfrom `IncomingTrades`/showing `Status: \"Declined\"` — not by expecting a\nbalance change.\n\n`cancelListing`/`cancelBuyOrder`/`cancelDirectTrade` and `claimBack` are all\nsingle-party grants (`Resources` only) refunding the **caller's own** escrow —\nstraightforward `applyResourceOperation` cases, no dual-party ambiguity. A\ncancel is only legal on an offer the caller created, while it's still\n`Active`; an auction that already has a bidder cannot be cancelled at all\n(bids are already in escrow — the only exit is `claimAuction` after\n`ExpiresAt`).\n\n---\n\n## Commission model\n\nComputed per price position (each `ResourceEntry` and each `EventTokenOperation`\nin the price bundle independently) at settlement, transcribed verbatim from\n`MarketplaceHelpers.ComputeFee`/`SplitCommission` (`MarketplaceHelpers.cs`):\n\n```\nfee = min(amount, max(ceil(amount * Percent), MinPerPosition))\nnet = amount - fee\n```\n\n- `ceil` rounds the percentage cut up to the next whole unit.\n- `MinPerPosition` is a floor, not an addition — it only raises `fee` when the\n percentage cut would be smaller than the minimum.\n- `fee` is clamped to never exceed `amount` itself (so a price position with a\n huge `MinPerPosition` can zero out the seller's net for that position, but\n never make it negative).\n- A position where the resulting `net` is `0` is dropped entirely from the\n net bundle; likewise a `fee` of `0` is dropped from the fee bundle — so\n `CommissionTaken` only lists positions that actually had something taken.\n\nThe effective `(Percent, MinPerPosition)` pair is resolved once, at **offer\ncreation time**, and snapshotted onto the offer\n(`CommissionPercentSnapshot`/`CommissionMinSnapshot`/`CommissionSinkSnapshot`)\n— `PerCatalogOverrides[GoodsCatalogID]` if present, else the top-level\n`Percent`/`MinPerPosition` (`MarketplaceHelpers.ResolveCommission`). This\nmeans a later change to the title's commission config **never** affects an\nalready-listed offer; only new offers pick up the new rate. For a\n`DirectTrade`, the snapshot is `(0, 0)` unless\n`Commission.ApplyToDirectTrades` was `true` **and** the trade was a\nswap (not a gift) at creation time.\n\n`Sink`: `\"Burn\"` (default) destroys the commission outright — it's simply the\ndifference between what the buyer/payer consumed and what the seller was\ngranted, with no corresponding write anywhere. `\"Ledger\"` additionally credits\n`LedgerAccountID` (default `\"default\"` if empty) in the title's\n`MarketplaceLedgerDocument`, in the same transaction as the settlement — an\naccounting/reporting concept internal to the title's economy, with no\nclient-visible effect beyond `CommissionTaken` on the response. Either way,\nwhat the trading parties themselves receive is identical — the sink only\nchanges whether the fee is destroyed or recorded for the title's own\nbookkeeping.\n\n---\n\n## Server-side limits, locking, and idempotency\n\nVerified against `Marketplace.cs`/`MarketplaceHelpers.cs`/`MarketplaceDefinitions.cs`:\n\n- **Per-endpoint rate limit**: every `MarketplaceV2` action shares a 500ms\n IP-level rate limit (`RateLimitMilliseconds = 500` in `Marketplace.cs`,\n enforced by `ClientRun.Execute`) — a repeat call inside that window surfaces\n to the SDK as `reason: \"throttled\"`.\n- **Per-offer mutation lock**: `buy`, `placeBid`, `claimAuction`,\n `fillBuyOrder`, `respondDirectTrade`, every `cancel*`, and `claimBack(offerID)`\n additionally take a short-lived Redis lock keyed on the specific `OfferID`\n (200ms rate window, 5000ms hold, `Marketplace.cs`'s `AcquireOfferLockAsync`)\n — this is on top of the per-endpoint rate limit and is scoped to that one\n offer, not the whole action. A concurrent second call on the same offer\n (yours or another player racing you for the same listing) fails with either\n `\"Too many requests for this offer, try again.\"` or `\"This offer is being\nprocessed, try again.\"` — both are safe to retry. The Mongo-side\n precondition (`PatchOfferInSessionAsync`'s optimistic-concurrency filter) is\n the actual source of truth against double-sell/double-claim; the Redis lock\n is only a latency optimization, so correctness doesn't depend on the client\n handling this perfectly.\n- **Per-player anti-abuse caps** (checked before escrow, in addition to\n `LimitSpec` cooldown/daily-cap pairs): `MaxActiveListings` (Listing+Auction\n combined), `MaxActiveOrders` (BuyOrder), `MaxPendingOutgoing` (DirectTrade,\n counts only the sender's still-`Active` outgoing offers). Each rejection\n names the current count and the cap, e.g. `\"You have too many open offers\n(10/10).\"`\n- **Dedup / atomicity**: there is no batch/multi-item marketplace action —\n every method operates on exactly one offer per call, so there's no\n partial-success array to interpret (contrast with modules that expose\n `BatchItemResult[]`). Every create is one atomic operation (escrow debit +\n offer insert + limit-counter patch in a single transaction via\n `ResourceService.ApplyResourceOperationAtomicAsync`); every settlement is\n one atomic operation touching both parties via\n `ResourceService.ApplyDualPartyAtomicAsync`. Resources for a create call\n live on `MarketplaceCreateOfferResponse.Resources`; for a settlement, on\n `MarketplaceSettlementResponse.Settlement` (dual-party) or `.Resources`\n (single-party) — there is no separate top-level resources field to check.\n- **Idempotency**: create calls (`createListing`/`createAuction`/\n `createBuyOrder`/`createDirectTrade`) resolve their idempotency key via\n `ResourceService.ResolveRelatedEntityID(args.RelatedEntityID, \"mp_create_{userID}\" | \"mp_order_{userID}\" | \"mp_trade_{userID}\")`\n — passing a stable `relatedEntityID` on repeated client-side retries of the\n _same_ create prevents a duplicate offer/charge; omitting it means every\n call is treated as a new create. Settlement actions key off the offer\n itself (e.g. `\"MpBuy:{offerID}\"`, `\"MpBid:{offerID}:{bidIndex}\"`,\n `\"MpAuctionGoods:{offerID}\"`), so retrying the exact same settlement call is\n naturally idempotent without the client needing to supply anything.\n"
9
9
  }
10
10
  ]
11
11
  }