@idosgames/mcp 0.1.24 → 0.1.25

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 (37) hide show
  1. package/package.json +1 -1
  2. package/registry/host.json +11 -11
  3. package/registry/index.json +41 -42
  4. package/registry/modules/ai.json +1 -1
  5. package/registry/modules/board-game.json +1 -1
  6. package/registry/modules/boosts.json +1 -1
  7. package/registry/modules/character.json +4 -4
  8. package/registry/modules/chat.json +1 -1
  9. package/registry/modules/collections.json +1 -1
  10. package/registry/modules/coop-events.json +1 -1
  11. package/registry/modules/craft.json +1 -1
  12. package/registry/modules/deals.json +1 -1
  13. package/registry/modules/events.json +1 -1
  14. package/registry/modules/friends.json +1 -1
  15. package/registry/modules/idle-rpg.json +21 -21
  16. package/registry/modules/inventory.json +1 -1
  17. package/registry/modules/invite.json +1 -1
  18. package/registry/modules/leaderboard.json +1 -1
  19. package/registry/modules/lootboxes.json +1 -1
  20. package/registry/modules/mailbox.json +1 -1
  21. package/registry/modules/marketplace.json +1 -1
  22. package/registry/modules/mini-game.json +1 -1
  23. package/registry/modules/multiplayer.json +1 -1
  24. package/registry/modules/premium.json +1 -1
  25. package/registry/modules/profile.json +11 -12
  26. package/registry/modules/pvp.json +1 -1
  27. package/registry/modules/quests.json +1 -1
  28. package/registry/modules/rewards.json +1 -1
  29. package/registry/modules/season.json +1 -1
  30. package/registry/modules/store.json +1 -1
  31. package/registry/modules/tutorials.json +1 -1
  32. package/registry/modules/voxelcraft.json +1 -1
  33. package/registry/modules/workshop.json +1 -1
  34. package/registry/skills/idosgames-compose-modules.json +1 -1
  35. package/registry/skills/idosgames-getting-started.json +1 -1
  36. package/registry/skills/idosgames-module-contract.json +1 -1
  37. package/registry/skills/user-profile.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  {
17
17
  "path": "IDOS.md",
18
- "content": "# IDOS.md — project guide\n\nEvery AI agent working on this game reads this file first: the platform's AI editor loads it into\ncontext on every run, and external agents reach it through `AGENTS.md` / `CLAUDE.md`. Keep it short\nand evergreen — what the game is, how the project is laid out, rules that hold for all of it. The\nhistory of individual features does NOT belong here (see \"Feature history\" at the end).\n\n## This game\n\n<!-- 2–4 lines, filled in once the direction is clear: genre, core loop, target platform, tone.\n Update it when the concept changes — not with every feature. -->\n\n_Not described yet._\n\n## Installed modules\n\nMirror of the `modules` array in `src/modules.ts` (the source of truth), maintained by the\nplatform — do not edit by hand.\n\n<!-- idos:modules:start -->\n\n_None yet._\n<!-- idos:modules:end -->\n\n## How the project is built\n\n**The base + modules** on the iDosGames SDK. The base is sign-in and the lobby; everything else — the\nsystems (shop, heroes, leaderboards…) and the games — is a module. After sign-in the player lands in\nthe lobby: its tabs are the systems' screens, \"Play\" launches a game, and a small button over the\ngame returns to the lobby.\n\n- `src/main.tsx` — creates ONE `IDosGamesClient` and calls `mountHost(...)` with the base first, then\n the modules. The host signs the player in before any module runs; modules never create a client or\n log in themselves.\n- `src/base/` — **the base**, ordinary code of this project: `login/` (the sign-in and play-access\n screens), `lobby/` (tabs, balances, wallet, profile, \"Play\"), `module.ts` (`baseModule`). It takes\n the shared-UI roles `currency-bar`, `wallet`, `account`, `menu`, `modes`, so a game hides its own\n balances and wallet by itself (`ctx.sharedUi.shouldDraw`) — don't edit games for that.\n- `src/app.config.ts` — what happens after sign-in (`start`: `\"lobby\"` or a game's mode id) and the\n order of the lobby's tabs. The look, animations and sounds of the WHOLE app — sign-in included:\n `src/ui.config.ts` (the only file a restyle edits; see the `idosgames-ui-kit` skill).\n- `src/modules.ts` — the composition list. **Regenerated by the platform** whenever modules are\n installed or removed, so it holds only the imports and the `modules` array — nothing else. It is\n also the list of what the app has: remove a system here and its tab is gone.\n- `src/modules/<id>/` — one folder per module: the catalog's systems and games and the game's own\n modules alike. A system registers its screen with `ctx.features.register` (a lobby tab) and hides it\n while the title has nothing for it (`available: false` + `ctx.features.setAvailable`); a game\n registers a route (`ctx.registerRoute`) and gets a \"Play\" button.\n- `src/shared/` — created when needed: code two or more of this game's modules share.\n- `src/idos.title.ts` — **generated, do not edit.** The project's identity: which Title's data this\n game reads and writes, baked in so a packaged mobile build or an iframe embed still knows it.\n- `src/config.ts` — **platform-owned, do not edit** (the build packs the platform's copy over it).\n Resolves the effective title and environment: identity from `src/idos.title.ts`, DEV vs PROD from\n the address (`{id}-dev.idos.games` vs `{id}.idos.games`) — never from the link's query. Never read\n the title from the URL yourself: use `client.titleID`, or `ctx.titleId` inside a module's `setup()`.\n- `src/env.ts` — build-time values from `.env.local` (local development only).\n- `src/walletLogin.tsx` — the wallet sign-in button, generated for web3 titles; safe to edit.\n- `idos.modules.lock.json` — **platform-owned, do not edit.** Which modules came from the catalog,\n their versions and file fingerprints — so updating a module never silently overwrites your edits.\n\n## Where new code goes\n\nFull standard: the `idosgames-project-structure` skill — load it before creating a module, touching\n`src/shared/`, or moving code around.\n\n- A catalog module already does it (a shop, heroes, leaderboards, rewards, …) → install that module\n instead of rebuilding it.\n- It extends an existing module → change that module. The lobby, sign-in, balances, wallet and\n profile → change `src/base/`.\n- It is its own mode, screen or system → a NEW module `src/modules/<feature-id>/` (kebab-case):\n `index.ts` exporting `<camelCaseId>Module`, `module.ts`, `module.meta.json`, and as needed\n `components/` (React UI), `game/` (engine & simulation), `data/` (static tables), `react/` (hooks,\n context). Register it in `src/modules.ts`. A screen → `ctx.features.register` (it becomes a lobby\n tab); a mode of its own → `ctx.registerRoute` (it gets a \"Play\" button).\n- To open another system's screen from your code: `ctx.features.open(\"store\")` — never import it. To\n switch modes: `ctx.navigate(\"lobby\")` or a game's mode id. Other UI shown over every mode → a panel\n with `activeOnly: false`.\n- Modules never reach into each other: a module imports only its own folder, `src/shared/` and npm\n packages — never another module's files or host files. Durable shared state lives in the SDK\n client; live signals go through `ctx.events` with typed topics\n (`defineTopic(\"<module-id>:<event>@1\", shape({…}))`) declared in the module's `module.meta.json`;\n to listen to another module, copy its topic, never import it.\n- `src/shared/` holds code TWO or more modules need (code one module needs stays inside it), by\n purpose — `shared/ui/`, `shared/types/`, `shared/utils/`: types, constants, UI, pure helpers; no\n game state or logic. It never imports a module.\n- When a change adds a new responsibility to a file past ~400 lines, put that part in its own file.\n\n## The SDK is npm packages — its source is NOT in this project\n\n`@idosgames/core`, `@idosgames/react`, `@idosgames/module-sdk`, `@idosgames/app-shell` are\ndependencies in `package.json`. There is no `node_modules` to read and no `.d.ts` to inspect here,\nso **you cannot discover the SDK's API by reading files in this project.**\n\n1. **Load the matching skill first.** Skills are the authoritative recipes for this SDK — one per\n service (store, currency, item, quest, leaderboard, auth, blockchain, …).\n2. Then copy the patterns from the modules already in `src/modules/`.\n3. Never invent an SDK method, hook, or type name — it looks plausible and fails the build.\n\n`client.<service>.<action>()` returns `OperationResult<T>` (check `isOk` / `isFail`; it does not\nthrow). In React, reach the client with `useIDosGamesClient()` and player state with\n`useUserState()` from `@idosgames/react`.\n\n## Backend features live in the Title configuration, not in this repo\n\nCurrencies, items, characters, store offers, quests, lootboxes, leaderboards and seasons are\nconfigured per environment in the platform. There is **no config file in this project** — do not\ncreate `config/*.json`; it would be ignored. Write code against ids that exist in the title; if a\nfeature needs an entity that is not configured yet, name exactly what has to be added instead of\ninventing ids.\n\n## Stack & running\n\n- React 19 + TypeScript (strict) + Vite 8. Game engines (three.js, Phaser) arrive with the modules\n that use them.\n- In the platform's AI editor there is no terminal and no package install: the project is built on\n the server after each turn and shown in the live preview. Use only what `package.json` lists.\n- Locally (exported project or an external agent): `npm install`, then `npm run dev`,\n `npm run build`, `npm run typecheck`.\n- Client code ships to players: never put secrets, keys or tokens in any file here.\n- Assets are not stored in the project — generated images/audio/3D are CDN URLs; reference those.\n\n## Feature history — how this project remembers\n\nThis file stays short on purpose: it is loaded on every run, so every paragraph here is paid for\nby all future work. When a game system or feature is done and something about it is worth\nremembering that the code does not show — why it is designed this way, a non-obvious constraint,\na decision the user made — write it to `docs/feature-history/<slug>.md` (one file per system;\nupdate that file when the system changes) and add ONE line to `docs/feature-history/README.md`:\n`- [Title](slug.md) — one-line gist`. Never append a feature write-up to this file. Those entries\nare not loaded automatically: open the relevant one before changing that system.\n"
18
+ "content": "# IDOS.md — project guide\n\nEvery AI agent working on this game reads this file first: the platform's AI editor loads it into\ncontext on every run, and external agents reach it through `AGENTS.md` / `CLAUDE.md`. Keep it short\nand evergreen — what the game is, how the project is laid out, rules that hold for all of it. The\nhistory of individual features does NOT belong here (see \"Feature history\" at the end).\n\n## This game\n\n<!-- 2–4 lines, filled in once the direction is clear: genre, core loop, target platform, tone.\n Update it when the concept changes — not with every feature. -->\n\n_Not described yet._\n\n## Installed modules\n\nMirror of the `modules` array in `src/modules.ts` (the source of truth), maintained by the\nplatform — do not edit by hand.\n\n<!-- idos:modules:start -->\n\n_None yet._\n<!-- idos:modules:end -->\n\n## How the project is built\n\n**The base + modules** on the iDosGames SDK. The base is sign-in and the lobby; everything else — the\nsystems (shop, heroes, leaderboards…) and the games — is a module. After sign-in the player lands in\nthe game INSIDE the lobby: the lobby's balances and profile on top, its tabs at the bottom, and the\n\"Play\" tab is the game itself; any other tab opens that system's screen (`src/base/lobby/GameFrame.tsx`\n— the host's `header`/`footer` panel slots lay the game out between them).\n\n- `src/main.tsx` — creates ONE `IDosGamesClient` and calls `mountHost(...)` with the base first, then\n the modules. The host signs the player in before any module runs; modules never create a client or\n log in themselves.\n- `src/base/` — **the base**, ordinary code of this project: `login/` (the sign-in and play-access\n screens), `lobby/` (tabs, balances, wallet, profile, \"Play\"), `module.ts` (`baseModule`). It takes\n the shared-UI roles `currency-bar`, `wallet`, `account`, `menu`, `modes`, so a game hides its own\n balances and wallet by itself (`ctx.sharedUi.shouldDraw`) — don't edit games for that.\n- `src/app.config.ts` — what happens after sign-in (`start`: `\"game\"` — straight into the game, the\n default; `\"lobby\"`; or a game's mode id) and the order of the lobby's tabs. The look, animations and sounds of the WHOLE app — sign-in included:\n `src/ui.config.ts` (the only file a restyle edits; see the `idosgames-ui-kit` skill).\n- `src/modules.ts` — the composition list. **Regenerated by the platform** whenever modules are\n installed or removed, so it holds only the imports and the `modules` array — nothing else. It is\n also the list of what the app has: remove a system here and its tab is gone.\n- `src/modules/<id>/` — one folder per module: the catalog's systems and games and the game's own\n modules alike. A system registers its screen with `ctx.features.register` (a lobby tab) and hides it\n while the title has nothing for it (`available: false` + `ctx.features.setAvailable`); a game\n registers a route (`ctx.registerRoute`) and gets a \"Play\" button.\n- `src/shared/` — created when needed: code two or more of this game's modules share.\n- `src/idos.title.ts` — **generated, do not edit.** The project's identity: which Title's data this\n game reads and writes, baked in so a packaged mobile build or an iframe embed still knows it.\n- `src/config.ts` — **platform-owned, do not edit** (the build packs the platform's copy over it).\n Resolves the effective title and environment: identity from `src/idos.title.ts`, DEV vs PROD from\n the address (`{id}-dev.idos.games` vs `{id}.idos.games`) — never from the link's query. Never read\n the title from the URL yourself: use `client.titleID`, or `ctx.titleId` inside a module's `setup()`.\n- `src/env.ts` — build-time values from `.env.local` (local development only).\n- `src/walletLogin.tsx` — the wallet sign-in button, generated for web3 titles; safe to edit.\n- `idos.modules.lock.json` — **platform-owned, do not edit.** Which modules came from the catalog,\n their versions and file fingerprints — so updating a module never silently overwrites your edits.\n\n## Where new code goes\n\nFull standard: the `idosgames-project-structure` skill — load it before creating a module, touching\n`src/shared/`, or moving code around.\n\n- A catalog module already does it (a shop, heroes, leaderboards, rewards, …) → install that module\n instead of rebuilding it.\n- It extends an existing module → change that module. The lobby, sign-in, balances, wallet and\n profile → change `src/base/`.\n- It is its own mode, screen or system → a NEW module `src/modules/<feature-id>/` (kebab-case):\n `index.ts` exporting `<camelCaseId>Module`, `module.ts`, `module.meta.json`, and as needed\n `components/` (React UI), `game/` (engine & simulation), `data/` (static tables), `react/` (hooks,\n context). Register it in `src/modules.ts`. A screen → `ctx.features.register` (it becomes a lobby\n tab); a mode of its own → `ctx.registerRoute` (it gets a \"Play\" button).\n- To open another system's screen from your code: `ctx.features.open(\"store\")` — never import it. To\n switch modes: `ctx.navigate(\"lobby\")` or a game's mode id. Other UI shown over every mode → a panel\n with `activeOnly: false`.\n- Modules never reach into each other: a module imports only its own folder, `src/shared/` and npm\n packages — never another module's files or host files. Durable shared state lives in the SDK\n client; live signals go through `ctx.events` with typed topics\n (`defineTopic(\"<module-id>:<event>@1\", shape({…}))`) declared in the module's `module.meta.json`;\n to listen to another module, copy its topic, never import it.\n- `src/shared/` holds code TWO or more modules need (code one module needs stays inside it), by\n purpose — `shared/ui/`, `shared/types/`, `shared/utils/`: types, constants, UI, pure helpers; no\n game state or logic. It never imports a module.\n- When a change adds a new responsibility to a file past ~400 lines, put that part in its own file.\n\n## The SDK is npm packages — its source is NOT in this project\n\n`@idosgames/core`, `@idosgames/react`, `@idosgames/module-sdk`, `@idosgames/app-shell` are\ndependencies in `package.json`. There is no `node_modules` to read and no `.d.ts` to inspect here,\nso **you cannot discover the SDK's API by reading files in this project.**\n\n1. **Load the matching skill first.** Skills are the authoritative recipes for this SDK — one per\n service (store, currency, item, quest, leaderboard, auth, blockchain, …).\n2. Then copy the patterns from the modules already in `src/modules/`.\n3. Never invent an SDK method, hook, or type name — it looks plausible and fails the build.\n\n`client.<service>.<action>()` returns `OperationResult<T>` (check `isOk` / `isFail`; it does not\nthrow). In React, reach the client with `useIDosGamesClient()` and player state with\n`useUserState()` from `@idosgames/react`.\n\n## Backend features live in the Title configuration, not in this repo\n\nCurrencies, items, characters, store offers, quests, lootboxes, leaderboards and seasons are\nconfigured per environment in the platform. There is **no config file in this project** — do not\ncreate `config/*.json`; it would be ignored. Write code against ids that exist in the title; if a\nfeature needs an entity that is not configured yet, name exactly what has to be added instead of\ninventing ids.\n\n## Stack & running\n\n- React 19 + TypeScript (strict) + Vite 8. Game engines (three.js, Phaser) arrive with the modules\n that use them.\n- In the platform's AI editor there is no terminal and no package install: the project is built on\n the server after each turn and shown in the live preview. Use only what `package.json` lists.\n- Locally (exported project or an external agent): `npm install`, then `npm run dev`,\n `npm run build`, `npm run typecheck`.\n- Client code ships to players: never put secrets, keys or tokens in any file here.\n- Assets are not stored in the project — generated images/audio/3D are CDN URLs; reference those.\n\n## Feature history — how this project remembers\n\nThis file stays short on purpose: it is loaded on every run, so every paragraph here is paid for\nby all future work. When a game system or feature is done and something about it is worth\nremembering that the code does not show — why it is designed this way, a non-obvious constraint,\na decision the user made — write it to `docs/feature-history/<slug>.md` (one file per system;\nupdate that file when the system changes) and add ONE line to `docs/feature-history/README.md`:\n`- [Title](slug.md) — one-line gist`. Never append a feature write-up to this file. Those entries\nare not loaded automatically: open the relevant one before changing that system.\n"
19
19
  },
20
20
  {
21
21
  "path": "index.html",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  {
25
25
  "path": "package.json",
26
- "content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.10.1\",\n \"@idosgames/core\": \"0.21.0\",\n \"@idosgames/module-sdk\": \"0.8.2\",\n \"@idosgames/react\": \"0.4.4\",\n \"@idosgames/wallet\": \"0.6.2\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
26
+ "content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.11.0\",\n \"@idosgames/core\": \"0.21.0\",\n \"@idosgames/module-sdk\": \"0.9.0\",\n \"@idosgames/react\": \"0.4.4\",\n \"@idosgames/wallet\": \"0.6.2\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
27
27
  },
28
28
  {
29
29
  "path": "public/sw.js",
@@ -31,31 +31,31 @@
31
31
  },
32
32
  {
33
33
  "path": "src/app.config.ts",
34
- "content": "import { defineAppConfig } from \"./base/app-config\";\n\n// What happens after sign-in and how the lobby is ordered. The look, animations and sounds are in\n// ./ui.config.ts. Which systems (shop, heroes, leaderboards…) and games the app has is\n// ./modules.ts: remove a module there and its tab is gone. A tab whose system has nothing configured\n// on the title yet (no store offers, no heroes…) stays hidden from players by itself.\n//\n// Everything is optional; uncomment and edit.\nexport default defineAppConfig({\n // \"lobby\" — sign-in → lobby → \"Play\". The mode id of a game (\"voxelcraft\") starts straight in it.\n start: \"lobby\",\n lobby: {\n // order: [\"play\", \"store\", \"character\"], // these tabs first, in this order\n // primary: [\"season\"], // also a main tab rather than under \"More\"\n },\n});\n"
34
+ "content": "import { defineAppConfig } from \"./base/app-config\";\n\n// What happens after sign-in and how the lobby is ordered. The look, animations and sounds are in\n// ./ui.config.ts. Which systems (shop, heroes, leaderboards…) and games the app has is\n// ./modules.ts: remove a module there and its tab is gone. A tab whose system has nothing configured\n// on the title yet (no store offers, no heroes…) stays hidden from players by itself.\n//\n// Everything is optional; uncomment and edit.\nexport default defineAppConfig({\n // \"game\" — sign-in → straight into the game (the first one, if the app has several); the lobby\n // is one tap away. \"lobby\" — sign-in → lobby → \"Play\". The mode id of a game (\"voxelcraft\")\n // starts in that one.\n start: \"game\",\n lobby: {\n // order: [\"play\", \"store\", \"character\"], // these tabs first, in this order\n // primary: [\"season\"], // also a main tab rather than under \"More\"\n },\n});\n"
35
35
  },
36
36
  {
37
37
  "path": "src/base/app-config.ts",
38
- "content": "// The shape of src/app.config.ts and its defaults.\n//\n// app.config.ts says what happens after sign-in and how the lobby is ordered. It does NOT list which\n// systems the app has — that is src/modules.ts: every installed module (shop, heroes, a game…) brings\n// its own tab or mode, and removing it there removes it from the app.\n\n/** The mode id of the lobby. Every other mode is a module's route (a game). */\nexport const LOBBY_MODE = \"lobby\";\n\nexport interface AppConfig {\n /**\n * Where the player lands after sign-in: `\"lobby\"`, or the mode id of a game (`\"voxelcraft\"`) to\n * start straight in it — the lobby stays one tap away.\n */\n start: string;\n lobby: {\n /** These tabs first, in this order (feature ids: \"play\", \"store\", \"character\"…). */\n order: readonly string[];\n /** Also shown as main tabs rather than under \"More\" (feature ids). */\n primary: readonly string[];\n };\n}\n\nexport interface AppConfigInput {\n start?: string;\n lobby?: {\n order?: readonly string[];\n primary?: readonly string[];\n };\n}\n\n/** Fills in the defaults — every field of app.config.ts is optional. */\nexport function defineAppConfig(input: AppConfigInput = {}): AppConfig {\n const start = input.start?.trim();\n return {\n start: start ? start : LOBBY_MODE,\n lobby: {\n order: input.lobby?.order ?? [],\n primary: input.lobby?.primary ?? [],\n },\n };\n}\n"
38
+ "content": "// The shape of src/app.config.ts and its defaults.\n//\n// app.config.ts says what happens after sign-in and how the lobby is ordered. It does NOT list which\n// systems the app has — that is src/modules.ts: every installed module (shop, heroes, a game…) brings\n// its own tab or mode, and removing it there removes it from the app.\n\n/** The mode id of the lobby. Every other mode is a module's route (a game). */\nexport const LOBBY_MODE = \"lobby\";\n\n/** `start` value: the first game of the app (by its mode order); the lobby when there is none. */\nexport const GAME_START = \"game\";\n\nexport interface AppConfig {\n /**\n * Where the player lands after sign-in: `\"game\"` (the default) — straight into the first game;\n * `\"lobby\"`; or the mode id of a particular game (`\"voxelcraft\"`). From a game the lobby stays\n * one tap away.\n */\n start: string;\n lobby: {\n /** These tabs first, in this order (feature ids: \"play\", \"store\", \"character\"…). */\n order: readonly string[];\n /** Also shown as main tabs rather than under \"More\" (feature ids). */\n primary: readonly string[];\n };\n}\n\nexport interface AppConfigInput {\n start?: string;\n lobby?: {\n order?: readonly string[];\n primary?: readonly string[];\n };\n}\n\n/** Fills in the defaults — every field of app.config.ts is optional. */\nexport function defineAppConfig(input: AppConfigInput = {}): AppConfig {\n const start = input.start?.trim();\n return {\n start: start ? start : GAME_START,\n lobby: {\n order: input.lobby?.order ?? [],\n primary: input.lobby?.primary ?? [],\n },\n };\n}\n"
39
39
  },
40
40
  {
41
41
  "path": "src/base/index.ts",
42
- "content": "// The base of the app: sign-in (./login, rendered by the host) and the lobby (./module.ts, installed\n// by main.tsx before the modules of src/modules.ts).\n\nexport { baseModule, createBaseModule, type BaseWeb3 } from \"./module\";\nexport { LoginScreen, type LoginScreenExtras } from \"./login/LoginScreen\";\nexport { PlayAccessScreen } from \"./login/PlayAccessScreen\";\nexport {\n defineAppConfig,\n LOBBY_MODE,\n type AppConfig,\n type AppConfigInput,\n} from \"./app-config\";\n"
42
+ "content": "// The base of the app: sign-in (./login, rendered by the host) and the lobby (./module.ts, installed\n// by main.tsx before the modules of src/modules.ts).\n\nexport { baseModule, createBaseModule, type BaseWeb3 } from \"./module\";\nexport { LoginScreen, type LoginScreenExtras } from \"./login/LoginScreen\";\nexport { PlayAccessScreen } from \"./login/PlayAccessScreen\";\nexport {\n defineAppConfig,\n GAME_START,\n LOBBY_MODE,\n type AppConfig,\n type AppConfigInput,\n} from \"./app-config\";\n"
43
43
  },
44
44
  {
45
45
  "path": "src/base/lobby/Account.tsx",
46
46
  "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n outlined,\n Popup,\n useGameLayout,\n useUiKit,\n v,\n well,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The player's corner of the lobby header. With the profile module installed (and available) the\n// button opens its screen; otherwise a small popup with the player ID (tap to copy) and \"Log out\".\n// The base takes the \"account\" role, so the host never floats its own copy over the tabs.\n\nexport function Account({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [open, setOpen] = useState(false);\n const [copied, setCopied] = useState(false);\n const userID = client.auth.context?.userID ?? null;\n const hasProfile = features.get(\"profile\")?.available === true;\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n const logout = (): void => void client.auth.logout();\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n const onProfile = (): void => {\n kit.play(\"click\");\n if (hasProfile) features.open(\"profile\");\n else setOpen(true);\n };\n\n return (\n <>\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"profile\")}\n onClick={onProfile}\n style={chip}\n data-tutorial-anchor=\"lobby:profile\"\n >\n <Icon glyph=\"user\" size={layout === \"phone\" ? 30 : 24} />\n {layout !== \"phone\" && shortID ? (\n <span\n style={{\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 13,\n }}\n >\n {shortID}\n </span>\n ) : null}\n </button>\n {open ? (\n <Popup title={t(\"profile\")} onClose={() => setOpen(false)} width={380}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Icon glyph=\"user\" size={64} />\n {userID ? (\n <>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"playerId\")}\n </div>\n <div\n style={{\n ...outlined,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 14,\n wordBreak: \"break-all\",\n textAlign: \"center\",\n }}\n >\n {userID}\n </div>\n </>\n ) : null}\n </div>\n {userID ? (\n <Button tone=\"blue\" onClick={copy}>\n <Icon glyph={copied ? \"check\" : \"copy\"} size={20} />{\" \"}\n {copied ? t(\"copied\") : t(\"copyId\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={logout}>\n <Icon glyph=\"logout\" size={20} /> {t(\"logout\")}\n </Button>\n </Popup>\n ) : null}\n </>\n );\n}\n\nconst chip: CSSProperties = {\n ...outlined,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: 3,\n paddingRight: 10,\n borderRadius: 999,\n ...well,\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n};\n"
47
47
  },
48
48
  {
49
- "path": "src/base/lobby/BackToLobby.tsx",
50
- "content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { ModeRegistry } from \"@idosgames/module-sdk\";\nimport { IconButton } from \"@idosgames/react/ui\";\nimport { LOBBY_MODE } from \"../app-config\";\nimport { t } from \"./i18n\";\n\n// The only thing the base draws over a game: a small round button back to the lobby, in the top-left\n// corner. Balances, the wallet and the tabs stay in the lobby — the game keeps the whole screen.\n// A game with its own \"exit\" calls ctx.navigate(\"lobby\") instead; then delete this panel in\n// ../module.ts.\n\nexport function makeBackToLobby(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n): ComponentType {\n return function BackToLobby(): ReactNode {\n const current = useSyncExternalStore(\n modes.subscribe,\n modes.current,\n modes.current,\n );\n if (current === null || current === LOBBY_MODE) return null;\n return (\n <div\n style={{\n position: \"absolute\",\n top: \"calc(var(--idos-safe-top, 0px) + 10px)\",\n left: 10,\n pointerEvents: \"auto\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label={t(\"toLobby\")}\n onClick={() => navigate(LOBBY_MODE)}\n size={40}\n tone=\"blue\"\n />\n </div>\n );\n };\n}\n"
49
+ "path": "src/base/lobby/GameFrame.tsx",
50
+ "content": "import {\n useRef,\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry, ModeRegistry } from \"@idosgames/module-sdk\";\nimport { useLayout, useUiKit, v } from \"@idosgames/react/ui\";\nimport { LOBBY_MODE, type AppConfig } from \"../app-config\";\nimport { BottomTabs, LobbyHeader } from \"./Lobby\";\nimport { PLAY, arrangeFeatures, splitTabs } from \"./model\";\n\n// The game inside the lobby. While a game is on screen the base keeps the lobby around it: the same\n// balances, wallet and profile on top (the host's `header` slot), the same tabs at the bottom (its\n// `footer` slot) with \"Play\" lit — \"Play\" IS the game. Any other tab opens that screen in the lobby.\n// The host lays the game's canvas and panels out between the two, so a game needs to know nothing.\n// In the lobby itself both render nothing — it draws its own.\n\nexport interface GameFrameOptions {\n features: FeatureRegistry;\n modes: ModeRegistry;\n /** `lobby` of src/app.config.ts — the same tab order as in the lobby. */\n lobby: AppConfig[\"lobby\"];\n wallet: boolean;\n /** Switch to the lobby and open this screen there (a feature id, or the \"More\" grid). */\n openInLobby: (id: string) => void;\n}\n\nfunction useInGame(modes: ModeRegistry): boolean {\n const current = useSyncExternalStore(\n modes.subscribe,\n modes.current,\n modes.current,\n );\n return current !== null && current !== LOBBY_MODE;\n}\n\nexport function makeFrameHeader(options: GameFrameOptions): ComponentType {\n return function GameFrameHeader(): ReactNode {\n const features = useSyncExternalStore(\n options.features.subscribe,\n options.features.list,\n options.features.list,\n );\n if (!useInGame(options.modes)) return null;\n const store = features.some((f) => f.id === \"store\" && f.available);\n return (\n <LobbyHeader\n features={options.features}\n wallet={options.wallet}\n pad={12}\n onStore={store ? () => options.openInLobby(\"store\") : null}\n style={{\n padding: \"8px 12px 8px\",\n background: `linear-gradient(180deg, ${v.bgTop}, ${v.panelDeep})`,\n borderBottom: `2px solid ${v.panelEdge}`,\n }}\n />\n );\n };\n}\n\nexport function makeFrameTabs(options: GameFrameOptions): ComponentType {\n return function GameFrameTabs(): ReactNode {\n const all = useSyncExternalStore(\n options.features.subscribe,\n options.features.list,\n options.features.list,\n );\n const ref = useRef<HTMLDivElement>(null);\n const layout = useLayout(ref);\n const kit = useUiKit();\n const inGame = useInGame(options.modes);\n if (!inGame) return null;\n\n // The lobby's own split; on a wide screen the lobby has a side rail, over a game — the pill.\n const list = arrangeFeatures(\n all.filter((f) => f.available),\n options.lobby,\n );\n const { tabs, more } = splitTabs(\n list,\n layout === \"desktop\" ? \"tablet\" : layout,\n );\n return (\n // The tabs place themselves in the lobby grid's \"tabs\" area — the same one area here.\n <div ref={ref} style={{ display: \"grid\", gridTemplateAreas: '\"tabs\"' }}>\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? {\n badge: more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0),\n counter: more.some((f) => f.id === \"inventory\"),\n }\n : null\n }\n current={PLAY}\n pill={layout !== \"phone\"}\n onPick={(id) => {\n if (id === PLAY) return;\n kit.play(\"tab\");\n options.openInLobby(id);\n }}\n />\n </div>\n );\n };\n}\n"
51
51
  },
52
52
  {
53
53
  "path": "src/base/lobby/i18n.ts",
54
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the lobby. Names of currencies, items and the rest are the publisher's words (title\n// config), and every tab's label is its module's own.\n\nconst EN = {\n lobby: \"Lobby\",\n toLobby: \"To the lobby\",\n play: \"Play\",\n withFriends: \"With friends\",\n more: \"More\",\n profile: \"Profile\",\n playerId: \"Player ID\",\n copyId: \"Copy ID\",\n copied: \"Copied\",\n logout: \"Log out\",\n wallet: \"Wallet\",\n noFeatures:\n \"Nothing to show yet. Tabs appear as the app's modules (shop, heroes, leaderboards…) get their settings on the title.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n lobby: \"Лобби\",\n toLobby: \"В лобби\",\n play: \"Играть\",\n withFriends: \"С друзьями\",\n more: \"Ещё\",\n profile: \"Профиль\",\n playerId: \"ID игрока\",\n copyId: \"Скопировать ID\",\n copied: \"Скопировано\",\n logout: \"Выйти\",\n wallet: \"Кошелёк\",\n noFeatures:\n \"Пока здесь пусто. Вкладки появляются, когда модулям приложения (магазин, герои, лидеры…) заданы настройки в тайтле.\",\n});\n"
54
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the lobby. Names of currencies, items and the rest are the publisher's words (title\n// config), and every tab's label is its module's own.\n\nconst EN = {\n lobby: \"Lobby\",\n play: \"Play\",\n withFriends: \"With friends\",\n more: \"More\",\n profile: \"Profile\",\n playerId: \"Player ID\",\n copyId: \"Copy ID\",\n copied: \"Copied\",\n logout: \"Log out\",\n wallet: \"Wallet\",\n noFeatures:\n \"Nothing to show yet. Tabs appear as the app's modules (shop, heroes, leaderboards…) get their settings on the title.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n lobby: \"Лобби\",\n play: \"Играть\",\n withFriends: \"С друзьями\",\n more: \"Ещё\",\n profile: \"Профиль\",\n playerId: \"ID игрока\",\n copyId: \"Скопировать ID\",\n copied: \"Скопировано\",\n logout: \"Выйти\",\n wallet: \"Кошелёк\",\n noFeatures:\n \"Пока здесь пусто. Вкладки появляются, когда модулям приложения (магазин, герои, лидеры…) заданы настройки в тайтле.\",\n});\n"
55
55
  },
56
56
  {
57
57
  "path": "src/base/lobby/Lobby.tsx",
58
- "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n type Layout,\n ON_TONE,\n outlined,\n ResourceCounter,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\nimport { Account } from \"./Account\";\nimport { WalletButton } from \"./WalletButton\";\nimport { MORE, arrangeFeatures, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby — the app's home. Its tabs are the modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace… and \"Play\" with a button per game); the lobby only lays them\n// out, in three layouts by its width:\n// - phone: balances, the wallet and the profile on top, the screen, 4 tabs + \"More\" at the bottom;\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. A feature the title has nothing for yet (`available: false`) is not\n// shown; remove a module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** `lobby` of src/app.config.ts: tabs to put first / to lift out of \"More\". */\n lobby: AppConfig[\"lobby\"];\n /** Show the wallet button — a web3 title on an EVM network. */\n wallet: boolean;\n /**\n * A screen asked for while the lobby was not on screen (a game's \"Open for friends\"): the base\n * switched to the lobby, the lobby opens it on mount. See `createBaseModule`.\n */\n pending?: { take(): { id: string; args?: Record<string, unknown> } | null };\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const all = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const list = arrangeFeatures(\n all.filter((f) => f.available),\n options.lobby,\n );\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(\n () => options.pending?.take() ?? null,\n );\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n {/* Balances on the left, the wallet and the profile on the right */}\n <div\n style={{\n gridArea: \"header\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={\n list.some((f) => f.id === \"store\")\n ? () => go(\"store\")\n : undefined\n }\n />\n ))}\n </div>\n {options.wallet ? <WalletButton /> : null}\n <Account features={features} />\n </div>\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail\n features={tabs}\n current={current?.id ?? null}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n )}\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nfunction BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: `linear-gradient(180deg, ${v.panelDeep}, ${v.bgBottom})`,\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? `0 3px 0 color-mix(in srgb, ${v.blueDeep} 70%, black), inset 0 2px 0 rgba(255,255,255,.3)`\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n ...(active ? ON_TONE.blue : { color: v.textDim }),\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : `color-mix(in srgb, ${v.text} 5%, transparent)`,\n boxShadow: active\n ? `0 4px 0 color-mix(in srgb, ${v.blueDeep} 70%, black), inset 0 2px 0 rgba(255,255,255,.3)`\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n ...(active ? ON_TONE.blue : { color: v.textDim }),\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: `linear-gradient(180deg, ${v.panelDeep}, ${v.bgBottom})`,\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
58
+ "content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n type Layout,\n ON_TONE,\n outlined,\n ResourceCounter,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\nimport { Account } from \"./Account\";\nimport { WalletButton } from \"./WalletButton\";\nimport { MORE, PLAY, arrangeFeatures, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby — the app's home. Its tabs are the modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace… and \"Play\" with a button per game); the lobby only lays them\n// out, in three layouts by its width:\n// - phone: balances, the wallet and the profile on top, the screen, 4 tabs + \"More\" at the bottom;\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. A feature the title has nothing for yet (`available: false`) is not\n// shown; remove a module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** `lobby` of src/app.config.ts: tabs to put first / to lift out of \"More\". */\n lobby: AppConfig[\"lobby\"];\n /** Show the wallet button — a web3 title on an EVM network. */\n wallet: boolean;\n /**\n * A screen asked for while the lobby was not on screen (a game's \"Open for friends\"): the base\n * switched to the lobby, the lobby opens it on mount. See `createBaseModule`.\n */\n pending?: { take(): { id: string; args?: Record<string, unknown> } | null };\n /**\n * The \"Play\" tab was picked: switch to the game and return true (the game then sits inside the\n * lobby's frame — ./GameFrame.tsx), or false to show the \"Play\" screen (several games).\n */\n onPlay?: () => boolean;\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const all = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const list = arrangeFeatures(\n all.filter((f) => f.available),\n options.lobby,\n );\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(\n () => options.pending?.take() ?? null,\n );\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n const pick = (id: string): void => {\n kit.play(\"tab\");\n if (id === PLAY && options.onPlay?.()) return;\n go(id);\n };\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n <LobbyHeader\n features={features}\n wallet={options.wallet}\n pad={pad}\n onStore={list.some((f) => f.id === \"store\") ? () => go(\"store\") : null}\n style={{ gridArea: \"header\" }}\n />\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail features={tabs} current={current?.id ?? null} onPick={pick} />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={pick}\n />\n )}\n </div>\n );\n}\n\n/** Balances on the left, the wallet and the profile on the right — the lobby's top row, and the\n * same row over a game (./GameFrame.tsx). */\nexport function LobbyHeader({\n features,\n wallet,\n pad,\n onStore,\n style,\n}: {\n features: FeatureRegistry;\n wallet: boolean;\n pad: number;\n /** A tap on a balance opens the shop; null — no shop on this title. */\n onStore: (() => void) | null;\n style?: CSSProperties;\n}): ReactNode {\n const state = useUserState();\n const catalog = useCatalog();\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n ...style,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={onStore ?? undefined}\n />\n ))}\n </div>\n {wallet ? <WalletButton /> : null}\n <Account features={features} />\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nexport function BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: `linear-gradient(180deg, ${v.panelDeep}, ${v.bgBottom})`,\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? `0 3px 0 color-mix(in srgb, ${v.blueDeep} 70%, black), inset 0 2px 0 rgba(255,255,255,.3)`\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n ...(active ? ON_TONE.blue : { color: v.textDim }),\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : `color-mix(in srgb, ${v.text} 5%, transparent)`,\n boxShadow: active\n ? `0 4px 0 color-mix(in srgb, ${v.blueDeep} 70%, black), inset 0 2px 0 rgba(255,255,255,.3)`\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n ...(active ? ON_TONE.blue : { color: v.textDim }),\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: `linear-gradient(180deg, ${v.panelDeep}, ${v.bgBottom})`,\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
59
59
  },
60
60
  {
61
61
  "path": "src/base/lobby/model.ts",
@@ -63,7 +63,7 @@
63
63
  },
64
64
  {
65
65
  "path": "src/base/lobby/PlayScreen.tsx",
66
- "content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n ModeEntry,\n ModeRegistry,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useStagger,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { gameModes } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The \"Play\" tab of the lobby: a button per installed game. One game — one big card; several — a\n// card each. Pressing it switches the host to the game's mode; the \"To the lobby\" button (see\n// BackToLobby.tsx) brings the player back. A game that plays online (registered in ctx.multiplayer)\n// also gets \"With friends\" — its rooms in the multiplayer system — while that system is on the app.\n\nexport function makePlayScreen(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n online: MultiplayerRegistry,\n features: FeatureRegistry,\n): ComponentType<FeatureScreenProps> {\n return function PlayScreen(): ReactNode {\n const list = useSyncExternalStore(modes.subscribe, modes.list, modes.list);\n const onlineGames = useSyncExternalStore(\n online.subscribe,\n online.listGames,\n online.listGames,\n );\n const rooms = useSyncExternalStore(\n features.subscribe,\n () => features.get(\"multiplayer\")?.available === true,\n () => false,\n );\n const games = gameModes(list);\n const stagger = useStagger();\n const kit = useUiKit();\n const start = (mode: ModeEntry): void => {\n kit.play(\"open\");\n navigate(mode.id);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 14,\n gridTemplateColumns:\n games.length > 1\n ? \"repeat(auto-fill, minmax(240px, 1fr))\"\n : \"minmax(0, 1fr)\",\n maxWidth: games.length > 1 ? undefined : 520,\n margin: \"0 auto\",\n }}\n >\n {games.map((game, i) => (\n <div\n key={game.id}\n className=\"idos-shine\"\n style={{\n ...panel,\n ...stagger(i),\n padding: games.length > 1 ? 18 : 26,\n display: \"grid\",\n gap: 14,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon\n glyph={game.icon ?? \"star\"}\n size={games.length > 1 ? 56 : 84}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {game.label}\n </div>\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => start(game)}\n data-tutorial-anchor={`play:${game.id}`}\n >\n ▶ {t(\"play\")}\n </Button>\n {rooms && onlineGames.some((g) => g.mode === game.id) ? (\n <Button\n tone=\"blue\"\n style={{ minWidth: 180 }}\n onClick={() => features.open(\"multiplayer\", { mode: game.id })}\n data-tutorial-anchor={`play-friends:${game.id}`}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"withFriends\")}\n </Button>\n ) : null}\n </div>\n ))}\n {games.length === 0 ? (\n <div style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}>\n {t(\"noFeatures\")}\n </div>\n ) : null}\n </div>\n );\n };\n}\n"
66
+ "content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n ModeEntry,\n ModeRegistry,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useStagger,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { gameModes } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The \"Play\" tab of the lobby: a button per installed game. One game — one big card; several — a\n// card each. Pressing it switches the host to the game's mode — the game then sits inside the lobby's\n// frame, whose tabs bring the player back (GameFrame.tsx). A game that plays online (registered in ctx.multiplayer)\n// also gets \"With friends\" — its rooms in the multiplayer system — while that system is on the app.\n\nexport function makePlayScreen(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n online: MultiplayerRegistry,\n features: FeatureRegistry,\n): ComponentType<FeatureScreenProps> {\n return function PlayScreen(): ReactNode {\n const list = useSyncExternalStore(modes.subscribe, modes.list, modes.list);\n const onlineGames = useSyncExternalStore(\n online.subscribe,\n online.listGames,\n online.listGames,\n );\n const rooms = useSyncExternalStore(\n features.subscribe,\n () => features.get(\"multiplayer\")?.available === true,\n () => false,\n );\n const games = gameModes(list);\n const stagger = useStagger();\n const kit = useUiKit();\n const start = (mode: ModeEntry): void => {\n kit.play(\"open\");\n navigate(mode.id);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 14,\n gridTemplateColumns:\n games.length > 1\n ? \"repeat(auto-fill, minmax(240px, 1fr))\"\n : \"minmax(0, 1fr)\",\n maxWidth: games.length > 1 ? undefined : 520,\n margin: \"0 auto\",\n }}\n >\n {games.map((game, i) => (\n <div\n key={game.id}\n className=\"idos-shine\"\n style={{\n ...panel,\n ...stagger(i),\n padding: games.length > 1 ? 18 : 26,\n display: \"grid\",\n gap: 14,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon\n glyph={game.icon ?? \"star\"}\n size={games.length > 1 ? 56 : 84}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {game.label}\n </div>\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => start(game)}\n data-tutorial-anchor={`play:${game.id}`}\n >\n ▶ {t(\"play\")}\n </Button>\n {rooms && onlineGames.some((g) => g.mode === game.id) ? (\n <Button\n tone=\"blue\"\n style={{ minWidth: 180 }}\n onClick={() => features.open(\"multiplayer\", { mode: game.id })}\n data-tutorial-anchor={`play-friends:${game.id}`}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"withFriends\")}\n </Button>\n ) : null}\n </div>\n ))}\n {games.length === 0 ? (\n <div style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}>\n {t(\"noFeatures\")}\n </div>\n ) : null}\n </div>\n );\n };\n}\n"
67
67
  },
68
68
  {
69
69
  "path": "src/base/lobby/WalletButton.tsx",
@@ -87,7 +87,7 @@
87
87
  },
88
88
  {
89
89
  "path": "src/base/module.ts",
90
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport appConfig from \"../app.config\";\nimport { IDOS_WEB3, IDOS_WEB3_NETWORK_ID } from \"../idos.title\";\nimport { LOBBY_MODE, type AppConfig } from \"./app-config\";\nimport { makeLobbyPanel } from \"./lobby/Lobby\";\nimport { makePlayScreen } from \"./lobby/PlayScreen\";\nimport { makeBackToLobby } from \"./lobby/BackToLobby\";\nimport { PLAY, gameModes, hasWallet } from \"./lobby/model\";\nimport { t } from \"./lobby/i18n\";\n\n// The base of every app on the platform — the lobby, the home the player lands on after sign-in (the\n// sign-in itself is ./login, rendered by the host before any module runs). It is written against the\n// same module contract as everything in src/modules/, and main.tsx installs it first.\n//\n// - The lobby lays out the features of the installed modules (the shop, heroes, leaders…) as tabs,\n// with the balances, the wallet and the profile on top.\n// - Every game module's mode gets a \"Play\" button (the \"Play\" tab); over a game the base draws only\n// a small button back to the lobby.\n// - It takes the shared-UI roles it draws for everybody, so games hide their own balances and wallet,\n// and the host draws no nav, ☰ or \"Log out\" of its own.\n//\n// What happens after sign-in and the order of the tabs: ../app.config.ts.\nexport const baseModule: Module = createBaseModule(appConfig, {\n web3: IDOS_WEB3,\n networkID: IDOS_WEB3_NETWORK_ID,\n});\n\n/** The web3 identity of the project (src/idos.title.ts) — whether the lobby offers the wallet. */\nexport interface BaseWeb3 {\n web3: boolean;\n networkID: string;\n}\n\n/** The base for a given app.config — the project uses `baseModule`; a test harness makes its own. */\nexport function createBaseModule(\n config: AppConfig,\n identity: BaseWeb3,\n): Module {\n return defineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n ctx.registerRoute({\n id: LOBBY_MODE,\n label: t(\"lobby\"),\n icon: \"🏠\",\n order: -1000,\n });\n\n // The wallet lives next to the balances — for any web3 title (EVM or Solana).\n const wallet = hasWallet(identity);\n\n // `features.open(id)` from a game (the lobby is not on screen, its own presenter is gone): switch\n // to the lobby and let it open the screen on mount. While the lobby is shown, its presenter —\n // registered later — handles the request first.\n let pending: { id: string; args?: Record<string, unknown> } | null = null;\n ctx.features.setPresenter((id, args) => {\n pending = { id, args };\n ctx.navigate(LOBBY_MODE);\n return true;\n });\n\n ctx.registerPanel({\n id: \"lobby\",\n slot: \"overlay\",\n component: makeLobbyPanel({\n features: ctx.features,\n lobby: config.lobby,\n wallet,\n pending: {\n take: () => {\n const next = pending;\n pending = null;\n return next;\n },\n },\n }),\n });\n ctx.registerPanel({\n id: \"back\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBackToLobby(ctx.modes, ctx.navigate),\n });\n\n // \"Play\" — only while there is something to play. The modes are known once every module is\n // installed, so this follows the list instead of reading it here.\n const PlayScreen = makePlayScreen(\n ctx.modes,\n ctx.navigate,\n ctx.multiplayer,\n ctx.features,\n );\n let offPlay: (() => void) | null = null;\n const syncPlay = (): void => {\n const hasGames = gameModes(ctx.modes.list()).length > 0;\n if (hasGames && !offPlay)\n offPlay = ctx.features.register({\n id: PLAY,\n label: t(\"play\"),\n icon: \"star\",\n group: \"events\",\n order: 0,\n primary: true,\n Screen: PlayScreen,\n });\n else if (!hasGames && offPlay) {\n offPlay();\n offPlay = null;\n }\n };\n ctx.modes.subscribe(syncPlay);\n\n // app.config.ts `start`: a game's mode id starts straight in it (the host applies it on its first\n // render — no flash of the lobby). An id no module registered is ignored by the host.\n if (config.start !== LOBBY_MODE) ctx.navigate(config.start);\n\n // The lobby is ordinary React in the DOM: an agent reads it as a tree and presses its buttons.\n // What is not on screen goes here — above all, why a tab is missing.\n ctx.exposeToAgent({\n state: () => ({\n mode: ctx.modes.current(),\n start: config.start,\n games: gameModes(ctx.modes.list()).map((m) => m.id),\n wallet,\n features: ctx.features.list().map((f) => ({\n id: f.id,\n module: f.moduleId,\n available: f.available,\n ...(f.unavailableReason\n ? { hiddenBecause: f.unavailableReason }\n : {}),\n badge: f.badge,\n })),\n }),\n actions: {\n go: (args) => ctx.navigate(String(args?.mode ?? LOBBY_MODE)),\n open: (args) => ctx.features.open(String(args?.feature ?? \"\")),\n },\n describeActions: {\n go: 'go({ mode }) — switch to a mode: \"lobby\" or a game\\'s mode id',\n open: 'open({ feature }) — open a tab by feature id (\"store\", \"character\"…); false if it is hidden',\n },\n });\n },\n });\n}\n"
90
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport appConfig from \"../app.config\";\nimport { IDOS_WEB3, IDOS_WEB3_NETWORK_ID } from \"../idos.title\";\nimport { GAME_START, LOBBY_MODE, type AppConfig } from \"./app-config\";\nimport { makeLobbyPanel } from \"./lobby/Lobby\";\nimport { makePlayScreen } from \"./lobby/PlayScreen\";\nimport { makeFrameHeader, makeFrameTabs } from \"./lobby/GameFrame\";\nimport { PLAY, gameModes, hasWallet } from \"./lobby/model\";\nimport { t } from \"./lobby/i18n\";\n\n// The base of every app on the platform — the lobby, the home the player lands on after sign-in (the\n// sign-in itself is ./login, rendered by the host before any module runs). It is written against the\n// same module contract as everything in src/modules/, and main.tsx installs it first.\n//\n// - The lobby lays out the features of the installed modules (the shop, heroes, leaders…) as tabs,\n// with the balances, the wallet and the profile on top.\n// - \"Play\" is the game: the game sits inside the lobby — over it the base keeps the balances on top\n// and the tabs at the bottom (./lobby/GameFrame.tsx), any other tab opens that screen. Several\n// games — the \"Play\" tab lists them.\n// - It takes the shared-UI roles it draws for everybody, so games hide their own balances and wallet,\n// and the host draws no nav, ☰ or \"Log out\" of its own.\n//\n// What happens after sign-in and the order of the tabs: ../app.config.ts.\nexport const baseModule: Module = createBaseModule(appConfig, {\n web3: IDOS_WEB3,\n networkID: IDOS_WEB3_NETWORK_ID,\n});\n\n/** The web3 identity of the project (src/idos.title.ts) — whether the lobby offers the wallet. */\nexport interface BaseWeb3 {\n web3: boolean;\n networkID: string;\n}\n\n/** The base for a given app.config — the project uses `baseModule`; a test harness makes its own. */\nexport function createBaseModule(\n config: AppConfig,\n identity: BaseWeb3,\n): Module {\n return defineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n ctx.registerRoute({\n id: LOBBY_MODE,\n label: t(\"lobby\"),\n icon: \"🏠\",\n order: -1000,\n });\n\n // The wallet lives next to the balances — for any web3 title (EVM or Solana).\n const wallet = hasWallet(identity);\n\n // `features.open(id)` from a game (the lobby is not on screen, its own presenter is gone): switch\n // to the lobby and let it open the screen on mount. While the lobby is shown, its presenter —\n // registered later — handles the request first.\n let pending: { id: string; args?: Record<string, unknown> } | null = null;\n ctx.features.setPresenter((id, args) => {\n pending = { id, args };\n ctx.navigate(LOBBY_MODE);\n return true;\n });\n\n // \"Play\" in the lobby with ONE game goes straight into it (the game sits inside the lobby's\n // frame below); with several, the \"Play\" screen lists them.\n const openGame = (): boolean => {\n const games = gameModes(ctx.modes.list());\n const only = games.length === 1 ? games[0] : undefined;\n if (!only) return false;\n ctx.navigate(only.id);\n return true;\n };\n\n ctx.registerPanel({\n id: \"lobby\",\n slot: \"overlay\",\n component: makeLobbyPanel({\n features: ctx.features,\n lobby: config.lobby,\n wallet,\n onPlay: openGame,\n pending: {\n take: () => {\n const next = pending;\n pending = null;\n return next;\n },\n },\n }),\n });\n\n // The game inside the lobby: over a game the base keeps the lobby's balances on top and its tabs\n // at the bottom (the host's frame slots — the game is laid out between them).\n const frame = {\n features: ctx.features,\n modes: ctx.modes,\n lobby: config.lobby,\n wallet,\n openInLobby: (id: string) => {\n pending = { id };\n ctx.navigate(LOBBY_MODE);\n },\n };\n ctx.registerPanel({\n id: \"frame-header\",\n slot: \"header\",\n activeOnly: false,\n component: makeFrameHeader(frame),\n });\n ctx.registerPanel({\n id: \"frame-tabs\",\n slot: \"footer\",\n activeOnly: false,\n component: makeFrameTabs(frame),\n });\n\n // \"Play\" — only while there is something to play. The modes are known once every module is\n // installed, so this follows the list instead of reading it here.\n const PlayScreen = makePlayScreen(\n ctx.modes,\n ctx.navigate,\n ctx.multiplayer,\n ctx.features,\n );\n let offPlay: (() => void) | null = null;\n const syncPlay = (): void => {\n const hasGames = gameModes(ctx.modes.list()).length > 0;\n if (hasGames && !offPlay)\n offPlay = ctx.features.register({\n id: PLAY,\n label: t(\"play\"),\n icon: \"star\",\n group: \"events\",\n order: 0,\n primary: true,\n Screen: PlayScreen,\n });\n else if (!hasGames && offPlay) {\n offPlay();\n offPlay = null;\n }\n };\n ctx.modes.subscribe(syncPlay);\n\n // app.config.ts `start`: a game's mode id starts straight in it (the host applies it on its first\n // render — no flash of the lobby). An id no module registered is ignored by the host.\n // \"game\" — the first game: the list is filled once every module is installed, so this waits\n // for it and goes once, and only while nothing is on screen yet (the host has not shown the app).\n if (config.start === GAME_START) {\n let started = false;\n ctx.modes.subscribe(() => {\n const first = gameModes(ctx.modes.list())[0];\n if (started || !first) return;\n started = true;\n if (ctx.modes.current() === null) ctx.navigate(first.id);\n });\n } else if (config.start !== LOBBY_MODE) ctx.navigate(config.start);\n\n // The lobby is ordinary React in the DOM: an agent reads it as a tree and presses its buttons.\n // What is not on screen goes here — above all, why a tab is missing.\n ctx.exposeToAgent({\n state: () => ({\n mode: ctx.modes.current(),\n start: config.start,\n games: gameModes(ctx.modes.list()).map((m) => m.id),\n wallet,\n features: ctx.features.list().map((f) => ({\n id: f.id,\n module: f.moduleId,\n available: f.available,\n ...(f.unavailableReason\n ? { hiddenBecause: f.unavailableReason }\n : {}),\n badge: f.badge,\n })),\n }),\n actions: {\n go: (args) => ctx.navigate(String(args?.mode ?? LOBBY_MODE)),\n open: (args) => ctx.features.open(String(args?.feature ?? \"\")),\n },\n describeActions: {\n go: 'go({ mode }) — switch to a mode: \"lobby\" or a game\\'s mode id',\n open: 'open({ feature }) — open a tab by feature id (\"store\", \"character\"…); false if it is hidden',\n },\n });\n },\n });\n}\n"
91
91
  },
92
92
  {
93
93
  "path": "src/config.ts",