@idosgames/mcp 0.1.17 → 0.1.19

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 (38) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +74 -22
  4. package/registry/index.json +761 -115
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/character.json +75 -0
  7. package/registry/modules/chat.json +71 -0
  8. package/registry/modules/craft.json +71 -0
  9. package/registry/modules/deals.json +70 -0
  10. package/registry/modules/events.json +77 -0
  11. package/registry/modules/friends.json +70 -0
  12. package/registry/modules/idle-rpg.json +8 -8
  13. package/registry/modules/inventory.json +70 -0
  14. package/registry/modules/invite.json +70 -0
  15. package/registry/modules/leaderboard.json +72 -0
  16. package/registry/modules/marketplace.json +72 -0
  17. package/registry/modules/mini-game.json +69 -0
  18. package/registry/modules/profile.json +71 -0
  19. package/registry/modules/pvp.json +69 -0
  20. package/registry/modules/rewards.json +72 -0
  21. package/registry/modules/season.json +76 -0
  22. package/registry/modules/store.json +72 -0
  23. package/registry/modules/tutorials.json +68 -0
  24. package/registry/modules/voxelcraft.json +65 -464
  25. package/registry/modules/workshop.json +13 -8
  26. package/registry/skills/dev-test-loop.json +1 -1
  27. package/registry/skills/idosgames-compose-modules.json +2 -2
  28. package/registry/skills/idosgames-getting-started.json +2 -2
  29. package/registry/skills/idosgames-module-contract.json +2 -2
  30. package/registry/skills/idosgames-project-structure.json +1 -1
  31. package/registry/skills/idosgames-ui-kit.json +6 -0
  32. package/registry/skills/leaderboard-system.json +1 -1
  33. package/registry/skills/multiplayer-realtime.json +3 -3
  34. package/registry/skills/push-notifications.json +1 -1
  35. package/registry/skills/referral-system.json +2 -2
  36. package/registry/skills/timed-event-system.json +1 -1
  37. package/registry/skills/workshop-system.json +1 -1
  38. package/registry/modules/game-hud.json +0 -99
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "host-starter",
2
+ "id": "base",
3
3
  "files": [
4
4
  {
5
5
  "path": "AGENTS.md",
@@ -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\nA **host shell + composable modules** app on the iDosGames SDK.\n\n- `src/main.tsx` — the host: creates ONE `IDosGamesClient` and calls `mountHost(...)`. The host signs\n the player in (login screen included) before any module runs; modules never create a client or\n log in themselves.\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.\n- `src/modules/<id>/` — one folder per module: ready-made catalog templates/features and the game's\n own modules alike. With two or more modules the host shows a bottom nav, one tab per module.\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/LoginScreen.tsx` — the login screen; restyle it freely.\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 → install that module instead of rebuilding it.\n- It extends an existing module's gameplay → change that module.\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`.\n- Balances, wallet, status line, Log out / player ID shown in every mode → never rebuilt per module:\n install `game-hud` (or change it). Templates hide their own copies by themselves\n (`ctx.sharedUi.shouldDraw`) — don't edit them for that. Other UI for every mode → a panel with\n `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 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: `src/ui.config.ts`.\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,47 +23,99 @@
23
23
  },
24
24
  {
25
25
  "path": "package.json",
26
- "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into 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.5.0\",\n \"@idosgames/core\": \"0.18.0\",\n \"@idosgames/module-sdk\": \"0.4.1\",\n \"@idosgames/react\": \"0.2.11\",\n \"@idosgames/wallet\": \"0.5.0\",\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.7.0\",\n \"@idosgames/core\": \"0.19.0\",\n \"@idosgames/module-sdk\": \"0.6.0\",\n \"@idosgames/react\": \"0.3.0\",\n \"@idosgames/wallet\": \"0.5.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",
30
30
  "content": "// Service worker for push notifications. This file is YOURS to edit — it ships as a starting\n// point, not as SDK code.\n//\n// ⚠⚠ THERE IS NO `fetch` HANDLER HERE, AND THERE MUST NEVER BE ONE.\n//\n// A caching service worker looks like a free win and is the single most expensive mistake you can\n// make on this platform. A cached `index.html` points at content-hashed chunks that the NEXT\n// deploy deletes; the player then gets a white screen served from INSIDE their own browser, where\n// neither a CDN purge nor Ctrl+F5 reaches it. The platform has already paid for that bug once, in\n// the publisher dashboard, and the fix was to stop caching HTML at all. A `fetch` handler here\n// would bring it back in a form that is much harder to undo. If you want offline support, do it\n// deliberately and never cache the document.\n//\n// ⚠ Registered RELATIVE to the page (`./sw.js`). A hosted build runs on the title's own origin,\n// `{titleid}.idos.games`, so the live game's worker covers that whole origin — and nothing else:\n// no other game lives there. A pinned version (`/v/{buildId}/`, the DEV preview) gets a worker\n// scoped to its own folder. On the legacy shared `cloud.idosgames.com` the relative path is what\n// kept each game in `/drive/app/{titleID}/`; do not add `Service-Worker-Allowed` to widen it.\n\nself.addEventListener(\"install\", (event) => {\n // Take over immediately. Without this the new worker waits for every tab of this game to close,\n // and a player with the game open all day would keep the old one for days.\n event.waitUntil(self.skipWaiting());\n});\n\nself.addEventListener(\"activate\", (event) => {\n event.waitUntil(self.clients.claim());\n});\n\n/**\n * A notification arrived.\n *\n * ⚠ `showNotification` is NOT optional. The subscription was created with `userVisibleOnly: true`,\n * which is a promise to display every message; break it and the browser first shows its own\n * \"this site is sending background notifications\" warning, then revokes the permission.\n *\n * The payload is the small JSON the server encrypts per device: `{ title, body?, url?, icon? }`.\n * The text is already resolved into the device's language — do not translate it here.\n */\nself.addEventListener(\"push\", (event) => {\n let payload = {};\n try {\n payload = event.data ? event.data.json() : {};\n } catch {\n // Never let a malformed payload swallow the notification: the promise above still stands.\n }\n\n const title = payload.title || \"\";\n if (!title) return; // An empty notification is a blank grey box. Better to show nothing.\n\n event.waitUntil(\n self.registration.showNotification(title, {\n body: payload.body || undefined,\n icon: payload.icon || undefined,\n // Keeps a repeated notification from stacking into a pile the player has to dismiss one by\n // one. Give the server-side producer a stable key per kind of message.\n tag: payload.tag || undefined,\n data: { url: payload.url || \"./\" },\n }),\n );\n});\n\n/**\n * Where a click is allowed to take the player.\n *\n * The URL arrives IN THE PAYLOAD, and for game events the server takes it from the TITLE CONFIG.\n * Anything outside this worker's own scope is refused: a notification shown under your game's\n * name and icon must not be able to send the tab to an arbitrary site, which is exactly what a\n * phishing notification is for (\"your session expired, sign in again\").\n *\n * A refused URL does not cancel the click — the player pressed it, and opening the game is more\n * honest than doing nothing, which reads as a broken notification.\n */\nfunction safeTarget(raw) {\n const scope = self.registration.scope;\n\n try {\n const url = new URL(raw || scope, scope);\n return url.href.startsWith(scope) ? url.href : scope;\n } catch {\n return scope;\n }\n}\n\n/**\n * The player tapped the notification.\n *\n * Focuses an already-open tab of this game instead of opening a second one — two copies of the\n * same game in two tabs is its own kind of bug.\n */\nself.addEventListener(\"notificationclick\", (event) => {\n event.notification.close();\n\n const target = safeTarget(\n event.notification.data && event.notification.data.url,\n );\n\n event.waitUntil(\n self.clients\n .matchAll({ type: \"window\", includeUncontrolled: true })\n .then((clients) => {\n for (const client of clients) {\n if (client.url === target && \"focus\" in client) return client.focus();\n }\n return self.clients.openWindow\n ? self.clients.openWindow(target)\n : undefined;\n }),\n );\n});\n"
31
31
  },
32
32
  {
33
- "path": "src/config.ts",
34
- "content": "// Which Title this app talks to, and in which environment. Always targets the real backend\n// (https://api.idosgames.com).\n//\n// ⚠ PLATFORM-OWNED. The platform's build packs its own copy of this file over the project's (the\n// same way it regenerates src/idos.title.ts), so an edit here never reaches a published game.\n//\n// Neither IDENTITY nor ENVIRONMENT is read from the link (query or path) — that is the point. A link\n// is something anyone can edit: when `?titleID=` could pick the title, another publisher could open\n// this finished game with their own title in the link and run it on their title's settings.\n//\n// identity — WHICH game. Resolved once, in this order:\n// 1. src/idos.title.ts — the centralized identity file (generated by the platform, or filled\n// by hand on a manual scaffold). Wins over everything, and is the only\n// channel a packaged mobile build has.\n// 2. the host — {id}.idos.games / {id}-dev.idos.games, the title's own addresses. The\n// platform's game host serves those names with that title's files only.\n// 3. VITE_IDOS_TITLE_ID — local development, via .env.local.\n// There is deliberately NO fallback title. A build that cannot tell which game it is must fail\n// loudly rather than quietly sign in to somebody else's data.\n//\n// environment — WHICH copy of that game, prod or dev. A modifier on the identity, never a\n// different identity: the dev title is always `{id}-DEV`. Resolved in this order:\n// 1. the host — {id}-dev.idos.games is dev, {id}.idos.games is prod. That is how one\n// artifact runs on both without a rebuild.\n// 2. the live preview — the AI Coder's in-browser preview marks its sandbox as dev before\n// any project code runs (it lives on a foreign origin, so the host\n// cannot say it).\n// 3. VITE_IDOS_ENV — local development, via .env.local (\"dev\" or \"prod\").\n// 4. IDOS_DEFAULT_ENV — the baked default from src/idos.title.ts.\n\nimport { IDOS_BUILD_KEY, IDOS_DEFAULT_ENV, IDOS_TITLE_ID } from \"./idos.title\";\n\n/** Mirrors DevTitleService.DevSuffix on the backend. */\nconst DEV_SUFFIX = \"-DEV\";\n\n// Build-time values straight from the __IDOS_ENV__ global (see vite.config.ts), not through\n// ./env: this file is packed over the project's own at build time, so it must not depend on what\n// the project's other files export.\ntype BuildEnv = { TITLE_ID?: string; BUILD_KEY?: string; ENV?: string };\ndeclare const __IDOS_ENV__: BuildEnv | undefined;\nconst buildEnv: BuildEnv =\n typeof __IDOS_ENV__ !== \"undefined\" && __IDOS_ENV__ ? __IDOS_ENV__ : {};\n\n/**\n * The title's own addresses: `{titleid}.idos.games` (prod) and `{titleid}-dev.idos.games` (dev).\n * Title ids are 8 characters of [A-Z0-9] and hostnames are case-insensitive, so the lowercase id IS\n * the address. Mirrors the platform's game-host Worker (TitleHostWorker/src/host.ts). A publisher's\n * own domain carries no id — that is what idos.title.ts is for.\n */\nfunction fromHost(): { titleId: string; dev: boolean } | null {\n if (typeof window === \"undefined\") return null;\n const match = window.location.hostname\n .toLowerCase()\n .replace(/\\.$/, \"\")\n .match(/^([a-z0-9]{8})(-dev)?\\.idos\\.games$/);\n const id = match?.[1];\n return id\n ? { titleId: id.toUpperCase(), dev: match?.[2] !== undefined }\n : null;\n}\n\n/**\n * Set by the AI Coder dashboard in its preview's entry point, before this file runs. Nothing\n * outside the dashboard can set it on a hosted game: that page is served from the title's own\n * origin, and a script from anywhere else cannot reach its globals.\n */\nfunction previewEnv(): string | null {\n const marker = (globalThis as { __IDOS_PREVIEW__?: { env?: unknown } })\n .__IDOS_PREVIEW__;\n return typeof marker?.env === \"string\" ? marker.env : null;\n}\n\nconst host = fromHost();\nconst envTitle = buildEnv.TITLE_ID ?? \"\";\nconst envTitleIsDev = envTitle.endsWith(DEV_SUFFIX);\n\nconst canonicalTitle =\n IDOS_TITLE_ID ||\n host?.titleId ||\n (envTitleIsDev ? envTitle.slice(0, -DEV_SUFFIX.length) : envTitle);\n\nif (!canonicalTitle) {\n throw new Error(\n \"[idos] No Title id resolved. Expected src/idos.title.ts (generated by the platform), \" +\n \"a {titleid}.idos.games host, or VITE_IDOS_TITLE_ID in .env.local.\",\n );\n}\n\nfunction resolveIsDev(): boolean {\n if (host) return host.dev;\n const preview = previewEnv();\n if (preview !== null) return preview === \"dev\";\n if (buildEnv.ENV === \"dev\" || buildEnv.ENV === \"prod\")\n return buildEnv.ENV === \"dev\";\n if (envTitleIsDev) return true;\n return IDOS_DEFAULT_ENV === \"dev\";\n}\n\nexport const TITLE_ID = resolveIsDev()\n ? `${canonicalTitle}${DEV_SUFFIX}`\n : canonicalTitle;\n\nexport const BUILD_KEY = IDOS_BUILD_KEY || buildEnv.BUILD_KEY || \"\";\n"
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"
35
35
  },
36
36
  {
37
- "path": "src/env.ts",
38
- "content": "// Env совместимое СРАЗУ с двумя сборщиками:\n// 1. настоящий vite (standalone-dev, прод, контейнер-билд build-runner) — значения приезжают из\n// vite.config через define в глобал __IDOS_ENV__ (см. vite.config.ts);\n// 2. классический бандлер превью (Sandpack) — он падает на самом токене `import.meta`, поэтому\n// его здесь быть не должно. Глобала __IDOS_ENV__ у него нет → `typeof` вернёт \"undefined\",\n// берём дефолты.\ntype IdosEnv = {\n DEV?: boolean;\n TITLE_ID?: string;\n BUILD_KEY?: string;\n WALLETCONNECT_PROJECT_ID?: string;\n GOOGLE_CLIENT_ID?: string;\n};\n\ndeclare const __IDOS_ENV__: IdosEnv | undefined;\n\nconst env: IdosEnv =\n typeof __IDOS_ENV__ !== \"undefined\" && __IDOS_ENV__ ? __IDOS_ENV__ : {};\n\nexport const IS_DEV = env.DEV ?? false;\nexport const ENV_TITLE_ID = env.TITLE_ID ?? \"\";\nexport const ENV_BUILD_KEY = env.BUILD_KEY ?? \"\";\nexport const ENV_WALLETCONNECT_PROJECT_ID = env.WALLETCONNECT_PROJECT_ID ?? \"\";\n\n/** Google Identity client id. Empty = the login screen hides the Google button rather than\n * offering one that cannot work (a Google sign-in needs a real ID token). */\nexport const ENV_GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID ?? \"\";\n"
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"
39
39
  },
40
40
  {
41
- "path": "src/idos.title.ts",
42
- "content": "// The project's IDENTITY file — the ONE centralized place the Title id lives.\n//\n// On platform-created projects the platform GENERATES this file when it creates the project; there\n// the AI editor is denied write access to this path on purpose — do not edit it by hand. On a\n// manually scaffolded project (get_host_scaffold / copied template) there is no generator: YOU fill\n// IDOS_TITLE_ID here yourself. Either way, do not import anything into this file, and do not bind\n// the title anywhere else (.env.local is a local-dev fallback for the raw template only).\n//\n// It is the highest-priority source of the Title id (see config.ts for the full chain). It is baked\n// into the bundle rather than read from the URL because a build has to keep working where there is\n// no URL to read — packaged as a mobile app, embedded in an iframe, or opened from a shared link\n// that dropped its query string.\n//\n// It holds the CANONICAL (production) title. The DEV title is derived from it, never stored here —\n// one project has one identity, and DEV/PROD is an environment on top of that identity.\n\n/** Canonical (production) Title id. Empty only in the raw template, before the platform seeds it. */\nexport const IDOS_TITLE_ID = \"\";\n\n/** Build key for this title, if the title enforces one. */\nexport const IDOS_BUILD_KEY = \"\";\n\n/** Environment this artifact defaults to. Web builds may override it (see config.ts); a packaged\n * mobile build cannot, so a DEV app is produced by generating this file with \"dev\". */\nexport const IDOS_DEFAULT_ENV: \"prod\" | \"dev\" = \"prod\";\n\n/**\n * Whether this title was created as web3. Set by the platform from the same toggle the publisher\n * used at title creation.\n *\n * It no longer gates the login screen — wallet sign-in is always offered (see walletLogin.tsx).\n * Kept as baked metadata for code that wants to know the title's web3 origin BEFORE there is a\n * session (`client.title.getTitlePublicConfiguration()` requires one).\n */\nexport const IDOS_WEB3 = false;\n\n/**\n * NetworkID the wallet login challenge is issued for (e.g. \"bsc\", \"base\", \"solana\"). Empty on\n * web2 titles. Baked for the same reason as everything else here: the login screen needs it before\n * there is a session, and the title's blockchain config is only readable once logged in.\n */\nexport const IDOS_WEB3_NETWORK_ID = \"\";\n\n/**\n * WalletConnect Cloud / Reown project id (dashboard.reown.com). Enables MOBILE wallet login via\n * WalletConnect (QR on desktop, deep-link on phones); without it only browser-extension wallets\n * are offered and mobile login is unavailable. Set per-title in the blockchain config\n * (Blockchain.WalletLogin.WalletConnectProjectId) — the platform bakes it here. Baked, not read at\n * runtime, for the same reason as IDOS_WEB3_NETWORK_ID: the login screen runs before there is a\n * session, and the title's blockchain config is only readable once logged in.\n */\nexport const IDOS_WEB3_WALLETCONNECT_PROJECT_ID = \"\";\n"
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"
43
+ },
44
+ {
45
+ "path": "src/base/lobby/Account.tsx",
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 Popup,\n outlined,\n useGameLayout,\n useUiKit,\n v,\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 border: `2px solid ${v.panelEdge}`,\n background: \"rgba(8, 10, 40, .6)\",\n boxShadow: \"inset 0 2px 4px rgba(0,0,0,.4)\",\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n};\n"
47
+ },
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"
51
+ },
52
+ {
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 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 more: \"Ещё\",\n profile: \"Профиль\",\n playerId: \"ID игрока\",\n copyId: \"Скопировать ID\",\n copied: \"Скопировано\",\n logout: \"Выйти\",\n wallet: \"Кошелёк\",\n noFeatures:\n \"Пока здесь пусто. Вкладки появляются, когда модулям приложения (магазин, герои, лидеры…) заданы настройки в тайтле.\",\n});\n"
55
+ },
56
+ {
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 ResourceCounter,\n outlined,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n type Layout,\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\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>(null);\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, #1b2063, #10133d)\",\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 #164f94, 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 color: active ? v.text : 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 : \"rgba(255,255,255,.04)\",\n boxShadow: active\n ? \"0 4px 0 #164f94, 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 color: active ? v.text : 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, #1b2063, #10133d)\",\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
+ },
60
+ {
61
+ "path": "src/base/lobby/model.ts",
62
+ "content": "// Pure helpers of the lobby — covered by model.test.ts.\n\nimport type { ModeEntry } from \"@idosgames/module-sdk\";\nimport {\n sortFeatures,\n type FeatureMenuItem,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\n\n/** The pseudo-feature \"More\" — the rest of the features, as a grid. */\nexport const MORE = \"__more\";\n\n/** The lobby's own feature: the \"Play\" tab with a button per game. */\nexport const PLAY = \"play\";\n\nexport interface LobbyFeature extends FeatureMenuItem {\n primary?: boolean;\n}\n\n/** How many tabs fit next to \"More\" at this width (the desktop rail lists everything). */\nexport function tabSlots(layout: Layout): number {\n return layout === \"phone\"\n ? 5\n : layout === \"tablet\"\n ? 7\n : Number.POSITIVE_INFINITY;\n}\n\n/**\n * Applies app.config.ts on top of what the modules declared: the features named in `order` come\n * first, in that order, as main tabs; those in `primary` become main tabs too. Everything else keeps\n * the module's own `primary` / `order`.\n */\nexport function arrangeFeatures<T extends LobbyFeature>(\n features: readonly T[],\n lobby: AppConfig[\"lobby\"],\n): T[] {\n return features.map((f) => {\n const i = lobby.order.indexOf(f.id);\n if (i >= 0) return { ...f, primary: true, order: -1000 + i };\n if (lobby.primary.includes(f.id)) return { ...f, primary: true };\n return f;\n });\n}\n\n/**\n * Splits the game's features into the lobby's tabs and \"More\": main (primary) features by their\n * order take the tab slots first, secondary ones fill what is left; the rest goes under \"More\".\n * \"More\" appears only when it has something — an app with three screens has three tabs.\n */\nexport function splitTabs<T extends LobbyFeature>(\n features: readonly T[],\n layout: Layout,\n): { tabs: T[]; more: T[] } {\n const primary = [...features]\n .filter((f) => f.primary)\n .sort(\n (a, b) =>\n (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label),\n );\n const secondary = sortFeatures(features.filter((f) => !f.primary));\n const ordered = [...primary, ...secondary];\n const slots = tabSlots(layout);\n if (ordered.length <= slots) return { tabs: ordered, more: [] };\n // One slot goes to \"More\" itself; main features claim the rest first.\n const room = Math.max(1, slots - 1);\n return { tabs: ordered.slice(0, room), more: ordered.slice(room) };\n}\n\n/** The modes the lobby offers to play: those of `game` modules, in the host's order. */\nexport function gameModes(modes: readonly ModeEntry[]): ModeEntry[] {\n return modes.filter((m) => m.moduleType === \"game\");\n}\n\n/** Whether the title's blockchain network is EVM — the only kind the in-app wallet panel serves. */\nexport function isEvmNetwork(\n blockchain:\n | { Networks?: Record<string, { Type?: string | null } | null> | null }\n | undefined,\n networkID: string,\n): boolean {\n if (!networkID) return false;\n const network = blockchain?.Networks?.[networkID];\n return (network?.Type ?? \"EVM\") !== \"Solana\";\n}\n"
63
+ },
64
+ {
65
+ "path": "src/base/lobby/PlayScreen.tsx",
66
+ "content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureScreenProps,\n ModeEntry,\n ModeRegistry,\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.\n\nexport function makePlayScreen(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n): ComponentType<FeatureScreenProps> {\n return function PlayScreen(): ReactNode {\n const list = useSyncExternalStore(modes.subscribe, modes.list, modes.list);\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, #5a3fd0 0%, #25207a 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 </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
+ },
68
+ {
69
+ "path": "src/base/lobby/WalletButton.tsx",
70
+ "content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport { LazyWalletPanel } from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Icon,\n Popup,\n outlined,\n useGameLayout,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The app's one wallet entry (deposit / withdraw), next to the balances in the lobby header. Shown\n// only on a web3 title whose network is EVM — the panel serves EVM (see lobby/model.ts isEvmNetwork).\n// Inside the platform's iframe the panel refuses by itself: there crypto goes through the site's\n// wallet card, never the app's own code.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls Reown\n// AppKit into the startup graph. The wallet config is memoised per project id, so a wallet connected\n// on the sign-in screen is already connected here.\n\nexport function WalletButton(): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [open, setOpen] = useState(false);\n\n return (\n <>\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"wallet\")}\n onClick={() => {\n kit.play(\"click\");\n setOpen(true);\n }}\n style={chip}\n data-tutorial-anchor=\"lobby:wallet\"\n >\n <Icon glyph=\"👛\" size={layout === \"phone\" ? 26 : 22} />\n {layout !== \"phone\" ? <span>{t(\"wallet\")}</span> : null}\n </button>\n {open ? (\n <Popup title={t(\"wallet\")} onClose={() => setOpen(false)} width={420}>\n <LazyWalletPanel client={client} appName=\"iDosGames\" />\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: \"3px 10px 3px 5px\",\n borderRadius: 999,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(8, 10, 40, .6)\",\n boxShadow: \"inset 0 2px 4px rgba(0,0,0,.4)\",\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n fontSize: 14,\n};\n"
43
71
  },
44
72
  {
45
- "path": "src/LoginScreen.tsx",
46
- "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { beginSsoRedirect, type PlayAccessMode } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"./env\";\nimport { LOGO_DATA_URL } from \"./logo\";\n\n// The Login scene. The host runtime owns WHEN this is shown (the auth gate in\n// @idosgames/app-shell); this file owns what it LOOKS like and which providers it offers.\n// Edit freely — branding, layout, copy, buttons.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus resetPassword.\n//\n// A provider is only rendered when it can actually complete, so players never meet a dead button:\n// guest / email — always available, no external setup.\n// Google — needs VITE_IDOS_GOOGLE_CLIENT_ID and the Google Identity script on the page.\n// wallet — always offered; ./walletLogin owns the wagmi config and the challenge network.\n// iDos Games — only where the platform accepts a return_to (see isSsoAvailable below).\n\n// Куда платформа соглашается вернуть одноразовый код. Список повторяет allowlist на бэкенде\n// (SsoService.AllowedOrigins) НАМЕРЕННО: здесь он решает только, показывать ли кнопку, а\n// настоящий барьер стоит на сервере. Показать кнопку там, где сервер откажет, — значит\n// пообещать игроку вход, который не состоится.\nconst SSO_ORIGINS = [\n \"https://cloud.idosgames.com\",\n \"https://idosgames.com\",\n \"https://www.idosgames.com\",\n];\n\n// Свои адреса игры: {titleid}.idos.games и {titleid}-dev.idos.games (её DEV-копия). Сервер примет\n// каждый ТОЛЬКО для этого же тайтла.\nconst GAME_HOST = /^https:\\/\\/[a-z0-9]{8}(-dev)?\\.idos\\.games$/;\n\nfunction isSsoAvailable(): boolean {\n if (typeof window === \"undefined\") return false;\n const { origin } = window.location;\n if (SSO_ORIGINS.includes(origin) || GAME_HOST.test(origin)) return true;\n\n // Свой домен издателя (play.brand.com) id тайтла не несёт, и по origin не понять, сработает ли\n // вход. Страницу, которую отдаёт платформа, её сервер помечает этой меткой — а на свой домен он\n // отдаёт игру, только когда домен подключён к ЭТОМУ тайтлу. В превью и на чужом хостинге метки нет.\n return (\n typeof document !== \"undefined\" &&\n document.querySelector('meta[name=\"idos-game-host\"]') !== null\n );\n}\n\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * A remembered wallet session comes back on the next launch by its refresh token (no new\n * signature); the server re-checks the token balance on it when the title's login mode needs one.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\n/** Пауза повторной отправки, когда сервер её не назвал. Совпадает со значением платформы. */\nconst DEFAULT_RESEND_COOLDOWN = 60;\n\n/**\n * Служебный код отказа → фраза, которую можно показать игроку.\n *\n * ⚠ Без этого экран входа показывал коды КАК ЕСТЬ: игрок видел «EMAIL_SENDER_NOT_CONFIGURED» или\n * «VERIFICATION_CODE_ATTEMPTS_EXCEEDED» вместо объяснения. Это шаблон, с которого начинается\n * каждая игра издателя, поэтому такое уезжает сразу всем.\n *\n * Незнакомый код возвращается как есть — намеренно: издателю на стенде он полезнее, чем общая\n * фраза «что-то пошло не так», а список ниже растёт по мере появления новых.\n */\nfunction humanizeAuthError(code: string | undefined): string {\n switch (code) {\n case \"EMAIL_SENDER_NOT_CONFIGURED\":\n return \"Sign-in by e-mail is unavailable in this game right now. Try another way to sign in.\";\n case \"INVALID_VERIFICATION_CODE\":\n return \"That code is not right. Check the e-mail and try again.\";\n case \"VERIFICATION_CODE_ATTEMPTS_EXCEEDED\":\n return \"Too many wrong attempts. Ask for a new code.\";\n case \"INCORRECT_EMAIL_OR_PASSWORD\":\n return \"Wrong e-mail or password.\";\n case \"INCORRECT_EMAIL\":\n return \"That does not look like an e-mail address.\";\n case \"PASSWORD_LENGTH_INVALID\":\n return \"The password must be 8 to 100 characters long.\";\n case \"TOO_MANY_FAILED_ATTEMPTS\":\n return \"Too many attempts. Please try again a little later.\";\n case \"RATE_LIMIT_EXCEEDED\":\n return \"Too many requests. Please try again in a moment.\";\n case \"PLAY_ACCESS_WALLET_LOGIN_ONLY\":\n return \"This game can only be entered with a crypto wallet.\";\n case \"PLAY_ACCESS_GUEST_DISABLED\":\n return \"Guest play is off in this game. Sign in to link a crypto wallet.\";\n case \"PLAY_ACCESS_CHECK_FAILED\":\n return \"Could not check your token balance right now. Please try again in a moment.\";\n default:\n return code ?? \"Something went wrong. Please try again.\";\n }\n}\n\ntype Mode = \"menu\" | \"email\" | \"verify\";\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n const [code, setCode] = useState(\"\");\n const [resendIn, setResendIn] = useState(0);\n const [resendCooldown, setResendCooldown] = useState(DEFAULT_RESEND_COOLDOWN);\n\n const [remember, setRemember] = useState(true);\n\n // The title's login mode (platform setting: Blockchain -> login mode), read before login so the\n // screen never offers a method the server refuses anyway: WalletOnly accepts only the wallet,\n // AnyWithLinkedWallet refuses guests (they cannot link a wallet). Until it arrives everything is\n // shown — the server check is the real barrier, this only keeps dead buttons off the screen.\n const [loginMode, setLoginMode] = useState<PlayAccessMode>(\"Open\");\n useEffect(() => {\n let active = true;\n void client.auth.getAuthOptions().then((result) => {\n if (active && result.ok && result.data.PlayAccessMode)\n setLoginMode(result.data.PlayAccessMode);\n });\n return () => {\n active = false;\n };\n }, [client]);\n const walletOnly = loginMode === \"WalletOnly\";\n const guestAllowed = loginMode === \"Open\";\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(\n humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\",\n );\n setBusy(false);\n };\n\n /**\n * Регистрация — единственный провайдер, который НЕ обязательно заканчивается входом.\n *\n * Когда тайтл требует подтверждения адреса (а это значение платформы), сервер только отправляет\n * код, и аккаунта ещё нет. Поэтому она идёт мимо `run`: тот на успехе сразу зовёт\n * `onAuthenticated()`, а здесь на успехе надо показать экран ввода кода.\n */\n const register = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n client.auth.setRememberSession(remember);\n\n const result = await client.auth.registerWithEmail(email, password);\n\n if (!result.ok) {\n setError(\n humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\",\n );\n setBusy(false);\n return;\n }\n\n // Аккаунта ещё нет — он появится на подтверждении. Уйти в игру здесь значило бы показать\n // пустую сессию.\n setCode(\"\");\n\n // Паузу задаёт СЕРВЕР (её настраивает издатель), поэтому запоминаем её и дальше берём\n // отсюда. Раньше первый отсчёт шёл от ответа, а каждый следующий — от захардкоженных 60\n // секунд: у тайтла с другой настройкой кнопка либо открывалась раньше, чем сервер согласен\n // слать (нажатие впустую, ответ всё равно успешный), либо держалась закрытой дольше нужного.\n const cooldown = result.data.resendCooldownSeconds ?? 0;\n setResendCooldown(cooldown > 0 ? cooldown : DEFAULT_RESEND_COOLDOWN);\n\n setResendIn(cooldown);\n setMode(\"verify\");\n setBusy(false);\n };\n\n const resendCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n\n const result = await client.auth.resendVerificationCode(email);\n\n // Ответ почти всегда успешный — начата регистрация или нет, выдержана пауза или нет. Иначе\n // эта кнопка отвечала бы на вопрос «заведён ли такой адрес». Поэтому и таймер заводим всегда.\n //\n // ⚠ Но ОДИН отказ отсюда приходит и его нельзя глотать: «слать нечем» (у тайтла и у\n // платформы нет отправителя). Он про конфигурацию сервера, а не про адрес, поэтому и\n // безопасен, и обязателен — иначе игрок жмёт кнопку до посинения, ожидая письма, которого\n // никто не отправлял.\n if (!result.ok) setError(humanizeAuthError(result.error));\n\n setResendIn(resendCooldown);\n setBusy(false);\n };\n\n // Обратный отсчёт до следующей отправки. Без него игрок жмёт «ещё раз» вслепую, а сервер молча\n // отказывает — и выглядит это как сломанная кнопка.\n useEffect(() => {\n if (resendIn <= 0) return;\n const id = setTimeout(() => setResendIn((v) => v - 1), 1000);\n return () => clearTimeout(id);\n }, [resendIn]);\n\n const signInWithGoogle = (): void => {\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: ENV_GOOGLE_CLIENT_ID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: { ...styles.button, ...styles.primary },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {!walletOnly && isSsoAvailable() && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {!walletOnly && ENV_GOOGLE_CLIENT_ID && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={signInWithGoogle}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n {!walletOnly && (\n <button\n type=\"button\"\n style={styles.button}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n )}\n\n {guestAllowed && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </button>\n )}\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n registering\n ? void register()\n : void run(() => client.auth.loginWithEmail(email, password))\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"verify\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n We sent a code to <strong>{email}</strong>. Enter it to finish\n creating your account.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Confirmation code\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n client.auth.confirmEmailRegistration(email, code),\n )\n }\n disabled={busy || !code}\n >\n {busy ? \"Please wait…\" : \"Confirm\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void resendCode()}\n disabled={busy || resendIn > 0}\n >\n {resendIn > 0\n ? `Send again in ${resendIn}s`\n : \"Send the code again\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {/* Applies to every provider above: remembered sessions come back by their refresh token. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n background: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n background: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n hint: {\n margin: 0,\n color: \"rgba(255, 255, 255, 0.85)\",\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
73
+ "path": "src/base/login/loginIcons.ts",
74
+ "content": "import type { CSSProperties } from \"react\";\nimport { MARK_DATA_URL } from \"./logo\";\n\n// Icons of the sign-in buttons (./LoginScreen), inlined as SVG data-URIs like the logo: no asset\n// pipeline and no image files in the project. Swap a string to restyle.\n//\n// The provider marks (Google, Apple, Telegram) are the providers' own sign-in marks - they tell the\n// player which account the button uses, and Google and Apple ask for exactly them on sign-in\n// buttons. Wallet, e-mail and guest are neutral line icons.\n\nconst svg = (body: string, viewBox = \"0 0 24 24\"): string =>\n `data:image/svg+xml;utf8,${encodeURIComponent(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\">${body}</svg>`,\n )}`;\n\nconst line = (paths: string, color: string): string =>\n svg(\n `<g fill=\"none\" stroke=\"${color}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">${paths}</g>`,\n );\n\nexport const LOGIN_ICONS = {\n // Blue: the wallet button is the primary one - white with blue text.\n wallet: line(\n '<path d=\"M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1\"/><path d=\"M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4\"/>',\n \"#0d66fe\",\n ),\n idos: MARK_DATA_URL,\n // The multicolor G on a white disc: on the translucent button it would lose its colors.\n google: svg(\n '<circle cx=\"24\" cy=\"24\" r=\"24\" fill=\"#fff\"/><g transform=\"translate(9 9) scale(.625)\"><path fill=\"#FFC107\" d=\"M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z\"/><path fill=\"#FF3D00\" d=\"m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z\"/><path fill=\"#4CAF50\" d=\"M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z\"/><path fill=\"#1976D2\" d=\"M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z\"/></g>',\n \"0 0 48 48\",\n ),\n apple: svg(\n '<path fill=\"#fff\" d=\"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701\"/>',\n ),\n telegram: svg(\n '<path fill=\"#fff\" d=\"M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z\"/>',\n ),\n email: line(\n '<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>',\n \"#fff\",\n ),\n guest: line(\n '<circle cx=\"12\" cy=\"8\" r=\"5\"/><path d=\"M20 21a8 8 0 0 0-16 0\"/>',\n \"rgba(255,255,255,0.85)\",\n ),\n} as const;\n\n/**\n * An icon at the left edge of a full-width button, with the label kept centered: the same padding\n * on both sides. A background, not a child element, so it also fits the wallet button, whose\n * content the wallet package draws and only takes a `style` from here.\n */\nexport function withIcon(icon: string): CSSProperties {\n return {\n backgroundImage: `url(\"${icon}\")`,\n backgroundRepeat: \"no-repeat\",\n backgroundPosition: \"14px center\",\n backgroundSize: \"20px 20px\",\n paddingLeft: \"44px\",\n paddingRight: \"44px\",\n };\n}\n"
47
75
  },
48
76
  {
49
- "path": "src/logo.ts",
50
- "content": "// The iDos Games logo (white), inlined as an SVG data-URI so it needs no asset pipeline: it\n// survives the template seed (text files only) and builds identically under real vite and the\n// classic preview bundler. Swap the string to rebrand.\n//\n// Composition mirrors the original brand mark: the spartan helmet on top, \"iDOS GAMES\" in one\n// line below it. The three path groups are the untouched brand-source paths (helmet / iDOS /\n// GAMES), positioned with transforms.\n\nconst LOGO_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 860 690\" fill=\"#fff\"><g transform=\"translate(243 -2)\"><path d=\"M147.333 12.333c-13.6 2.534-12.533 1.2-12.933 15.067l-.4 12.267-4.533-11.067c-2.534-6-5.067-10.933-5.734-10.933-2.666 0-27.866 8.666-36.533 12.666-11.733 5.334-26.667 14.8-36.133 22.934l-6.934 5.866L51.6 69.267c8.133 11.066 7.333 11.466-5.067 2.933-7.6-5.067-8.4-5.333-11.466-3.733-3.467 1.866-18.4 17.333-20.4 21.2-.8 1.6 7.066 8.8 33.733 30.933l34.667 28.933 8.8-8.266c14.666-13.867 28.666-22 48.133-28.267 9.067-2.933 14.8-3.733 28.533-4.4 34.267-1.333 60 7.467 81.6 28.133 21.6 20.4 34.934 49.6 37.467 81.734l.8 11.2 14.8 4.533c8.133 2.533 23.733 7.333 34.667 10.8 10.933 3.333 20.4 5.6 21.066 4.933 1.6-1.6 6.134-20.533 7.734-32.666.8-6 1.333-17.6 1.066-25.6l-.4-14.667-8-1.067c-4.4-.533-10.133-1.466-12.666-1.866l-4.667-.8 4.667-1.2c16.933-4.534 18.666-5.2 18.666-8 0-5.467-9.6-31.334-16.666-44.8-4-7.6-10.667-18.667-14.934-24.667-9.866-13.867-31.333-35.467-45.2-45.2-10.4-7.467-34.4-20.933-37.2-21.067-.8 0-4 4-7.333 8.8-3.2 4.8-6.133 8.134-6.4 7.334s.4-5.6 1.6-10.667c1.2-5.2 2.133-9.6 2.133-9.867 0-.933-19.866-6.133-34.4-9.066-14.8-2.934-49.333-4.4-59.6-2.534\"/><path d=\"M157.867 125.533c-21.067 3.334-42.134 14.8-57.467 31.334-13.333 14.133-13.733 12.8 5.6 22 9.2 4.4 16.667 8.266 16.667 8.533s-1.334.4-2.934.133c-13.866-2.133-41.6-5.866-41.733-5.6-.267.267-7.467 28.534-16.267 63.067l-16 62.667 7.334.4c4 .266 7.466.266 7.6 0 .266-.267 3.866-13.867 8.133-30.4 4.267-16.534 8.267-30.8 8.933-31.734.934-1.2 11.067-1.6 42.4-1.6 32.134 0 41.2.4 41.2 1.6 0 .934-1.733 4.667-3.866 8.4L153.733 261l-35.066 6.933c-19.334 3.867-35.2 7.2-35.467 7.467-.667.667-29.867 113.467-29.467 113.867.267.133 23.734-3.6 52.4-8.534l52-8.8 20.4-39.866c11.2-22 20.667-39.734 20.934-39.467.4.533-1.334 26.8-2.8 40.8l-.667 6.933 6.267-3.2c21.2-10.666 52.4-13.466 86.933-8l5.867.934-5.6-7.334C286.4 318.6 281.2 310.6 278 304.867c-5.867-10.134-6-10.8-6-21.867 0-6.267 1.467-20 3.333-30.667 1.867-10.666 3.334-19.466 3.334-19.466 0-.134-9.067-4.267-20.134-9.334s-19.466-9.333-18.8-9.6c.8-.266 7.734.8 15.6 2.267 7.734 1.6 14.4 2.8 14.8 2.8 1.2 0 .4-10.533-1.466-18-7.2-27.733-28-53.467-53.734-66-16.4-8-40.8-12-57.066-9.467\"/><path d=\"M235.6 346.333c-24 4.534-44.133 13.867-68.267 31.467-14 10.267-28.533 18.133-47.733 25.867-23.467 9.466-52.8 32.666-64.4 50.666l-2.533 4L58 454.2c9.333-7.333 30.133-19.467 49.333-28.667C173.733 393.667 246.4 382.2 312.8 393c11.2 1.733 39.2 8.533 45.067 10.8 2.266.8 2.266.667.8-2.267-4-7.6-15.6-21.866-25.867-31.466-12-11.467-24.667-18.934-37.6-22.4-11.467-3.067-46.267-3.867-59.6-1.334\"/></g><g transform=\"translate(-460 467)\"><path d=\"M721.533 34.467c-14.933 5.466-22 17.466-22.933 38.666-.933 24.134 3.467 32.8 30.267 59.867 19.6 19.867 22.933 25.2 22.933 37.867.133 13.466-6.133 20.133-16.933 18.4-9.067-1.467-12.534-7.867-12.534-22.667V159H696.6l.667 13.333c.533 11.467 1.2 14.4 4.933 22 7.067 14.267 16.8 19.6 36.4 19.734 10.667.133 12.267-.134 20.667-4.4 7.6-3.867 9.733-5.867 13.2-11.734 7.6-12.533 9.066-32 3.733-47.6-3.733-11.066-10.667-20.4-27.867-37.2-19.866-19.6-22.533-24.133-22.666-38.4 0-8.933.4-10.533 3.2-13.6 6.266-6.666 18.266-5.6 22.133 2 1.067 2 2 7.067 2 11.2v7.334h27.067l-.934-9.2c-1.6-18.267-8.266-30.267-20-36.4-7.2-3.867-29.2-4.8-37.6-1.6M630.067 34.867c-11.734 4.666-18 11.6-21.734 24.4-1.866 6.4-2 15.333-1.733 67.2l.4 59.866 3.733 7.6c6.8 13.867 18.534 20.267 36.934 20.267 13.733 0 22.933-3.333 30.666-11.067 10.934-11.066 11.2-12.533 11.734-74.666.266-35.734-.134-56.934-1.067-62.8-2.667-16.134-11.6-27.467-24.8-31.467-8.933-2.667-26.4-2.267-34.133.667m28.666 26.8 3.6 4v56.666c0 40.934-.4 57.467-1.6 60-3.6 8-15.6 9.734-22.666 3.467l-4.4-3.867-.4-56.533c-.267-51.733-.134-56.8 2-61.067 3.066-5.866 7.6-8.266 14.4-7.466 3.733.4 6.4 1.866 9.066 4.8M511.667 123.133v89.334l29.066-.4c28.8-.4 28.934-.4 36.667-4.267 9.467-4.533 14.4-10.933 17.467-22.4 2-7.333 2.266-15.467 1.866-65.733l-.4-57.334-4.266-8.8c-8.267-16.666-17.2-19.866-55.334-19.866h-25.066zm53.6-60.266 4.4 3.866.4 52.8c.266 35.067-.134 54.4-1.067 58-2 7.467-5.733 9.6-18.667 10.4l-10.666.8V59h10.666c9.867 0 10.934.267 14.934 3.867M471.667 145v67.333h26.666V77.667h-26.666zM471.667 48.333V63h26.666V33.667h-26.666z\"/></g><g transform=\"translate(-106 244)\"><path d=\"M893.4 259.733c-6.8 3.467-9.2 5.6-12.667 11.467-10.666 17.733-9.2 42.933 3.467 62.267 2.8 4.266 12.267 14.8 20.933 23.333 9.867 9.467 17.067 17.733 18.934 21.6 5.6 11.333 5.2 25.333-.934 30.8-3.466 3.067-11.333 4.133-16.133 2.133-5.867-2.4-8-7.466-8-19.333v-10h-27.067l.8 11.6c1.2 15.867 3.867 24.133 10.267 31.333 7.467 8.534 16.4 12.134 30 12.267 19.067.133 30.133-5.733 37.333-19.867 3.734-7.466 4-8.666 4-24 0-16 0-16.133-5.2-26.666-4.4-9.2-7.733-13.2-23.066-28.534-21.067-21.066-24.8-27.066-24.934-40.4-.133-10.4 2.8-15.6 9.734-17.6 10.133-2.933 17.2 3.334 18.4 16.267l.8 8.267H955.4l-.8-10.8c-.533-8.4-1.6-12.667-4.8-19.2-7.067-14.134-16.667-19.334-36.133-19.334-10.667 0-12.534.4-20.267 4.4M791 346v89.333h76v-24h-48v-54.666h37.333v-24H819V282h48v-25.333h-76zM661.667 346v89.333h22.666l.134-63.066c0-34.534.533-61.734 1.066-60.267.534 1.467 5.467 29.867 10.8 62.933l9.867 60.4h13.067c7.2 0 13.066-.4 13.066-1.066 0-3.067 18.8-124.8 19.334-125.334.4-.4.666 27.867.666 62.8v63.6h25.334V256.667h-38l-9.2 61.6c-5.067 34-9.6 62.133-10 62.666-.534.534-5.334-26.4-10.667-60-5.467-33.466-10.133-61.733-10.533-62.666-.4-1.2-5.467-1.6-19.067-1.6h-18.533zM586.467 258.267c-.8 2.666-27.467 172.4-27.467 174.933 0 1.867 1.733 2.133 12 2.133h11.867l.8-4.4c.533-2.266 1.6-9.333 2.4-15.6l1.6-11.333 17.6-.4 17.466-.4 1.467 8.4c.8 4.667 1.733 11.867 2.267 16l.933 7.733h13.733c7.6 0 13.867-.266 13.867-.666 0-.8-26.533-168.134-27.467-173.734l-.8-4.266h-19.866c-14.8 0-20.134.4-20.4 1.6M613 336.533c3.333 23.067 5.867 42.134 5.6 42.4-.4.267-6.533.267-13.867.134l-13.2-.4 4.934-33.334c2.8-18.4 5.733-38.4 6.666-44.666C604.2 292.533 605 290.133 605.8 292c.533 1.467 3.867 21.467 7.2 44.533M497.267 257.2c-10.134 3.733-15.2 8.267-19.867 17.733L473 284l-.4 59.067c-.4 51.2-.133 60.133 1.733 66.533 5.6 19.333 17.067 27.333 39.467 27.467 10.267.133 12.267-.267 20.133-4.134 9.334-4.533 13.867-9.733 17.867-20.266 2-4.934 2.4-12 2.933-41.067l.534-34.933h-38.934v23.866l6.4.4 6.267.4v19.334c0 13.466-.533 20.666-1.867 23.733-5.066 12-23.333 10.4-26.8-2.4-.933-3.6-1.333-23.2-1.066-59.067.4-48.533.666-54.133 2.666-57.333 3.334-4.8 11.2-7.067 17.6-4.933 6.934 2.266 9.067 7.2 9.867 22.933l.667 13.067H555.4l-.8-15.734c-.8-18-3.067-25.6-9.867-33.466-7.466-8.8-13.466-11.2-29.066-11.734-9.067-.266-14.934.134-18.4 1.467\"/></g></svg>`;\n\n/** White iDos Games logo (helmet over wordmark) as an `<img src>`-ready data-URI. */\nexport const LOGO_DATA_URL = `data:image/svg+xml;utf8,${encodeURIComponent(LOGO_SVG)}`;\n"
77
+ "path": "src/base/login/LoginScreen.tsx",
78
+ "content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { beginSsoRedirect, type AuthOptionsResponse } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"../../env\";\nimport { LOGO_DATA_URL } from \"./logo\";\nimport { LOGIN_ICONS, withIcon } from \"./loginIcons\";\n\n// The Login scene. The host runtime owns WHEN this is shown (the auth gate in\n// @idosgames/app-shell); this file owns what it LOOKS like and which providers it offers.\n// Edit freely — branding, layout, copy, buttons.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithApple, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus\n// forgotPassword/resetPassword.\n//\n// A provider is only rendered when it can actually complete, so players never meet a dead button:\n// guest / email - always available, no external setup (guest only on an open title).\n// Google - the title's own Google sign-in (platform setting) or VITE_IDOS_GOOGLE_CLIENT_ID;\n// the Google Identity script is loaded on demand.\n// Apple - when the title set up Sign in with Apple for the web (a Services ID).\n// Telegram - only inside a Telegram Mini App (it signs in with the app's initData).\n// wallet - always offered; ./walletLogin owns the wagmi config and the challenge network.\n// iDos Games - only where the platform accepts a return_to (see isSsoAvailable below).\n//\n// The title's login mode decides the rest (platform setting: Blockchain -> login mode):\n// Open - every method;\n// AnyWithLinkedWallet - every method except guest (a guest cannot link a wallet);\n// WalletOnly - the wallet and iDos Games only: an iDos Games account plays with a wallet\n// linked to it (one wallet - one account), everything else is refused.\n\n// Куда платформа соглашается вернуть одноразовый код. Список повторяет allowlist на бэкенде\n// (SsoService.AllowedOrigins) НАМЕРЕННО: здесь он решает только, показывать ли кнопку, а\n// настоящий барьер стоит на сервере. Показать кнопку там, где сервер откажет, — значит\n// пообещать игроку вход, который не состоится.\nconst SSO_ORIGINS = [\n \"https://cloud.idosgames.com\",\n \"https://idosgames.com\",\n \"https://www.idosgames.com\",\n];\n\n// Свои адреса игры: {titleid}.idos.games и {titleid}-dev.idos.games (её DEV-копия). Сервер примет\n// каждый ТОЛЬКО для этого же тайтла.\nconst GAME_HOST = /^https:\\/\\/[a-z0-9]{8}(-dev)?\\.idos\\.games$/;\n\nfunction isSsoAvailable(): boolean {\n if (typeof window === \"undefined\") return false;\n const { origin } = window.location;\n if (SSO_ORIGINS.includes(origin) || GAME_HOST.test(origin)) return true;\n\n // Свой домен издателя (play.brand.com) id тайтла не несёт, и по origin не понять, сработает ли\n // вход. Страницу, которую отдаёт платформа, её сервер помечает этой меткой — а на свой домен он\n // отдаёт игру, только когда домен подключён к ЭТОМУ тайтлу. В превью и на чужом хостинге метки нет.\n return (\n typeof document !== \"undefined\" &&\n document.querySelector('meta[name=\"idos-game-host\"]') !== null\n );\n}\n\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * A remembered wallet session comes back on the next launch by its refresh token (no new\n * signature); the server re-checks the token balance on it when the title's login mode needs one.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\n/** Пауза повторной отправки, когда сервер её не назвал. Совпадает со значением платформы. */\nconst DEFAULT_RESEND_COOLDOWN = 60;\n\n/**\n * Служебный код отказа → фраза, которую можно показать игроку.\n *\n * ⚠ Без этого экран входа показывал коды КАК ЕСТЬ: игрок видел «EMAIL_SENDER_NOT_CONFIGURED» или\n * «VERIFICATION_CODE_ATTEMPTS_EXCEEDED» вместо объяснения. Это шаблон, с которого начинается\n * каждая игра издателя, поэтому такое уезжает сразу всем.\n *\n * Незнакомый код возвращается как есть — намеренно: издателю на стенде он полезнее, чем общая\n * фраза «что-то пошло не так», а список ниже растёт по мере появления новых.\n */\nfunction humanizeAuthError(code: string | undefined): string {\n switch (code) {\n case \"EMAIL_SENDER_NOT_CONFIGURED\":\n return \"Sign-in by e-mail is unavailable in this game right now. Try another way to sign in.\";\n case \"INVALID_VERIFICATION_CODE\":\n return \"That code is not right. Check the e-mail and try again.\";\n case \"VERIFICATION_CODE_ATTEMPTS_EXCEEDED\":\n return \"Too many wrong attempts. Ask for a new code.\";\n case \"INCORRECT_EMAIL_OR_PASSWORD\":\n return \"Wrong e-mail or password.\";\n case \"INCORRECT_EMAIL\":\n return \"That does not look like an e-mail address.\";\n case \"PASSWORD_LENGTH_INVALID\":\n return \"The password must be 8 to 100 characters long.\";\n case \"TOO_MANY_FAILED_ATTEMPTS\":\n return \"Too many attempts. Please try again a little later.\";\n case \"RATE_LIMIT_EXCEEDED\":\n return \"Too many requests. Please try again in a moment.\";\n case \"PLAY_ACCESS_WALLET_LOGIN_ONLY\":\n return \"This game can only be entered with a crypto wallet or your iDos Games account.\";\n case \"PLAY_ACCESS_WALLET_USED_BY_ANOTHER_ACCOUNT\":\n return \"This wallet already plays with another account.\";\n case \"WALLET_UNLINK_COOLDOWN\":\n return \"This wallet was unlinked from another account less than 7 days ago. Try again after the 7 days.\";\n case \"APPLE_EMAIL_MISSING\":\n return \"Apple did not share an e-mail address. Try again and allow it, or use another way to sign in.\";\n case \"PLAY_ACCESS_GUEST_DISABLED\":\n return \"Guest play is off in this game. Sign in to link a crypto wallet.\";\n case \"PLAY_ACCESS_CHECK_FAILED\":\n return \"Could not check your token balance right now. Please try again in a moment.\";\n default:\n return code ?? \"Something went wrong. Please try again.\";\n }\n}\n\ntype Mode = \"menu\" | \"email\" | \"verify\" | \"forgot\" | \"reset\";\n\nconst GOOGLE_SCRIPT = \"https://accounts.google.com/gsi/client\";\nconst APPLE_SCRIPT =\n \"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js\";\n\n/** Loads a provider's script once; resolves when it has run. */\nfunction loadScript(src: string): Promise<void> {\n if (typeof document === \"undefined\")\n return Promise.reject(new Error(\"no document\"));\n const existing = document.querySelector<HTMLScriptElement>(\n `script[src=\"${src}\"]`,\n );\n if (existing?.dataset.loaded === \"1\") return Promise.resolve();\n return new Promise((resolve, reject) => {\n const el = existing ?? document.createElement(\"script\");\n el.addEventListener(\"load\", () => {\n el.dataset.loaded = \"1\";\n resolve();\n });\n el.addEventListener(\"error\", () =>\n reject(new Error(`could not load ${src}`)),\n );\n if (!existing) {\n el.src = src;\n el.async = true;\n document.head.appendChild(el);\n }\n });\n}\n\n/** A random nonce for the provider's request: the server checks the token carries it, then burns it. */\nfunction randomNonce(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Inside a Telegram Mini App the app hands over signed initData - the one thing Telegram login needs. */\nfunction hasTelegramInitData(): boolean {\n const tg = (globalThis as { Telegram?: { WebApp?: { initData?: string } } })\n .Telegram;\n return Boolean(tg?.WebApp?.initData);\n}\n\n/** Minimal Sign in with Apple JS surface - declared here so the template needs no Apple typings. */\ntype AppleIdentity = {\n auth: {\n init(config: {\n clientId: string;\n scope: string;\n redirectURI: string;\n usePopup: boolean;\n nonce?: string;\n }): void;\n signIn(): Promise<{\n authorization: { id_token: string };\n user?: { name?: { firstName?: string; lastName?: string } };\n }>;\n };\n};\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n const [code, setCode] = useState(\"\");\n const [resendIn, setResendIn] = useState(0);\n const [resendCooldown, setResendCooldown] = useState(DEFAULT_RESEND_COOLDOWN);\n\n const [remember, setRemember] = useState(true);\n\n const [notice, setNotice] = useState<string | null>(null);\n\n // The title's sign-in settings, read before login: the login mode (so the screen never offers a\n // method the server refuses anyway) and which providers the title set up (Google, Apple). Until\n // they arrive the open-title set is shown - the server check is the real barrier, this only\n // keeps dead buttons off the screen.\n const [options, setOptions] = useState<AuthOptionsResponse | null>(null);\n useEffect(() => {\n let active = true;\n void client.auth.getAuthOptions().then((result) => {\n if (active && result.ok) setOptions(result.data);\n });\n return () => {\n active = false;\n };\n }, [client]);\n const loginMode = options?.PlayAccessMode ?? \"Open\";\n const walletOnly = loginMode === \"WalletOnly\";\n const guestAllowed = loginMode === \"Open\";\n const googleClientID =\n ENV_GOOGLE_CLIENT_ID ||\n (options?.Google?.Enabled ? (options.Google.WebClientID ?? \"\") : \"\");\n const appleServiceID = options?.Apple?.Enabled\n ? (options.Apple.ServiceID ?? \"\")\n : \"\";\n const telegram = hasTelegramInitData();\n const sso = isSsoAvailable();\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(\n humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\",\n );\n setBusy(false);\n };\n\n /**\n * Регистрация — единственный провайдер, который НЕ обязательно заканчивается входом.\n *\n * Когда тайтл требует подтверждения адреса (а это значение платформы), сервер только отправляет\n * код, и аккаунта ещё нет. Поэтому она идёт мимо `run`: тот на успехе сразу зовёт\n * `onAuthenticated()`, а здесь на успехе надо показать экран ввода кода.\n */\n const register = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n client.auth.setRememberSession(remember);\n\n const result = await client.auth.registerWithEmail(email, password);\n\n if (!result.ok) {\n setError(\n humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\",\n );\n setBusy(false);\n return;\n }\n\n // Аккаунта ещё нет — он появится на подтверждении. Уйти в игру здесь значило бы показать\n // пустую сессию.\n setCode(\"\");\n\n // Паузу задаёт СЕРВЕР (её настраивает издатель), поэтому запоминаем её и дальше берём\n // отсюда. Раньше первый отсчёт шёл от ответа, а каждый следующий — от захардкоженных 60\n // секунд: у тайтла с другой настройкой кнопка либо открывалась раньше, чем сервер согласен\n // слать (нажатие впустую, ответ всё равно успешный), либо держалась закрытой дольше нужного.\n const cooldown = result.data.resendCooldownSeconds ?? 0;\n setResendCooldown(cooldown > 0 ? cooldown : DEFAULT_RESEND_COOLDOWN);\n\n setResendIn(cooldown);\n setMode(\"verify\");\n setBusy(false);\n };\n\n const resendCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n\n const result = await client.auth.resendVerificationCode(email);\n\n // Ответ почти всегда успешный — начата регистрация или нет, выдержана пауза или нет. Иначе\n // эта кнопка отвечала бы на вопрос «заведён ли такой адрес». Поэтому и таймер заводим всегда.\n //\n // ⚠ Но ОДИН отказ отсюда приходит и его нельзя глотать: «слать нечем» (у тайтла и у\n // платформы нет отправителя). Он про конфигурацию сервера, а не про адрес, поэтому и\n // безопасен, и обязателен — иначе игрок жмёт кнопку до посинения, ожидая письма, которого\n // никто не отправлял.\n if (!result.ok) setError(humanizeAuthError(result.error));\n\n setResendIn(resendCooldown);\n setBusy(false);\n };\n\n // Обратный отсчёт до следующей отправки. Без него игрок жмёт «ещё раз» вслепую, а сервер молча\n // отказывает — и выглядит это как сломанная кнопка.\n useEffect(() => {\n if (resendIn <= 0) return;\n const id = setTimeout(() => setResendIn((v) => v - 1), 1000);\n return () => clearTimeout(id);\n }, [resendIn]);\n\n const signInWithGoogle = async (): Promise<void> => {\n try {\n await loadScript(GOOGLE_SCRIPT);\n } catch {\n // Handled below: no global means the script is unavailable.\n }\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: googleClientID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n const signInWithApple = async (): Promise<void> => {\n setError(null);\n try {\n await loadScript(APPLE_SCRIPT);\n } catch {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const apple = (globalThis as { AppleID?: AppleIdentity }).AppleID;\n if (!apple) {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const nonce = randomNonce();\n apple.auth.init({\n clientId: appleServiceID,\n scope: \"name email\",\n // Must be a return URL registered for this Services ID; with a popup Apple only checks it.\n redirectURI: `${window.location.origin}/`,\n usePopup: true,\n nonce,\n });\n try {\n const response = await apple.auth.signIn();\n const name = [\n response.user?.name?.firstName,\n response.user?.name?.lastName,\n ]\n .filter(Boolean)\n .join(\" \");\n await run(() =>\n client.auth.loginWithApple(response.authorization.id_token, {\n nonce,\n name,\n }),\n );\n } catch {\n // The player closed Apple's window - nothing to report.\n }\n };\n\n /** \"Forgot password\": the server e-mails a code; the next step sets the new password with it. */\n const sendResetCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.forgotPassword(email);\n setBusy(false);\n // Like the resend: the answer does not say whether the address has an account - except \"there\n // is nothing to send with\", which is about the server and must be shown.\n if (!result.ok && result.error === \"EMAIL_SENDER_NOT_CONFIGURED\") {\n setError(humanizeAuthError(result.error));\n return;\n }\n setCode(\"\");\n setPassword(\"\");\n setMode(\"reset\");\n };\n\n const resetPassword = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.resetPassword(\n email,\n code.trim(),\n password,\n );\n setBusy(false);\n if (!result.ok) {\n setError(humanizeAuthError(result.error));\n return;\n }\n setPassword(\"\");\n setNotice(\"Password changed. Sign in with the new one.\");\n setMode(\"email\");\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: {\n ...styles.button,\n ...styles.primary,\n ...withIcon(LOGIN_ICONS.wallet),\n },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {sso && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.idos) }}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {walletOnly && sso && (\n <p style={styles.hint}>\n Signing in with iDos Games? Link your wallet after that - the\n game is played with a wallet.\n </p>\n )}\n\n {!walletOnly && googleClientID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.google) }}\n onClick={() => void signInWithGoogle()}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n {!walletOnly && appleServiceID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.apple) }}\n onClick={() => void signInWithApple()}\n disabled={busy}\n >\n Continue with Apple\n </button>\n )}\n\n {!walletOnly && telegram && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.telegram) }}\n onClick={() => void run(() => client.auth.loginWithTelegram())}\n disabled={busy}\n >\n Continue with Telegram\n </button>\n )}\n\n {!walletOnly && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.email) }}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n )}\n\n {guestAllowed && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n <span style={styles.inlineIcon}>\n <img src={LOGIN_ICONS.guest} alt=\"\" width={18} height={18} />\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </span>\n </button>\n )}\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n registering\n ? void register()\n : void run(() => client.auth.loginWithEmail(email, password))\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n {!registering && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n setNotice(null);\n }}\n disabled={busy}\n >\n Forgot password?\n </button>\n )}\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"verify\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n We sent a code to <strong>{email}</strong>. Enter it to finish\n creating your account.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Confirmation code\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n client.auth.confirmEmailRegistration(email, code),\n )\n }\n disabled={busy || !code}\n >\n {busy ? \"Please wait…\" : \"Confirm\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void resendCode()}\n disabled={busy || resendIn > 0}\n >\n {resendIn > 0\n ? `Send again in ${resendIn}s`\n : \"Send the code again\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"forgot\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n Enter your e-mail - we will send a code to set a new password.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void sendResetCode()}\n disabled={busy || !email}\n >\n {busy ? \"Please wait...\" : \"Send the code\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"reset\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n If <strong>{email}</strong> has an account, a code is on its way.\n Enter it and choose a new password.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Code from the e-mail\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n autoComplete=\"new-password\"\n placeholder=\"New password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void resetPassword()}\n disabled={busy || !code || !password}\n >\n {busy ? \"Please wait...\" : \"Set the new password\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {notice && !error && <p style={styles.hint}>{notice}</p>}\n\n {/* Applies to every provider above: remembered sessions come back by their refresh token. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n // backgroundColor, not the `background` shorthand: the shorthand would wipe the icon\n // (backgroundImage from ./loginIcons) - here and on the wallet button, whose own default style\n // uses the shorthand.\n backgroundColor: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n backgroundColor: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n inlineIcon: {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: \"8px\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n hint: {\n margin: 0,\n color: \"rgba(255, 255, 255, 0.85)\",\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
79
+ },
80
+ {
81
+ "path": "src/base/login/logo.ts",
82
+ "content": "// The iDos Games logo (white), inlined as an SVG data-URI so it needs no asset pipeline: it\n// survives the template seed (text files only) and needs no image file in the project. Swap the\n// string to rebrand.\n//\n// Composition mirrors the original brand mark: the spartan helmet on top, \"iDOS GAMES\" in one\n// line below it. The three path groups are the untouched brand-source paths (helmet / iDOS /\n// GAMES), positioned with transforms.\n\n// The helmet alone: the logo reuses it, and so does the small square mark (MARK_DATA_URL).\nconst HELMET_PATHS = `<path d=\"M147.333 12.333c-13.6 2.534-12.533 1.2-12.933 15.067l-.4 12.267-4.533-11.067c-2.534-6-5.067-10.933-5.734-10.933-2.666 0-27.866 8.666-36.533 12.666-11.733 5.334-26.667 14.8-36.133 22.934l-6.934 5.866L51.6 69.267c8.133 11.066 7.333 11.466-5.067 2.933-7.6-5.067-8.4-5.333-11.466-3.733-3.467 1.866-18.4 17.333-20.4 21.2-.8 1.6 7.066 8.8 33.733 30.933l34.667 28.933 8.8-8.266c14.666-13.867 28.666-22 48.133-28.267 9.067-2.933 14.8-3.733 28.533-4.4 34.267-1.333 60 7.467 81.6 28.133 21.6 20.4 34.934 49.6 37.467 81.734l.8 11.2 14.8 4.533c8.133 2.533 23.733 7.333 34.667 10.8 10.933 3.333 20.4 5.6 21.066 4.933 1.6-1.6 6.134-20.533 7.734-32.666.8-6 1.333-17.6 1.066-25.6l-.4-14.667-8-1.067c-4.4-.533-10.133-1.466-12.666-1.866l-4.667-.8 4.667-1.2c16.933-4.534 18.666-5.2 18.666-8 0-5.467-9.6-31.334-16.666-44.8-4-7.6-10.667-18.667-14.934-24.667-9.866-13.867-31.333-35.467-45.2-45.2-10.4-7.467-34.4-20.933-37.2-21.067-.8 0-4 4-7.333 8.8-3.2 4.8-6.133 8.134-6.4 7.334s.4-5.6 1.6-10.667c1.2-5.2 2.133-9.6 2.133-9.867 0-.933-19.866-6.133-34.4-9.066-14.8-2.934-49.333-4.4-59.6-2.534\"/><path d=\"M157.867 125.533c-21.067 3.334-42.134 14.8-57.467 31.334-13.333 14.133-13.733 12.8 5.6 22 9.2 4.4 16.667 8.266 16.667 8.533s-1.334.4-2.934.133c-13.866-2.133-41.6-5.866-41.733-5.6-.267.267-7.467 28.534-16.267 63.067l-16 62.667 7.334.4c4 .266 7.466.266 7.6 0 .266-.267 3.866-13.867 8.133-30.4 4.267-16.534 8.267-30.8 8.933-31.734.934-1.2 11.067-1.6 42.4-1.6 32.134 0 41.2.4 41.2 1.6 0 .934-1.733 4.667-3.866 8.4L153.733 261l-35.066 6.933c-19.334 3.867-35.2 7.2-35.467 7.467-.667.667-29.867 113.467-29.467 113.867.267.133 23.734-3.6 52.4-8.534l52-8.8 20.4-39.866c11.2-22 20.667-39.734 20.934-39.467.4.533-1.334 26.8-2.8 40.8l-.667 6.933 6.267-3.2c21.2-10.666 52.4-13.466 86.933-8l5.867.934-5.6-7.334C286.4 318.6 281.2 310.6 278 304.867c-5.867-10.134-6-10.8-6-21.867 0-6.267 1.467-20 3.333-30.667 1.867-10.666 3.334-19.466 3.334-19.466 0-.134-9.067-4.267-20.134-9.334s-19.466-9.333-18.8-9.6c.8-.266 7.734.8 15.6 2.267 7.734 1.6 14.4 2.8 14.8 2.8 1.2 0 .4-10.533-1.466-18-7.2-27.733-28-53.467-53.734-66-16.4-8-40.8-12-57.066-9.467\"/><path d=\"M235.6 346.333c-24 4.534-44.133 13.867-68.267 31.467-14 10.267-28.533 18.133-47.733 25.867-23.467 9.466-52.8 32.666-64.4 50.666l-2.533 4L58 454.2c9.333-7.333 30.133-19.467 49.333-28.667C173.733 393.667 246.4 382.2 312.8 393c11.2 1.733 39.2 8.533 45.067 10.8 2.266.8 2.266.667.8-2.267-4-7.6-15.6-21.866-25.867-31.466-12-11.467-24.667-18.934-37.6-22.4-11.467-3.067-46.267-3.867-59.6-1.334\"/>`;\n\nconst LOGO_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 860 690\" fill=\"#fff\"><g transform=\"translate(243 -2)\">${HELMET_PATHS}</g><g transform=\"translate(-460 467)\"><path d=\"M721.533 34.467c-14.933 5.466-22 17.466-22.933 38.666-.933 24.134 3.467 32.8 30.267 59.867 19.6 19.867 22.933 25.2 22.933 37.867.133 13.466-6.133 20.133-16.933 18.4-9.067-1.467-12.534-7.867-12.534-22.667V159H696.6l.667 13.333c.533 11.467 1.2 14.4 4.933 22 7.067 14.267 16.8 19.6 36.4 19.734 10.667.133 12.267-.134 20.667-4.4 7.6-3.867 9.733-5.867 13.2-11.734 7.6-12.533 9.066-32 3.733-47.6-3.733-11.066-10.667-20.4-27.867-37.2-19.866-19.6-22.533-24.133-22.666-38.4 0-8.933.4-10.533 3.2-13.6 6.266-6.666 18.266-5.6 22.133 2 1.067 2 2 7.067 2 11.2v7.334h27.067l-.934-9.2c-1.6-18.267-8.266-30.267-20-36.4-7.2-3.867-29.2-4.8-37.6-1.6M630.067 34.867c-11.734 4.666-18 11.6-21.734 24.4-1.866 6.4-2 15.333-1.733 67.2l.4 59.866 3.733 7.6c6.8 13.867 18.534 20.267 36.934 20.267 13.733 0 22.933-3.333 30.666-11.067 10.934-11.066 11.2-12.533 11.734-74.666.266-35.734-.134-56.934-1.067-62.8-2.667-16.134-11.6-27.467-24.8-31.467-8.933-2.667-26.4-2.267-34.133.667m28.666 26.8 3.6 4v56.666c0 40.934-.4 57.467-1.6 60-3.6 8-15.6 9.734-22.666 3.467l-4.4-3.867-.4-56.533c-.267-51.733-.134-56.8 2-61.067 3.066-5.866 7.6-8.266 14.4-7.466 3.733.4 6.4 1.866 9.066 4.8M511.667 123.133v89.334l29.066-.4c28.8-.4 28.934-.4 36.667-4.267 9.467-4.533 14.4-10.933 17.467-22.4 2-7.333 2.266-15.467 1.866-65.733l-.4-57.334-4.266-8.8c-8.267-16.666-17.2-19.866-55.334-19.866h-25.066zm53.6-60.266 4.4 3.866.4 52.8c.266 35.067-.134 54.4-1.067 58-2 7.467-5.733 9.6-18.667 10.4l-10.666.8V59h10.666c9.867 0 10.934.267 14.934 3.867M471.667 145v67.333h26.666V77.667h-26.666zM471.667 48.333V63h26.666V33.667h-26.666z\"/></g><g transform=\"translate(-106 244)\"><path d=\"M893.4 259.733c-6.8 3.467-9.2 5.6-12.667 11.467-10.666 17.733-9.2 42.933 3.467 62.267 2.8 4.266 12.267 14.8 20.933 23.333 9.867 9.467 17.067 17.733 18.934 21.6 5.6 11.333 5.2 25.333-.934 30.8-3.466 3.067-11.333 4.133-16.133 2.133-5.867-2.4-8-7.466-8-19.333v-10h-27.067l.8 11.6c1.2 15.867 3.867 24.133 10.267 31.333 7.467 8.534 16.4 12.134 30 12.267 19.067.133 30.133-5.733 37.333-19.867 3.734-7.466 4-8.666 4-24 0-16 0-16.133-5.2-26.666-4.4-9.2-7.733-13.2-23.066-28.534-21.067-21.066-24.8-27.066-24.934-40.4-.133-10.4 2.8-15.6 9.734-17.6 10.133-2.933 17.2 3.334 18.4 16.267l.8 8.267H955.4l-.8-10.8c-.533-8.4-1.6-12.667-4.8-19.2-7.067-14.134-16.667-19.334-36.133-19.334-10.667 0-12.534.4-20.267 4.4M791 346v89.333h76v-24h-48v-54.666h37.333v-24H819V282h48v-25.333h-76zM661.667 346v89.333h22.666l.134-63.066c0-34.534.533-61.734 1.066-60.267.534 1.467 5.467 29.867 10.8 62.933l9.867 60.4h13.067c7.2 0 13.066-.4 13.066-1.066 0-3.067 18.8-124.8 19.334-125.334.4-.4.666 27.867.666 62.8v63.6h25.334V256.667h-38l-9.2 61.6c-5.067 34-9.6 62.133-10 62.666-.534.534-5.334-26.4-10.667-60-5.467-33.466-10.133-61.733-10.533-62.666-.4-1.2-5.467-1.6-19.067-1.6h-18.533zM586.467 258.267c-.8 2.666-27.467 172.4-27.467 174.933 0 1.867 1.733 2.133 12 2.133h11.867l.8-4.4c.533-2.266 1.6-9.333 2.4-15.6l1.6-11.333 17.6-.4 17.466-.4 1.467 8.4c.8 4.667 1.733 11.867 2.267 16l.933 7.733h13.733c7.6 0 13.867-.266 13.867-.666 0-.8-26.533-168.134-27.467-173.734l-.8-4.266h-19.866c-14.8 0-20.134.4-20.4 1.6M613 336.533c3.333 23.067 5.867 42.134 5.6 42.4-.4.267-6.533.267-13.867.134l-13.2-.4 4.934-33.334c2.8-18.4 5.733-38.4 6.666-44.666C604.2 292.533 605 290.133 605.8 292c.533 1.467 3.867 21.467 7.2 44.533M497.267 257.2c-10.134 3.733-15.2 8.267-19.867 17.733L473 284l-.4 59.067c-.4 51.2-.133 60.133 1.733 66.533 5.6 19.333 17.067 27.333 39.467 27.467 10.267.133 12.267-.267 20.133-4.134 9.334-4.533 13.867-9.733 17.867-20.266 2-4.934 2.4-12 2.933-41.067l.534-34.933h-38.934v23.866l6.4.4 6.267.4v19.334c0 13.466-.533 20.666-1.867 23.733-5.066 12-23.333 10.4-26.8-2.4-.933-3.6-1.333-23.2-1.066-59.067.4-48.533.666-54.133 2.666-57.333 3.334-4.8 11.2-7.067 17.6-4.933 6.934 2.266 9.067 7.2 9.867 22.933l.667 13.067H555.4l-.8-15.734c-.8-18-3.067-25.6-9.867-33.466-7.466-8.8-13.466-11.2-29.066-11.734-9.067-.266-14.934.134-18.4 1.467\"/></g></svg>`;\n\n/** White iDos Games logo (helmet over wordmark) as an `<img src>`-ready data-URI. */\nexport const LOGO_DATA_URL = `data:image/svg+xml;utf8,${encodeURIComponent(LOGO_SVG)}`;\n\n/**\n * The helmet on its own, square, for small places (the \"Continue with iDos Games\" button icon).\n * The viewBox is the helmet's bounding box padded to a square.\n */\nexport const MARK_DATA_URL = `data:image/svg+xml;utf8,${encodeURIComponent(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-33 11 448 448\" fill=\"#fff\">${HELMET_PATHS}</svg>`,\n)}`;\n"
83
+ },
84
+ {
85
+ "path": "src/base/login/PlayAccessScreen.tsx",
86
+ "content": "import type { ReactNode } from \"react\";\nimport {\n DefaultPlayAccessScreen,\n type PlayAccessScreenProps,\n} from \"@idosgames/app-shell\";\nimport { renderWalletLink } from \"../../walletLogin\";\n\n// The play-access screen. The host shows it INSTEAD of the game while the title's login mode\n// (platform setting: Blockchain -> login mode) requires a token balance the player has not confirmed\n// yet — no linked wallet, balance too low, or the pass expired. The server re-checks the balance on\n// every login and session refresh; this screen is the manual path in between.\n//\n// The ready-made screen lists the requirements, the player's linked wallets, \"Link wallet\" (from\n// ./walletLogin) and \"Check balance\". Restyle freely: replace the body with your own UI and keep\n// calling client.blockchain.getPlayAccess() / confirmPlayAccess() and props.onGranted().\nexport function PlayAccessScreen(props: PlayAccessScreenProps): ReactNode {\n return (\n <DefaultPlayAccessScreen {...props} renderLinkWallet={renderWalletLink} />\n );\n}\n"
87
+ },
88
+ {
89
+ "path": "src/base/module.ts",
90
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\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, isEvmNetwork } 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 a web3 title on an EVM network (the panel's kind).\n const wallet =\n identity.web3 &&\n isEvmNetwork(\n configSection<Parameters<typeof isEvmNetwork>[0]>(\n ctx.client,\n \"Blockchain\",\n ),\n identity.networkID,\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 }),\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(ctx.modes, ctx.navigate);\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"
91
+ },
92
+ {
93
+ "path": "src/config.ts",
94
+ "content": "// Which Title this app talks to, and in which environment. Always targets the real backend\n// (https://api.idosgames.com).\n//\n// ⚠ PLATFORM-OWNED. The platform's build packs its own copy of this file over the project's (the\n// same way it regenerates src/idos.title.ts), so an edit here never reaches a published game.\n//\n// Neither IDENTITY nor ENVIRONMENT is read from the link (query or path) — that is the point. A link\n// is something anyone can edit: when `?titleID=` could pick the title, another publisher could open\n// this finished game with their own title in the link and run it on their title's settings.\n//\n// identity — WHICH game. Resolved once, in this order:\n// 1. src/idos.title.ts — the centralized identity file (generated by the platform, or filled\n// by hand on a manual scaffold). Wins over everything, and is the only\n// channel a packaged mobile build has.\n// 2. the host — {id}.idos.games / {id}-dev.idos.games, the title's own addresses. The\n// platform's game host serves those names with that title's files only.\n// 3. VITE_IDOS_TITLE_ID — local development, via .env.local.\n// There is deliberately NO fallback title. A build that cannot tell which game it is must fail\n// loudly rather than quietly sign in to somebody else's data.\n//\n// environment — WHICH copy of that game, prod or dev. A modifier on the identity, never a\n// different identity: the dev title is always `{id}-DEV`. Resolved in this order:\n// 1. the host — {id}-dev.idos.games is dev, {id}.idos.games is prod. That is how one\n// artifact runs on both without a rebuild.\n// 2. the live preview — the AI Coder's in-browser preview marks its sandbox as dev before\n// any project code runs (it lives on a foreign origin, so the host\n// cannot say it).\n// 3. VITE_IDOS_ENV — local development, via .env.local (\"dev\" or \"prod\").\n// 4. IDOS_DEFAULT_ENV — the baked default from src/idos.title.ts.\n\nimport { IDOS_BUILD_KEY, IDOS_DEFAULT_ENV, IDOS_TITLE_ID } from \"./idos.title\";\n\n/** Mirrors DevTitleService.DevSuffix on the backend. */\nconst DEV_SUFFIX = \"-DEV\";\n\n// Build-time values straight from Vite's `import.meta.env` (VITE_* of .env.local), not through\n// ./env: this file is packed over the project's own at build time, so it must not depend on what\n// the project's other files export — nor on the project having Vite's client types, hence the cast.\nconst viteEnv: Record<string, string | undefined> =\n (import.meta as unknown as { env?: Record<string, string | undefined> })\n .env ?? {};\nconst buildEnv = {\n TITLE_ID: viteEnv.VITE_IDOS_TITLE_ID,\n BUILD_KEY: viteEnv.VITE_IDOS_BUILD_KEY,\n ENV: viteEnv.VITE_IDOS_ENV,\n};\n\n/**\n * The title's own addresses: `{titleid}.idos.games` (prod) and `{titleid}-dev.idos.games` (dev).\n * Title ids are 8 characters of [A-Z0-9] and hostnames are case-insensitive, so the lowercase id IS\n * the address. Mirrors the platform's game-host Worker (TitleHostWorker/src/host.ts). A publisher's\n * own domain carries no id — that is what idos.title.ts is for.\n */\nfunction fromHost(): { titleId: string; dev: boolean } | null {\n if (typeof window === \"undefined\") return null;\n const match = window.location.hostname\n .toLowerCase()\n .replace(/\\.$/, \"\")\n .match(/^([a-z0-9]{8})(-dev)?\\.idos\\.games$/);\n const id = match?.[1];\n return id\n ? { titleId: id.toUpperCase(), dev: match?.[2] !== undefined }\n : null;\n}\n\n/**\n * Set by the AI Coder's live preview before any project code runs. Nothing outside the platform can\n * set it on a hosted game: that page is served from the title's own origin, and a script from\n * anywhere else cannot reach its globals.\n */\nfunction previewEnv(): string | null {\n const marker = (globalThis as { __IDOS_PREVIEW__?: { env?: unknown } })\n .__IDOS_PREVIEW__;\n return typeof marker?.env === \"string\" ? marker.env : null;\n}\n\nconst host = fromHost();\nconst envTitle = buildEnv.TITLE_ID ?? \"\";\nconst envTitleIsDev = envTitle.endsWith(DEV_SUFFIX);\n\nconst canonicalTitle =\n IDOS_TITLE_ID ||\n host?.titleId ||\n (envTitleIsDev ? envTitle.slice(0, -DEV_SUFFIX.length) : envTitle);\n\nif (!canonicalTitle) {\n throw new Error(\n \"[idos] No Title id resolved. Expected src/idos.title.ts (generated by the platform), \" +\n \"a {titleid}.idos.games host, or VITE_IDOS_TITLE_ID in .env.local.\",\n );\n}\n\nfunction resolveIsDev(): boolean {\n if (host) return host.dev;\n const preview = previewEnv();\n if (preview !== null) return preview === \"dev\";\n if (buildEnv.ENV === \"dev\" || buildEnv.ENV === \"prod\")\n return buildEnv.ENV === \"dev\";\n if (envTitleIsDev) return true;\n return IDOS_DEFAULT_ENV === \"dev\";\n}\n\nexport const TITLE_ID = resolveIsDev()\n ? `${canonicalTitle}${DEV_SUFFIX}`\n : canonicalTitle;\n\nexport const BUILD_KEY = IDOS_BUILD_KEY || buildEnv.BUILD_KEY || \"\";\n"
95
+ },
96
+ {
97
+ "path": "src/env.ts",
98
+ "content": "// Build-time values from Vite's `import.meta.env`: the VITE_* variables of `.env.local` (local\n// development). In the platform's editor nothing sets them, so the defaults below apply — the\n// title and environment come from src/config.ts, not from here.\nconst env = import.meta.env;\n\nexport const IS_DEV = env.DEV;\nexport const ENV_TITLE_ID: string = env.VITE_IDOS_TITLE_ID ?? \"\";\nexport const ENV_BUILD_KEY: string = env.VITE_IDOS_BUILD_KEY ?? \"\";\nexport const ENV_WALLETCONNECT_PROJECT_ID: string =\n env.VITE_WALLETCONNECT_PROJECT_ID ?? \"\";\n\n/** Google Identity client id. Empty = the login screen hides the Google button rather than\n * offering one that cannot work (a Google sign-in needs a real ID token). */\nexport const ENV_GOOGLE_CLIENT_ID: string =\n env.VITE_IDOS_GOOGLE_CLIENT_ID ?? \"\";\n"
99
+ },
100
+ {
101
+ "path": "src/idos.title.ts",
102
+ "content": "// The project's IDENTITY file — the ONE centralized place the Title id lives.\n//\n// On platform-created projects the platform GENERATES this file when it creates the project; there\n// the AI editor is denied write access to this path on purpose — do not edit it by hand. On a\n// manually scaffolded project (get_host_scaffold / copied template) there is no generator: YOU fill\n// IDOS_TITLE_ID here yourself. Either way, do not import anything into this file, and do not bind\n// the title anywhere else (.env.local is a local-dev fallback for the raw template only).\n//\n// It is the highest-priority source of the Title id (see config.ts for the full chain). It is baked\n// into the bundle rather than read from the URL because a build has to keep working where there is\n// no URL to read — packaged as a mobile app, embedded in an iframe, or opened from a shared link\n// that dropped its query string.\n//\n// It holds the CANONICAL (production) title. The DEV title is derived from it, never stored here —\n// one project has one identity, and DEV/PROD is an environment on top of that identity.\n\n/** Canonical (production) Title id. Empty only in the raw template, before the platform seeds it. */\nexport const IDOS_TITLE_ID = \"\";\n\n/** Build key for this title, if the title enforces one. */\nexport const IDOS_BUILD_KEY = \"\";\n\n/** Environment this artifact defaults to. Web builds may override it (see config.ts); a packaged\n * mobile build cannot, so a DEV app is produced by generating this file with \"dev\". */\nexport const IDOS_DEFAULT_ENV: \"prod\" | \"dev\" = \"prod\";\n\n/**\n * Whether this title was created as web3. Set by the platform from the same toggle the publisher\n * used at title creation.\n *\n * It no longer gates the login screen — wallet sign-in is always offered (see walletLogin.tsx).\n * Kept as baked metadata for code that wants to know the title's web3 origin BEFORE there is a\n * session (`client.title.getTitlePublicConfiguration()` requires one).\n */\nexport const IDOS_WEB3 = false;\n\n/**\n * NetworkID the wallet login challenge is issued for (e.g. \"bsc\", \"base\", \"solana\"). Empty on\n * web2 titles. Baked for the same reason as everything else here: the login screen needs it before\n * there is a session, and the title's blockchain config is only readable once logged in.\n */\nexport const IDOS_WEB3_NETWORK_ID = \"\";\n\n/**\n * WalletConnect Cloud / Reown project id (dashboard.reown.com). Enables MOBILE wallet login via\n * WalletConnect (QR on desktop, deep-link on phones); without it only browser-extension wallets\n * are offered and mobile login is unavailable. Set per-title in the blockchain config\n * (Blockchain.WalletLogin.WalletConnectProjectId) — the platform bakes it here. Baked, not read at\n * runtime, for the same reason as IDOS_WEB3_NETWORK_ID: the login screen runs before there is a\n * session, and the title's blockchain config is only readable once logged in.\n */\nexport const IDOS_WEB3_WALLETCONNECT_PROJECT_ID = \"\";\n"
51
103
  },
52
104
  {
53
105
  "path": "src/main.tsx",
54
- "content": "// ПЕРВЫЙ импорт сознательно: зонд превью ставит перехват console/ошибок при загрузке своего\n// модуля, и всё, что упадёт ниже по старту (включая config.ts на нераспознанном тайтле), уже\n// попадёт в его лог — а значит доедет до AI-кодера. Вне превью не делает ничего.\nimport { installPreviewProbe } from \"./previewProbe\";\nimport { createIDosGamesClient } from \"@idosgames/core\";\nimport { mountHost } from \"@idosgames/app-shell\";\nimport { modules } from \"./modules\";\nimport { LoginScreen } from \"./LoginScreen\";\nimport { renderWalletLogin } from \"./walletLogin\";\nimport { PlayAccessScreen } from \"./PlayAccessScreen\";\nimport { TITLE_ID, BUILD_KEY } from \"./config\";\nimport { IS_DEV } from \"./env\";\n\n// Тайтл зонду — отдельным вызовом: сам он встал раньше, чем config.ts успел его разрешить.\ninstallPreviewProbe({ titleId: TITLE_ID });\n\nconst app = document.getElementById(\"app\");\nif (!app) throw new Error(\"#app container not found\");\n\n// Always runs against the real backend (https://api.idosgames.com) via the global fetch.\nconst client = createIDosGamesClient({\n titleID: TITLE_ID,\n buildKey: BUILD_KEY.length > 0 ? BUILD_KEY : undefined,\n throttleMs: 0,\n});\n\nclient.on(\"error:global\", (message) => {\n console.error(\"[idos] global error:\", message);\n});\nclient.on(\"error:connection\", (message) => {\n console.error(\"[idos] connection error:\", message);\n});\n\nif (IS_DEV) {\n (\n globalThis as typeof globalThis & { idosClient?: typeof client }\n ).idosClient = client;\n}\n\n// The host owns sign-in: mountHost replays the previous session (autoLogin) and, when there is\n// none, renders the login screen. Do NOT log in here — that would skip the screen, and with it the\n// player's ability to pick a provider or switch accounts.\n//\n// Wallet sign-in comes from ./walletLogin — always offered; that file owns the wagmi config and\n// the challenge network.\nmountHost({\n container: app,\n client,\n modules,\n renderLogin: (props) => (\n <LoginScreen {...props} renderWalletLogin={renderWalletLogin} />\n ),\n // Shown instead of the game when the title's login mode requires a token balance the player has\n // not confirmed yet (see ./PlayAccessScreen).\n renderPlayAccess: (props) => <PlayAccessScreen {...props} />,\n});\n"
106
+ "content": "// ПЕРВЫЙ импорт сознательно: зонд превью ставит перехват console/ошибок при загрузке своего\n// модуля, и всё, что упадёт ниже по старту (включая config.ts на нераспознанном тайтле), уже\n// попадёт в его лог — а значит доедет до AI-кодера. Вне превью не делает ничего.\nimport { installPreviewProbe } from \"./previewProbe\";\nimport { createIDosGamesClient } from \"@idosgames/core\";\nimport { mountHost } from \"@idosgames/app-shell\";\nimport { baseModule, LoginScreen, PlayAccessScreen } from \"./base\";\nimport { modules } from \"./modules\";\nimport { renderWalletLogin } from \"./walletLogin\";\nimport { TITLE_ID, BUILD_KEY } from \"./config\";\nimport { IS_DEV } from \"./env\";\nimport ui from \"./ui.config\";\n\n// Тайтл зонду — отдельным вызовом: сам он встал раньше, чем config.ts успел его разрешить.\ninstallPreviewProbe({ titleId: TITLE_ID });\n\nconst app = document.getElementById(\"app\");\nif (!app) throw new Error(\"#app container not found\");\n\n// Always runs against the real backend (https://api.idosgames.com) via the global fetch.\nconst client = createIDosGamesClient({\n titleID: TITLE_ID,\n buildKey: BUILD_KEY.length > 0 ? BUILD_KEY : undefined,\n throttleMs: 0,\n});\n\nclient.on(\"error:global\", (message) => {\n console.error(\"[idos] global error:\", message);\n});\nclient.on(\"error:connection\", (message) => {\n console.error(\"[idos] connection error:\", message);\n});\n\nif (IS_DEV) {\n (\n globalThis as typeof globalThis & { idosClient?: typeof client }\n ).idosClient = client;\n}\n\n// The host owns sign-in: mountHost replays the previous session (autoLogin) and, when there is\n// none, renders the login screen. Do NOT log in here — that would skip the screen, and with it the\n// player's ability to pick a provider or switch accounts.\n//\n// The app = the base (./base: sign-in and the lobby) + the modules of ./modules.ts (the shop, heroes,\n// leaderboards… and the games). The base goes first; what happens after sign-in is ./app.config.ts.\n//\n// Wallet sign-in comes from ./walletLogin — always offered; that file owns the wagmi config and\n// the challenge network.\nmountHost({\n container: app,\n client,\n modules: [baseModule, ...modules],\n renderLogin: (props) => (\n <LoginScreen {...props} renderWalletLogin={renderWalletLogin} />\n ),\n // Shown instead of the app when the title's login mode requires a token balance the player has\n // not confirmed yet (see ./base/login/PlayAccessScreen).\n renderPlayAccess: (props) => <PlayAccessScreen {...props} />,\n // Look, animations and sounds of every module's interface — edit ./ui.config.ts, not the modules.\n ui,\n});\n"
55
107
  },
56
108
  {
57
109
  "path": "src/modules.ts",
58
- "content": "import type { Module } from \"@idosgames/module-sdk\";\n\n// The list of feature modules this project composes. A fresh project starts empty and shows the\n// host's \"no modules\" state. Add a module by copying it into src/modules/<id>/ and registering it\n// here — the AI Coder seeder appends to this array, and you can also edit it by hand.\n//\n// Example, after copying a module into src/modules/board-game/:\n// import { boardGameModule } from \"./modules/board-game\";\n// export const modules: Module[] = [boardGameModule];\nexport const modules: Module[] = [];\n"
110
+ "content": "import type { Module } from \"@idosgames/module-sdk\";\n\n// The modules this app is made of, on top of the base (./base — sign-in and the lobby): the systems\n// shown as lobby tabs (shop, heroes, leaderboards…) and the games launched by \"Play\". Remove a module\n// here and it is gone from the app. The platform writes this file whenever modules are installed or\n// removed, so it holds only the imports and the array.\n//\n// Example, after copying a module into src/modules/voxelcraft/:\n// import { voxelcraftModule } from \"./modules/voxelcraft\";\n// export const modules: Module[] = [voxelcraftModule];\nexport const modules: Module[] = [];\n"
59
111
  },
60
112
  {
61
- "path": "src/PlayAccessScreen.tsx",
62
- "content": "import type { ReactNode } from \"react\";\nimport {\n DefaultPlayAccessScreen,\n type PlayAccessScreenProps,\n} from \"@idosgames/app-shell\";\nimport { renderWalletLink } from \"./walletLogin\";\n\n// The play-access screen. The host shows it INSTEAD of the game while the title's login mode\n// (platform setting: Blockchain -> login mode) requires a token balance the player has not confirmed\n// yet — no linked wallet, balance too low, or the pass expired. The server re-checks the balance on\n// every login and session refresh; this screen is the manual path in between.\n//\n// The ready-made screen lists the requirements, the player's linked wallets, \"Link wallet\" (from\n// ./walletLogin) and \"Check balance\". Restyle freely: replace the body with your own UI and keep\n// calling client.blockchain.getPlayAccess() / confirmPlayAccess() and props.onGranted().\nexport function PlayAccessScreen(props: PlayAccessScreenProps): ReactNode {\n return (\n <DefaultPlayAccessScreen {...props} renderLinkWallet={renderWalletLink} />\n );\n}\n"
113
+ "path": "src/previewProbe.ts",
114
+ "content": "// Зонд превью: даёт AI-кодеру ГЛАЗА на работающее приложение.\n//\n// Живёт ВНУТРИ iframe с игрой, потому что иначе никак: превью грузится с домена бандлера, и\n// родительская страница (дашборд) по правилам браузера в его DOM залезть не может — единственная\n// щель между ними это postMessage. Зонд эту щель и обслуживает: сериализует DOM в компактное\n// дерево, копит console/сеть и умеет кликать по элементам, найденным в прошлом readPage.\n//\n// Отдельный слой — АВТОМАТИЧЕСКОЕ наблюдение за отрисованной игрой (см. ниже): полотна, кадры,\n// three.js, цвет экрана. Он работает без всякого участия игры и именно поэтому нужен: контракт\n// `exposeToAgent` даёт больше, но его кто-то должен написать, а произвольной игре не напишет никто.\n//\n// ДВА ИНВАРИАНТА, которые нельзя нарушать:\n//\n// 1. Зонд молчит, пока с ним не поздоровались с РАЗРЕШЁННОГО origin. Публичный сайт\n// idosgames.com тоже встраивает игры в iframe — там hello никто не пришлёт, и весь этот код\n// останется мёртвым. Ответ всегда уходит на event.origin, никогда на \"*\".\n// 2. Детект среды — только рантаймовый (адрес страницы, метка превью): зонд копируется в любой\n// проект, и зависеть от того, что проект передаёт при сборке, ему нельзя.\n//\n// Подключается ПЕРВОЙ строкой main.tsx: тогда перехват console/ошибок стоит раньше, чем всё\n// остальное успевает упасть, и агент увидит причину падения старта, а не пустоту.\n\n/** Метка протокола: всё, что без неё, зонда не касается. */\nconst WIRE = \"idos-preview-probe/1\";\n\n/** Кто имеет право разговаривать с зондом. Всё остальное игнорируется молча. */\nconst ALLOWED_ORIGIN_HOSTS = [\"platform.idosgames.com\"];\n\n/** Сколько записей console храним. Ring buffer: старое вытесняется. */\nconst CONSOLE_LIMIT = 100;\n\n/** Сколько сетевых вызовов храним. */\nconst NETWORK_LIMIT = 50;\n\n/** Потолок дерева: узлов, глубины и символов. Держит снапшот в разумных токенах. */\nconst MAX_NODES = 400;\nconst MAX_DEPTH = 15;\nconst MAX_TREE_CHARS = 8000;\n\n/** Обрезка текста узла — модели хватает начала, а полный текст раздувает снапшот. */\nconst MAX_TEXT = 80;\n\n/** Однотипных детей печатаем не больше этого, остальные схлопываем в «… +N more». */\nconst MAX_SIBLINGS = 12;\n\n/** Пауза после клика/ввода перед новым снимком: даём React дорисовать. */\nconst SETTLE_MS = 350;\n\n/** Кольцо отметок кадров — по нему считается fps. Хватает на несколько секунд при 60 fps. */\nconst FRAME_RING = 240;\n\n/** Сетка чтения пикселей: 6×6 = 36 точек. Каждая точка — отдельный синхронный readPixels. */\nconst PIXEL_GRID = 6;\n\n/** Сколько ждём кадр перед чтением пикселей. Не дождались — это и есть ответ: петля не идёт. */\nconst FRAME_WAIT_MS = 200;\n\n/** Потолок удержания синтетической клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_INPUT_HOLD_MS = 3000;\n\ntype ProbeRequest = {\n wire: typeof WIRE;\n id: string;\n cmd: string;\n args?: Record<string, unknown>;\n};\n\ntype ConsoleEntry = { level: string; text: string; at: number };\ntype NetworkEntry = {\n method: string;\n url: string;\n status: number | string;\n ms: number;\n at: number;\n};\n\nexport type PreviewProbeOptions = {\n /** Тайтл, против которого работает приложение — агенту важно видеть DEV это или PROD. */\n titleId?: string;\n};\n\n/**\n * Debug-поверхности модулей (см. `ctx.exposeToAgent` в @idosgames/module-sdk). Их выкладывает в\n * глобал host-shell; для отрисованных игр это ЕДИНСТВЕННЫЙ способ что-то узнать — у Three/Phaser\n * весь интерфейс это один `<canvas>`, и дерево DOM про него не расскажет ничего.\n */\ntype AgentModuleApi = {\n state?: () => unknown;\n actions?: Record<\n string,\n (args?: Record<string, unknown>) => unknown | Promise<unknown>\n >;\n describeActions?: Record<string, string>;\n};\n\n/** Состояние платформы, которое выкладывает host-shell (см. `publishHostState` в app-shell). */\ntype AgentHostState = {\n screen?: string;\n loggedIn?: boolean;\n userId?: string | null;\n titleId?: string | null;\n modules?: string[];\n /** Кто рисует общий интерфейс: роль → id модуля или null (роль свободна, шаблоны рисуют своё). */\n sharedUi?: Record<string, string | null>;\n};\n\ntype AgentGlobal = {\n version?: number;\n modules?: Record<string, AgentModuleApi>;\n host?: AgentHostState;\n /** Журнал шины событий между модулями (последние события, кто что слушает, расхождения версий). */\n events?: () => unknown;\n};\n\nfunction agentGlobal(): AgentGlobal | undefined {\n return (globalThis as typeof globalThis & { __IDOS_AGENT__?: AgentGlobal })\n .__IDOS_AGENT__;\n}\n\nfunction agentModules(): Record<string, AgentModuleApi> {\n return agentGlobal()?.modules ?? {};\n}\n\nconst consoleLog: ConsoleEntry[] = [];\nconst networkLog: NetworkEntry[] = [];\n\n/** Элементы последнего снимка: клик адресуется по ref_N отсюда. */\nlet refs = new Map<string, Element>();\n\nlet installed = false;\nlet probeOptions: PreviewProbeOptions = {};\n\n/* ------------------------------------------------------------------ утилиты */\n\nfunction push<T>(buf: T[], entry: T, limit: number): void {\n buf.push(entry);\n if (buf.length > limit) buf.shift();\n}\n\nfunction clip(text: string, max: number): string {\n const flat = text.replace(/\\s+/g, \" \").trim();\n return flat.length > max ? `${flat.slice(0, max)}…` : flat;\n}\n\n/** Безопасная печать аргумента console: объекты в JSON, циклы и геттеры-бомбы не роняют зонд. */\nfunction stringifyArg(value: unknown): string {\n if (typeof value === \"string\") return value;\n if (value instanceof Error) return `${value.name}: ${value.message}`;\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return String(value);\n }\n}\n\nfunction isAllowedOrigin(origin: string): boolean {\n try {\n const url = new URL(origin);\n if (url.hostname === \"localhost\" || url.hostname === \"127.0.0.1\")\n return true;\n return ALLOWED_ORIGIN_HOSTS.includes(url.hostname);\n } catch {\n return false;\n }\n}\n\n/* -------------------------------------------------------- сбор console/сети */\n\nfunction captureConsole(): void {\n const levels = [\"log\", \"info\", \"warn\", \"error\", \"debug\"] as const;\n for (const level of levels) {\n const original = console[level].bind(console);\n console[level] = (...args: unknown[]): void => {\n push(\n consoleLog,\n {\n level,\n text: clip(args.map(stringifyArg).join(\" \"), 300),\n at: Date.now(),\n },\n CONSOLE_LIMIT,\n );\n original(...args);\n };\n }\n\n window.addEventListener(\"error\", (event) => {\n const where = event.filename\n ? ` (${event.filename}:${event.lineno}:${event.colno})`\n : \"\";\n push(\n consoleLog,\n {\n level: \"error\",\n text: clip(`Uncaught ${event.message}${where}`, 300),\n at: Date.now(),\n },\n CONSOLE_LIMIT,\n );\n });\n\n window.addEventListener(\"unhandledrejection\", (event) => {\n push(\n consoleLog,\n {\n level: \"error\",\n text: clip(`Unhandled rejection: ${stringifyArg(event.reason)}`, 300),\n at: Date.now(),\n },\n CONSOLE_LIMIT,\n );\n });\n}\n\nfunction captureNetwork(): void {\n const originalFetch = window.fetch.bind(window);\n window.fetch = async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> => {\n const started = Date.now();\n const url =\n typeof input === \"string\"\n ? input\n : input instanceof URL\n ? input.href\n : input.url;\n const method = (\n init?.method ??\n (typeof input === \"object\" && \"method\" in input ? input.method : \"GET\") ??\n \"GET\"\n ).toUpperCase();\n\n // Заголовки и тела НЕ пишем сознательно: в них сидит Bearer-тикет игрока, а лог уезжает в LLM.\n try {\n const response = await originalFetch(input, init);\n push(\n networkLog,\n {\n method,\n url: clip(url, 200),\n status: response.status,\n ms: Date.now() - started,\n at: started,\n },\n NETWORK_LIMIT,\n );\n return response;\n } catch (error: unknown) {\n push(\n networkLog,\n {\n method,\n url: clip(url, 200),\n status: `failed: ${stringifyArg(error)}`,\n ms: Date.now() - started,\n at: started,\n },\n NETWORK_LIMIT,\n );\n throw error;\n }\n };\n}\n\n/* ------------------------------------------ автоматический слой наблюдения */\n\n// Всё, что ниже, работает БЕЗ какой-либо кооперации со стороны игры — в этом весь смысл.\n// Контракт `exposeToAgent` даёт данные лучше, но его должен кто-то НАПИСАТЬ, а произвольной (и тем\n// более будущей) игре его не напишет никто. Поэтому зонд снимает сам всё, что можно снять с движка\n// и с полотна: есть ли WebGL-контекст, идут ли кадры, сколько draw-вызовов, что говорит three.js о\n// сцене и камере, и не залит ли кадр одним цветом.\n//\n// Скриншотов здесь нет и не будет (решение владельца): наружу уходят ТОЛЬКО числа. Зато число\n// «все 36 проб одного цвета #ffffff» ловит белый экран не хуже картинки.\n\n/** Настоящий rAF, снятый ДО перехвата: им зонд ждёт кадр, не накручивая собственный счётчик. */\nconst rawRaf: ((cb: FrameRequestCallback) => number) | null =\n typeof window !== \"undefined\" &&\n typeof window.requestAnimationFrame === \"function\"\n ? window.requestAnimationFrame.bind(window)\n : null;\n\ntype CanvasRecord = {\n canvas: HTMLCanvasElement;\n /** Как контекст запрашивали: \"2d\" | \"webgl\" | \"webgl2\" | \"webgpu\" | … */\n kind: string;\n gl: WebGLRenderingContext | WebGL2RenderingContext | null;\n ctx2d: CanvasRenderingContext2D | null;\n /** Отметки «кадр отрисован» (clear/clearRect) — по ним считается настоящий fps. */\n frames: number;\n /** Вызовы отрисовки: у Three/Phaser их десятки-сотни за кадр. */\n draws: number;\n lastDrawAt: number;\n};\n\n/** Сколько полотен помним. Больше игре и не нужно, а временные canvas'ы иначе растут без конца. */\nconst MAX_CANVAS_RECORDS = 24;\n\nconst canvasRecords: CanvasRecord[] = [];\n\n/** Отметки кадров: по полотну (надёжнее) и по rAF (петля жива, даже если ничего не рисуется). */\nconst glFrameTimes: number[] = [];\nconst rafFrameTimes: number[] = [];\n\nfunction markFrame(ring: number[]): void {\n ring.push(Date.now());\n if (ring.length > FRAME_RING) ring.shift();\n}\n\n/** Сколько отметок пришлось на последнюю секунду. Это и есть fps — без усреднения по сессии. */\nfunction perSecond(ring: number[]): number {\n const cutoff = Date.now() - 1000;\n let count = 0;\n for (let i = ring.length - 1; i >= 0; i--) {\n if ((ring[i] ?? 0) < cutoff) break;\n count++;\n }\n return count;\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n return value && typeof value === \"object\"\n ? (value as Record<string, unknown>)\n : null;\n}\n\nfunction num(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\n/**\n * Подменить метод объекта счётчиком. Пишем в САМ объект, а не в прототип: у WebGL-контекста метод\n * лежит на прототипе, и собственное свойство просто перекрывает его для этого экземпляра — чужие\n * контексты (и чужие вкладки) остаются нетронутыми.\n */\nfunction countCalls(target: unknown, name: string, tick: () => void): void {\n const holder = target as Record<string, unknown> | null;\n if (!holder) return;\n const current = holder[name];\n if (typeof current !== \"function\") return;\n const original = current as (...args: unknown[]) => unknown;\n holder[name] = function (this: unknown, ...args: unknown[]): unknown {\n try {\n tick();\n } catch {\n /* счётчик не имеет права ломать отрисовку */\n }\n return original.apply(this, args);\n };\n}\n\n/** Инструментовка полотна: кадры отдельно, вызовы отрисовки отдельно. */\nfunction instrument(rec: CanvasRecord): void {\n const frame = (): void => {\n rec.frames++;\n rec.lastDrawAt = Date.now();\n // В общий счётчик fps идут только полотна НА СТРАНИЦЕ: временные canvas'ы, на которых движки\n // рисуют текстуры и текст, чистятся так же часто и накрутили бы «кадры» на пустом месте.\n if (rec.canvas.isConnected) markFrame(glFrameTimes);\n };\n const draw = (): void => {\n rec.draws++;\n };\n\n if (rec.gl) {\n // clear зовут один раз за кадр практически все движки — он и служит границей кадра.\n countCalls(rec.gl, \"clear\", frame);\n for (const method of [\n \"drawArrays\",\n \"drawElements\",\n \"drawArraysInstanced\",\n \"drawElementsInstanced\",\n ]) {\n countCalls(rec.gl, method, draw);\n }\n return;\n }\n\n if (rec.ctx2d) {\n countCalls(rec.ctx2d, \"clearRect\", frame);\n for (const method of [\n \"drawImage\",\n \"fillRect\",\n \"fill\",\n \"stroke\",\n \"fillText\",\n ]) {\n countCalls(rec.ctx2d, method, draw);\n }\n }\n}\n\nfunction registerContext(\n canvas: HTMLCanvasElement,\n kind: string,\n ctx: unknown,\n): void {\n // Повторный getContext возвращает тот же объект — второй раз инструментовать нельзя.\n if (canvasRecords.some((rec) => rec.canvas === canvas && rec.kind === kind))\n return;\n\n // Потолок списка обязателен: движки создают временные полотна пачками (текстуры из текста,\n // атласы), и без него зонд держал бы ссылку на каждое — утечка памяти плюс линейный поиск,\n // который растёт с каждым кадром. Первыми уходят полотна, которых уже нет на странице.\n if (canvasRecords.length >= MAX_CANVAS_RECORDS) {\n for (let i = canvasRecords.length - 1; i >= 0; i--) {\n if (!canvasRecords[i]?.canvas.isConnected) canvasRecords.splice(i, 1);\n }\n while (canvasRecords.length >= MAX_CANVAS_RECORDS) canvasRecords.shift();\n }\n\n const isGl =\n kind === \"webgl\" || kind === \"webgl2\" || kind === \"experimental-webgl\";\n const rec: CanvasRecord = {\n canvas,\n kind,\n gl: isGl ? (ctx as WebGLRenderingContext | WebGL2RenderingContext) : null,\n ctx2d: kind === \"2d\" ? (ctx as CanvasRenderingContext2D) : null,\n frames: 0,\n draws: 0,\n lastDrawAt: 0,\n };\n canvasRecords.push(rec);\n instrument(rec);\n}\n\n/**\n * Перехват `getContext`. Ставится ДО импорта движка (зонд — первый импорт main.tsx), поэтому ни\n * одно полотно мимо не проходит, чем бы игра ни рисовала: Three, Phaser, Pixi, сырой WebGL, 2d.\n *\n * Заодно ТОЛЬКО В ПРЕВЬЮ навязывается `preserveDrawingBuffer: true`. По умолчанию содержимое\n * WebGL-буфера действительно лишь до композитинга кадра, и снаружи кадра оттуда читается пустота —\n * то есть статичная сцена, которая рисуется один раз, выглядела бы «чёрным экраном». С флагом\n * последний нарисованный кадр остаётся читаемым в любой момент. Цена — небольшая потеря\n * производительности, и платит её только вкладка с превью: в собранной игре зонда нет.\n */\nfunction captureCanvases(): void {\n type GetContext = (\n this: HTMLCanvasElement,\n id: string,\n options?: unknown,\n ) => unknown;\n\n const proto = HTMLCanvasElement.prototype;\n const original = proto.getContext as unknown as GetContext;\n\n const patched: GetContext = function (this: HTMLCanvasElement, id, options) {\n let effective = options;\n try {\n const kind = String(id);\n if (\n kind === \"webgl\" ||\n kind === \"webgl2\" ||\n kind === \"experimental-webgl\"\n ) {\n // Копия, а не правка чужого объекта: игра могла передать свой конфиг и переиспользовать его.\n effective = {\n ...(asRecord(options) ?? {}),\n preserveDrawingBuffer: true,\n };\n }\n } catch {\n effective = options;\n }\n\n const ctx = original.call(this, id, effective);\n try {\n if (ctx) registerContext(this, String(id), ctx);\n } catch {\n /* наблюдение не имеет права мешать игре получить контекст */\n }\n return ctx;\n };\n\n proto.getContext = patched as unknown as typeof proto.getContext;\n}\n\n/** Перехват rAF: показывает, что цикл приложения вообще крутится, даже если полотна нет. */\nfunction captureFrames(): void {\n if (!rawRaf) return;\n window.requestAnimationFrame = (callback: FrameRequestCallback): number =>\n rawRaf((time) => {\n markFrame(rafFrameTimes);\n callback(time);\n });\n}\n\n/* ---------------------------------------------------------------- three.js */\n\nconst three: {\n renderer: Record<string, unknown> | null;\n scene: Record<string, unknown> | null;\n camera: Record<string, unknown> | null;\n scenes: number;\n} = { renderer: null, scene: null, camera: null, scenes: 0 };\n\n/**\n * Канал, по которому three.js сам представляется наблюдателю.\n *\n * `WebGLRenderer` и `Scene` в своих конструкторах проверяют глобал `__THREE_DEVTOOLS__` и, если он\n * есть, шлют в него событие `observe` со ссылкой на себя. Задуман он для расширения-девтулзов, но\n * это ровно то, что нужно здесь: никакой правки игры, а на выходе живой рендерер со счётчиками.\n * Глобал обязан существовать ДО создания рендерера — отсюда установка при импорте зонда.\n */\nfunction captureThree(): void {\n const holder = globalThis as typeof globalThis & {\n __THREE_DEVTOOLS__?: EventTarget;\n };\n\n // Если глобал уже кто-то поставил (расширение three-devtools) — подписываемся, а не затираем.\n const target: EventTarget = holder.__THREE_DEVTOOLS__ ?? new EventTarget();\n holder.__THREE_DEVTOOLS__ = target;\n\n target.addEventListener(\"observe\", (event: Event) => {\n try {\n const detail = asRecord((event as CustomEvent<unknown>).detail);\n if (!detail) return;\n\n if (detail[\"isScene\"] === true) {\n three.scenes++;\n three.scene ??= detail;\n return;\n }\n\n // Рендерер узнаём по паре info + domElement: это его, и только его, поверхность.\n if (detail[\"info\"] && detail[\"domElement\"]) {\n three.renderer = detail;\n watchRender(detail);\n }\n } catch {\n /* чужой объект не обязан быть таким, как мы ждём */\n }\n });\n}\n\n/**\n * Подмена `renderer.render(scene, camera)`: это единственный способ узнать, какие сцену и камеру\n * игра рисует ПРЯМО СЕЙЧАС. Событие `observe` про камеру не рассказывает вообще, а сцен у игры\n * может быть несколько (меню, мир, миникарта).\n */\nfunction watchRender(renderer: Record<string, unknown>): void {\n if (renderer[\"__idosProbeWatched\"] === true) return;\n const original = renderer[\"render\"];\n if (typeof original !== \"function\") return;\n\n renderer[\"__idosProbeWatched\"] = true;\n const call = original as (...args: unknown[]) => unknown;\n renderer[\"render\"] = function (this: unknown, ...args: unknown[]): unknown {\n const scene = asRecord(args[0]);\n const camera = asRecord(args[1]);\n if (scene) three.scene = scene;\n if (camera) three.camera = camera;\n return call.apply(this, args);\n };\n}\n\nfunction threeSnapshot(): Record<string, unknown> | null {\n if (!three.renderer && !three.scene) return null;\n const out: Record<string, unknown> = {};\n\n const info = asRecord(three.renderer?.[\"info\"]);\n const render = asRecord(info?.[\"render\"]);\n const memory = asRecord(info?.[\"memory\"]);\n if (render) {\n // `calls` у WebGLRenderer, `drawCalls` у WebGPURenderer — представляются они одинаково.\n out[\"drawCalls\"] = num(render[\"calls\"]) ?? num(render[\"drawCalls\"]);\n out[\"triangles\"] = num(render[\"triangles\"]);\n out[\"lines\"] = num(render[\"lines\"]);\n out[\"points\"] = num(render[\"points\"]);\n out[\"framesRendered\"] = num(render[\"frame\"]);\n }\n if (memory) {\n out[\"geometries\"] = num(memory[\"geometries\"]);\n out[\"textures\"] = num(memory[\"textures\"]);\n }\n\n const scene = three.scene;\n const traverse = scene?.[\"traverse\"];\n if (scene && typeof traverse === \"function\") {\n let total = 0;\n let meshes = 0;\n let lights = 0;\n let hidden = 0;\n try {\n (traverse as (cb: (obj: unknown) => void) => void).call(\n scene,\n (obj: unknown) => {\n if (total > 20000) return; // защита от сцены-монстра: считаем, но не вечно\n total++;\n const node = asRecord(obj);\n if (!node) return;\n if (node[\"isMesh\"] === true) meshes++;\n if (node[\"isLight\"] === true) lights++;\n if (node[\"visible\"] === false) hidden++;\n },\n );\n out[\"scene\"] = {\n name: typeof scene[\"name\"] === \"string\" ? scene[\"name\"] : null,\n objects: total,\n meshes,\n lights,\n hidden,\n scenesCreated: three.scenes,\n };\n } catch {\n out[\"scene\"] = { error: \"scene.traverse failed\" };\n }\n }\n\n // Позиция и направление камеры — прямо из мировой матрицы: третий столбец это её «взгляд»\n // (с минусом — камера в three смотрит вдоль -Z). Так не нужен импорт three ради Vector3.\n const elements = asRecord(three.camera?.[\"matrixWorld\"])?.[\"elements\"] as\n ArrayLike<number> | undefined;\n if (elements && elements.length >= 16) {\n const dx = -(elements[8] ?? 0);\n const dy = -(elements[9] ?? 0);\n const dz = -(elements[10] ?? 0);\n const len = Math.hypot(dx, dy, dz) || 1;\n out[\"camera\"] = {\n pos: {\n x: round(elements[12] ?? 0),\n y: round(elements[13] ?? 0),\n z: round(elements[14] ?? 0),\n },\n lookDir: {\n x: round(dx / len),\n y: round(dy / len),\n z: round(dz / len),\n },\n fov: num(three.camera?.[\"fov\"]),\n };\n }\n\n return out;\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\n/* ------------------------------------------------------------------- Pixi */\n\nconst pixi: {\n app: Record<string, unknown> | null;\n renderer: Record<string, unknown> | null;\n version: string | null;\n} = { app: null, renderer: null, version: null };\n\n/**\n * Канал, по которому PixiJS сам представляется наблюдателю — точный аналог `__THREE_DEVTOOLS__`.\n *\n * Pixi 8 при инициализации зовёт `globalThis.__PIXI_APP_INIT__(app, VERSION)`, а его рендерер —\n * `__PIXI_RENDERER_INIT__(renderer, VERSION)` (см. `utils/global/globalHooks`). Хуки задуманы для\n * расширения-девтулзов, и это ровно то, что нужно: игру править не надо, а на выходе живые\n * Application и Renderer. Ставить их обязательно ДО инициализации Pixi — отсюда установка при\n * импорте зонда.\n *\n * Оба хука ставятся не «вместо», а «поверх»: прежний (расширение браузера) вызывается следом.\n */\nfunction capturePixi(): void {\n type Hook = (target: unknown, version?: string) => void;\n const holder = globalThis as typeof globalThis & {\n __PIXI_APP_INIT__?: Hook;\n __PIXI_RENDERER_INIT__?: Hook;\n };\n\n const previousApp = holder.__PIXI_APP_INIT__;\n holder.__PIXI_APP_INIT__ = (app: unknown, version?: string): void => {\n try {\n pixi.app = asRecord(app);\n pixi.version = version ?? pixi.version;\n } catch {\n /* чужой объект не обязан быть таким, как мы ждём */\n }\n previousApp?.(app, version);\n };\n\n const previousRenderer = holder.__PIXI_RENDERER_INIT__;\n holder.__PIXI_RENDERER_INIT__ = (\n renderer: unknown,\n version?: string,\n ): void => {\n try {\n pixi.renderer = asRecord(renderer);\n pixi.version = version ?? pixi.version;\n } catch {\n /* см. выше */\n }\n previousRenderer?.(renderer, version);\n };\n}\n\n/** Обход дерева отображения Pixi: у него нет `traverse`, только `children`. */\nfunction countPixiTree(root: Record<string, unknown>): Record<string, unknown> {\n let total = 0;\n let hidden = 0;\n let depth = 0;\n\n const walk = (node: Record<string, unknown>, level: number): void => {\n if (total > 20000) return; // потолок как у three: считаем, но не вечно\n total++;\n if (node[\"visible\"] === false || node[\"renderable\"] === false) hidden++;\n if (level > depth) depth = level;\n\n const children = node[\"children\"];\n if (!Array.isArray(children)) return;\n for (const child of children) {\n const record = asRecord(child);\n if (record) walk(record, level + 1);\n }\n };\n\n walk(root, 0);\n // Сам корень объектом сцены не считаем — интересно, что В нём.\n return { objects: Math.max(total - 1, 0), hidden, depth };\n}\n\nfunction pixiSnapshot(): Record<string, unknown> | null {\n const app = pixi.app;\n const renderer = pixi.renderer ?? asRecord(app?.[\"renderer\"]);\n if (!app && !renderer) return null;\n\n const out: Record<string, unknown> = { version: pixi.version };\n\n if (renderer) {\n const type = renderer[\"type\"];\n out[\"renderer\"] = {\n // `name` у Pixi 8 это \"webgl\"/\"webgpu\"; `type` — числовой флаг того же самого.\n backend:\n typeof renderer[\"name\"] === \"string\"\n ? renderer[\"name\"]\n : (num(type) ?? null),\n width: num(renderer[\"width\"]),\n height: num(renderer[\"height\"]),\n resolution: num(renderer[\"resolution\"]),\n };\n }\n\n const ticker = asRecord(app?.[\"ticker\"]);\n if (ticker) {\n const fps = num(ticker[\"FPS\"]);\n out[\"ticker\"] = {\n fps: fps === null ? null : round(fps, 1),\n started: ticker[\"started\"] ?? null,\n };\n }\n\n const stage = asRecord(app?.[\"stage\"]);\n if (stage) {\n const tree = countPixiTree(stage);\n out[\"stage\"] = {\n ...tree,\n label:\n typeof stage[\"label\"] === \"string\"\n ? stage[\"label\"]\n : typeof stage[\"name\"] === \"string\"\n ? stage[\"name\"]\n : null,\n };\n }\n\n return out;\n}\n\n/* ----------------------------------------------------------------- Phaser */\n\n/**\n * У Phaser канала самопредставления НЕТ — и это проверено, а не предположено: `Game.boot` пишет\n * `window.PHASER_GAME = this` только под флагом `WEBGL_DEBUG`, а из готовых сборок\n * (`dist/phaser.esm.js`, которые и ставит npm) эта ветка вырезана целиком.\n *\n * Поэтому игру приходится ИСКАТЬ, а не ждать. Два источника, оба без кооперации игры:\n * 1. `window.PHASER_GAME` — есть в отладочных сборках и в превью, если бандлер взял `main`\n * (у Phaser это исходники, а не dist);\n * 2. скан собственных ключей `window` на сигнатуру `Phaser.Game`.\n *\n * Поиск ленивый (только при сборке снимка) и с кэшем: перебирать глобалы каждый кадр незачем.\n */\n// Кэшируется ИМЯ глобала, а не сам объект: игру можно пересоздать (host-shell перемонтирует сцену\n// при смене режима), и ссылка на прежнюю осталась бы живой в памяти — зонд честно показывал бы\n// уничтоженную игру. Перечитывание по ключу всегда отдаёт текущую.\nlet phaserKey: string | null = null;\n\nfunction looksLikePhaserGame(value: unknown): boolean {\n const game = asRecord(value);\n if (!game) return false;\n return (\n typeof game[\"isBooted\"] === \"boolean\" &&\n asRecord(game[\"scene\"]) !== null &&\n Array.isArray(asRecord(game[\"scene\"])?.[\"scenes\"]) &&\n asRecord(game[\"loop\"]) !== null\n );\n}\n\nfunction findPhaserGame(): Record<string, unknown> | null {\n const holder = globalThis as typeof globalThis & Record<string, unknown>;\n\n const at = (key: string): Record<string, unknown> | null => {\n try {\n return looksLikePhaserGame(holder[key]) ? asRecord(holder[key]) : null;\n } catch {\n // Чтение чужого свойства window может бросить (кросс-доменный фрейм) — не наша забота.\n return null;\n }\n };\n\n if (phaserKey) {\n const cached = at(phaserKey);\n if (cached) return cached;\n phaserKey = null;\n }\n\n for (const key of [\"PHASER_GAME\", ...Object.keys(holder)]) {\n const found = at(key);\n if (found) {\n phaserKey = key;\n return found;\n }\n }\n return null;\n}\n\nfunction phaserSnapshot(): Record<string, unknown> | null {\n const game = findPhaserGame();\n if (!game) return null;\n\n const out: Record<string, unknown> = {\n booted: game[\"isBooted\"] ?? null,\n running: game[\"isRunning\"] ?? null,\n };\n\n const fps = num(asRecord(game[\"loop\"])?.[\"actualFps\"]);\n if (fps !== null) out[\"fps\"] = round(fps, 1);\n\n const renderer = asRecord(game[\"renderer\"]);\n if (renderer) {\n // У Phaser `type` — числовая константа: 1 = CANVAS, 2 = WEBGL.\n const type = num(renderer[\"type\"]);\n out[\"renderer\"] = {\n backend: type === 2 ? \"webgl\" : type === 1 ? \"canvas\" : (type ?? null),\n width: num(renderer[\"width\"]),\n height: num(renderer[\"height\"]),\n };\n }\n\n const scenes = asRecord(game[\"scene\"])?.[\"scenes\"];\n if (Array.isArray(scenes)) {\n const list: Record<string, unknown>[] = [];\n for (const raw of scenes.slice(0, 12)) {\n const sys = asRecord(asRecord(raw)?.[\"sys\"]);\n const settings = asRecord(sys?.[\"settings\"]);\n const displayList = asRecord(sys?.[\"displayList\"]);\n const camera = asRecord(asRecord(sys?.[\"cameras\"])?.[\"main\"]);\n\n const entry: Record<string, unknown> = {\n key: settings?.[\"key\"] ?? null,\n active: settings?.[\"active\"] ?? null,\n visible: settings?.[\"visible\"] ?? null,\n objects: Array.isArray(displayList?.[\"list\"])\n ? (displayList[\"list\"] as unknown[]).length\n : null,\n };\n if (camera) {\n entry[\"camera\"] = {\n scrollX: round(num(camera[\"scrollX\"]) ?? 0),\n scrollY: round(num(camera[\"scrollY\"]) ?? 0),\n zoom: round(num(camera[\"zoom\"]) ?? 1),\n };\n }\n list.push(entry);\n }\n out[\"scenes\"] = list;\n // «Активная» сцена — та, что реально обновляется: по ней и судят, что происходит на экране.\n out[\"activeScenes\"] = list.filter((s) => s[\"active\"] === true).length;\n }\n\n return out;\n}\n\n/* ------------------------------------------------------------- пиксели */\n\nfunction hex(r: number, g: number, b: number): string {\n const part = (v: number): string => v.toString(16).padStart(2, \"0\");\n return `#${part(r)}${part(g)}${part(b)}`;\n}\n\n/**\n * Чтение редкой сетки пикселей — 36 точек вместо картинки: скриншотов здесь нет по решению\n * владельца, а «все 36 проб одного цвета» ловит белый экран ничуть не хуже.\n *\n * Читать можно в любой момент, потому что зонд принудительно включает `preserveDrawingBuffer`\n * (см. `captureCanvases`); без него содержимое буфера пропадало бы сразу после композитинга кадра.\n */\nfunction readGrid(rec: CanvasRecord): number[][] | null {\n const width = rec.canvas.width;\n const height = rec.canvas.height;\n if (width < 2 || height < 2) return null;\n\n const at = (i: number, size: number): number =>\n Math.min(size - 1, Math.floor(((i + 0.5) / PIXEL_GRID) * size));\n\n if (rec.gl) {\n const gl = rec.gl;\n if (gl.isContextLost()) return null;\n\n // Игра могла оставить привязанным свой render target — тогда мы прочли бы не экран. Снимаем\n // привязку на время чтения и возвращаем ровно ту, что была.\n const previous = gl.getParameter(\n gl.FRAMEBUFFER_BINDING,\n ) as WebGLFramebuffer | null;\n if (previous) gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\n const pixel = new Uint8Array(4);\n const samples: number[][] = [];\n for (let iy = 0; iy < PIXEL_GRID; iy++) {\n for (let ix = 0; ix < PIXEL_GRID; ix++) {\n gl.readPixels(\n at(ix, width),\n at(iy, height),\n 1,\n 1,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n pixel,\n );\n samples.push([\n pixel[0] ?? 0,\n pixel[1] ?? 0,\n pixel[2] ?? 0,\n pixel[3] ?? 0,\n ]);\n }\n }\n\n if (previous) gl.bindFramebuffer(gl.FRAMEBUFFER, previous);\n return samples;\n }\n\n if (rec.ctx2d) {\n const samples: number[][] = [];\n for (let iy = 0; iy < PIXEL_GRID; iy++) {\n for (let ix = 0; ix < PIXEL_GRID; ix++) {\n const data = rec.ctx2d.getImageData(\n at(ix, width),\n at(iy, height),\n 1,\n 1,\n ).data;\n samples.push([data[0] ?? 0, data[1] ?? 0, data[2] ?? 0, data[3] ?? 0]);\n }\n }\n return samples;\n }\n\n return null;\n}\n\nfunction pixelStats(samples: number[][]): Record<string, unknown> {\n const colours = new Set<string>();\n let transparent = 0;\n let r = 0;\n let g = 0;\n let b = 0;\n\n for (const [pr, pg, pb, pa] of samples) {\n const alpha = pa ?? 0;\n if (alpha < 8) transparent++;\n r += pr ?? 0;\n g += pg ?? 0;\n b += pb ?? 0;\n colours.add(hex(pr ?? 0, pg ?? 0, pb ?? 0));\n }\n\n const n = samples.length || 1;\n const uniform = colours.size <= 1;\n const average = hex(Math.round(r / n), Math.round(g / n), Math.round(b / n));\n\n return {\n sampled: samples.length,\n distinctColours: colours.size,\n uniform,\n colour: uniform ? ([...colours][0] ?? null) : null,\n averageColour: average,\n transparentSamples: transparent,\n note: uniform\n ? `every sampled pixel is the same colour — the frame is a flat fill (a blank/white/black screen looks exactly like this)`\n : `${colours.size} distinct colours across ${samples.length} samples — something is actually drawn`,\n };\n}\n\n/** Прочитать сетку прямо сейчас, обернув всё, что может пойти не так, в ответ, а не в исключение. */\nfunction readNow(\n rec: CanvasRecord,\n extra: Record<string, unknown>,\n): Record<string, unknown> {\n try {\n const samples = readGrid(rec);\n return samples\n ? { ...pixelStats(samples), ...extra }\n : { sampled: 0, note: \"could not read pixels from this canvas\" };\n } catch (error: unknown) {\n return { sampled: 0, note: `pixel read failed: ${stringifyArg(error)}` };\n }\n}\n\n/**\n * Прочитать пиксели, по возможности — в кадре, где что-то нарисовано.\n *\n * Сначала ждём кадр с отрисовкой: у живой игры это самая свежая картинка. Не дождались — читаем всё\n * равно: буфер сохраняется принудительно (см. `captureCanvases`), поэтому там лежит ПОСЛЕДНИЙ\n * нарисованный кадр. Отдать в такой ситуации «ничего не вижу» было бы худшим из ответов: именно\n * когда петля встала, картинка нужнее всего.\n */\nfunction samplePixels(rec: CanvasRecord): Promise<Record<string, unknown>> {\n return new Promise((resolve) => {\n if (!rawRaf) {\n resolve(\n readNow(rec, {\n drewDuringSample: false,\n frameNote: \"read outside an animation frame\",\n }),\n );\n return;\n }\n\n let attempts = 0;\n let settled = false;\n const finish = (value: Record<string, unknown>): void => {\n if (settled) return;\n settled = true;\n window.clearTimeout(timer);\n resolve(value);\n };\n\n const timer = window.setTimeout(\n () =>\n finish(\n readNow(rec, {\n drewDuringSample: false,\n staleFrame: true,\n frameNote:\n `nothing was drawn within ${FRAME_WAIT_MS}ms, so this is the LAST frame the game ` +\n \"rendered, not a live one — the render loop is stopped, the scene only redraws on \" +\n \"demand, or the preview tab is in the background\",\n }),\n ),\n FRAME_WAIT_MS,\n );\n\n const tick = (): void => {\n if (settled) return;\n const before = rec.frames + rec.draws;\n rawRaf(() => {\n if (settled) return;\n const drew = rec.frames + rec.draws > before;\n attempts++;\n // Ещё один шанс поймать кадр с отрисовкой; на третьей попытке читаем как есть.\n if (!drew && attempts < 3) {\n tick();\n return;\n }\n finish(readNow(rec, { drewDuringSample: drew }));\n });\n };\n\n tick();\n });\n}\n\n/* --------------------------------------------------------- сборка снимка */\n\n/** Полотна документа, самое большое — первым: оно почти всегда и есть игра. */\nfunction canvasesByArea(): {\n el: HTMLCanvasElement;\n rec: CanvasRecord | null;\n}[] {\n const list = Array.from(document.querySelectorAll(\"canvas\")).map((el) => ({\n el,\n rec: canvasRecords.find((rec) => rec.canvas === el) ?? null,\n }));\n return list.sort(\n (a, b) => b.el.width * b.el.height - a.el.width * a.el.height,\n );\n}\n\nfunction describeCanvas(entry: {\n el: HTMLCanvasElement;\n rec: CanvasRecord | null;\n}): Record<string, unknown> {\n const rect = entry.el.getBoundingClientRect();\n const rec = entry.rec;\n return {\n context: rec?.kind ?? \"unknown (context created before the probe, or none)\",\n buffer: `${entry.el.width}x${entry.el.height}`,\n onScreen: `${Math.round(rect.width)}x${Math.round(rect.height)}`,\n visible: rect.width > 0 && rect.height > 0,\n framesDrawn: rec?.frames ?? null,\n drawCalls: rec?.draws ?? null,\n msSinceLastDraw:\n rec && rec.lastDrawAt > 0 ? Date.now() - rec.lastDrawAt : null,\n };\n}\n\nfunction platformState(): Record<string, unknown> {\n const host = agentGlobal()?.host;\n return {\n titleId: probeOptions.titleId ?? host?.titleId ?? null,\n url: window.location.href,\n // Экран хоста: loading | login | game. «Игрок не залогинен» — самая частая причина того, что\n // «игра не работает», и без этой строки агент ищет причину в коде игры.\n screen: host?.screen ?? \"unknown (host state not published)\",\n loggedIn: host?.loggedIn ?? null,\n userId: host?.userId ?? null,\n modulesInstalled: host?.modules ?? null,\n // Роли общего интерфейса. Отвечает на «почему у шаблона пропал кошелёк/баланс»: его взял\n // поставщик роли (обычно game-hud), это не поломка.\n sharedUi: host?.sharedUi ?? null,\n };\n}\n\n/** Журнал шины событий. Чужой код — поэтому под try: сломанный журнал не должен ронять ответ. */\nfunction eventBusState(): unknown {\n const read = agentGlobal()?.events;\n if (typeof read !== \"function\") return null;\n try {\n return read();\n } catch (error: unknown) {\n return { error: stringifyArg(error) };\n }\n}\n\n/**\n * Автоматический слой: что видно в приложении БЕЗ его участия.\n *\n * Возвращается всегда — и когда модули открылись агенту, и когда нет. Слепой тишины быть не должно:\n * даже у игры, о которой никто ничего не рассказал, есть полотно, кадры и цвет экрана.\n */\nasync function observeRuntime(options: {\n pixels: boolean;\n}): Promise<Record<string, unknown>> {\n const canvases = canvasesByArea();\n const main = canvases[0] ?? null;\n const notes: string[] = [];\n\n const glFps = perSecond(glFrameTimes);\n const rafFps = perSecond(rafFrameTimes);\n const rendering: Record<string, unknown> = {\n fps: glFps > 0 ? glFps : rafFps,\n fpsSource:\n glFps > 0 ? \"canvas clear() calls\" : \"requestAnimationFrame callbacks\",\n animationFramesPerSecond: rafFps,\n canvasFramesPerSecond: glFps,\n };\n\n // Движки опрашиваются ДО заметок про «ничего не анимируется»: их собственный счётчик кадров эту\n // заметку отменяет, и выдать обе разом значило бы противоречить самому себе в одном ответе.\n const threeInfo = threeSnapshot();\n const pixiInfo = pixiSnapshot();\n const phaserInfo = phaserSnapshot();\n\n // Кадры, о которых отчитывается САМ движок. Им веры больше, чем счётчику по полотну: движок не\n // обязан чистить буфер каждый кадр, и тогда наш счётчик занижает. Поймано вживую — Phaser\n // сообщал 60, а полотно давало 1.\n const engineFps =\n num(phaserInfo?.[\"fps\"]) ?? num(asRecord(pixiInfo?.[\"ticker\"])?.[\"fps\"]);\n const canvasFps = glFps > 0 ? glFps : rafFps;\n const engineRunning = engineFps !== null && engineFps > 5;\n if (engineFps !== null) rendering[\"engineFps\"] = engineFps;\n\n if (!main) {\n notes.push(\n \"No <canvas> in the document: this is a DOM app (or the game has not mounted its canvas yet). Read the page instead.\",\n );\n } else if (engineRunning && canvasFps <= 5) {\n notes.push(\n `The engine reports ${engineFps} fps while the canvas counter sees almost none. Trust the engine: the counter only sees frames that clear the buffer, and a page that has just rebuilt has not accumulated any yet. The game IS running.`,\n );\n } else if (rafFps === 0 && glFps === 0) {\n // Порядок причин здесь не случаен: пауза стоит ПЕРВОЙ, потому что она самая частая и самая\n // безобидная. Живой прогон показал именно её — игра ждала клика по полотну, а заметка звучала\n // как «петля мертва», то есть звала чинить исправное.\n notes.push(\n \"Nothing is animating: no animation frame ran in the last second. Most often the game is simply PAUSED and waiting for the player to click the canvas (a click via SendInput starts it — check the frames again after that). It is also normal for a scene that only redraws on demand. Only if neither applies is the render loop actually dead (an exception inside it, or it was never started) — the console says which.\",\n );\n }\n\n if (!threeInfo && !pixiInfo && !phaserInfo && main?.rec) {\n notes.push(\n \"A canvas is present but no engine identified itself: three.js and PixiJS announce themselves automatically, and Phaser is looked up in the page globals. So this is either another engine (Babylon, raw WebGL/2d), or Phaser that keeps its Game object out of reach. The canvas numbers above (fps, draw calls, pixels) still hold — only the scene/camera detail is missing.\",\n );\n }\n\n let pixels: Record<string, unknown> | null = null;\n if (options.pixels && main?.rec) {\n pixels = await samplePixels(main.rec);\n if (pixels[\"uniform\"] === true) {\n notes.push(\n \"The sampled frame is ONE flat colour. Together with a live fps that usually means the scene renders but nothing is in view (camera inside geometry, everything culled, materials/lights missing); with fps 0 it means nothing is being drawn at all.\",\n );\n }\n }\n\n return {\n platform: platformState(),\n canvas: main ? describeCanvas(main) : null,\n otherCanvases: canvases.length > 1 ? canvases.length - 1 : 0,\n rendering,\n three: threeInfo,\n pixi: pixiInfo,\n phaser: phaserInfo,\n pixels,\n notes,\n };\n}\n\n/** Однострочная выжимка автоматического слоя — она подмешивается в снимок страницы. */\nfunction summarize(observation: Record<string, unknown>): string {\n const parts: string[] = [];\n\n const canvas = asRecord(observation[\"canvas\"]);\n if (canvas)\n parts.push(\n `canvas ${String(canvas[\"buffer\"])} (${String(canvas[\"context\"])})`,\n );\n\n const rendering = asRecord(observation[\"rendering\"]);\n if (rendering) parts.push(`${String(rendering[\"fps\"])} fps`);\n\n const threeInfo = asRecord(observation[\"three\"]);\n if (threeInfo) {\n const scene = asRecord(threeInfo[\"scene\"]);\n if (scene)\n parts.push(`three.js scene: ${String(scene[\"objects\"])} objects`);\n if (threeInfo[\"drawCalls\"] !== null && threeInfo[\"drawCalls\"] !== undefined)\n parts.push(`${String(threeInfo[\"drawCalls\"])} draw calls`);\n }\n\n const pixiInfo = asRecord(observation[\"pixi\"]);\n const pixiStage = asRecord(pixiInfo?.[\"stage\"]);\n if (pixiStage)\n parts.push(`PixiJS stage: ${String(pixiStage[\"objects\"])} display objects`);\n\n const phaserInfo = asRecord(observation[\"phaser\"]);\n if (phaserInfo) {\n const scenes = phaserInfo[\"scenes\"];\n parts.push(\n `Phaser: ${String(phaserInfo[\"activeScenes\"] ?? 0)} active scene(s)` +\n (Array.isArray(scenes) ? ` of ${scenes.length}` : \"\"),\n );\n }\n\n const pixels = asRecord(observation[\"pixels\"]);\n if (pixels && num(pixels[\"sampled\"])) {\n parts.push(\n pixels[\"uniform\"] === true\n ? `the whole frame is ${String(pixels[\"colour\"])}`\n : `${String(pixels[\"distinctColours\"])} distinct colours on screen`,\n );\n }\n\n const platform = asRecord(observation[\"platform\"]);\n if (platform && platform[\"screen\"])\n parts.push(`host screen: ${String(platform[\"screen\"])}`);\n\n return parts.join(\", \");\n}\n\n/* ------------------------------------------------------ синтетический ввод */\n\n// Универсальные «руки» — для игр, которые НЕ описали свои действия через `exposeToAgent`.\n// Работает не всегда, и это честно сказано в ответе: движок, который гейтит ввод на Pointer Lock,\n// синтетические события игнорирует, а Pointer Lock в кросс-доменном iframe запрещён браузером.\n\nconst KEY_CODES: Record<string, number> = {\n Space: 32,\n Enter: 13,\n Escape: 27,\n Tab: 9,\n Backspace: 8,\n ArrowLeft: 37,\n ArrowUp: 38,\n ArrowRight: 39,\n ArrowDown: 40,\n ShiftLeft: 16,\n ShiftRight: 16,\n ControlLeft: 17,\n ControlRight: 17,\n};\n\n/** `key` по `code`: движки читают то одно, то другое, поэтому заполняем оба. */\nfunction keyFromCode(code: string): string {\n if (code.startsWith(\"Key\")) return code.slice(3).toLowerCase();\n if (code.startsWith(\"Digit\")) return code.slice(5);\n if (code === \"Space\") return \" \";\n if (code.startsWith(\"Shift\")) return \"Shift\";\n if (code.startsWith(\"Control\")) return \"Control\";\n if (code.startsWith(\"Alt\")) return \"Alt\";\n return code;\n}\n\nfunction legacyKeyCode(code: string): number {\n const known = KEY_CODES[code];\n if (known) return known;\n if (code.startsWith(\"Key\")) return code.charCodeAt(3);\n if (code.startsWith(\"Digit\")) return 48 + Number(code.slice(5));\n return 0;\n}\n\n/** Кого считаем игрой: самое большое полотно, иначе — активный элемент. */\nfunction inputTarget(): EventTarget {\n const main = canvasesByArea()[0];\n return main?.el ?? document.activeElement ?? document.body ?? window;\n}\n\nfunction keyEvent(type: string, code: string): KeyboardEvent {\n const event = new KeyboardEvent(type, {\n code,\n key: keyFromCode(code),\n bubbles: true,\n cancelable: true,\n composed: true,\n });\n // keyCode/which в конструкторе не поддерживаются, а игры на них до сих пор смотрят.\n const legacy = legacyKeyCode(code);\n Object.defineProperty(event, \"keyCode\", { get: () => legacy });\n Object.defineProperty(event, \"which\", { get: () => legacy });\n return event;\n}\n\nfunction wait(ms: number): Promise<void> {\n return new Promise((resolve) => window.setTimeout(resolve, ms));\n}\n\nasync function sendKey(code: string, ms: number): Promise<void> {\n const target = inputTarget();\n if (target instanceof HTMLElement) target.focus?.();\n target.dispatchEvent(keyEvent(\"keydown\", code));\n await wait(Math.min(Math.max(ms, 16), MAX_INPUT_HOLD_MS));\n target.dispatchEvent(keyEvent(\"keyup\", code));\n}\n\n/** Координата: 0..1 читается как доля полотна, больше — как CSS-пиксели. */\nfunction resolveCoord(value: unknown, size: number): number {\n const n = typeof value === \"number\" && Number.isFinite(value) ? value : 0.5;\n return n >= 0 && n <= 1 ? n * size : n;\n}\n\n/** Типы, после которых кнопка уже отпущена: у них `buttons` обязан быть нулём. */\nconst RELEASE_EVENTS = new Set([\n \"mouseup\",\n \"pointerup\",\n \"click\",\n \"mousemove\",\n \"pointermove\",\n]);\n\n/**\n * Событие указателя. Для `pointer*` строим именно PointerEvent: движки на pointer-событиях\n * (Phaser 4) читают у него `pointerId`/`isPrimary`, и обычный MouseEvent они отбрасывают.\n */\nfunction pointerLikeEvent(\n type: string,\n x: number,\n y: number,\n button: number,\n movement?: { dx: number; dy: number },\n): MouseEvent {\n const init: PointerEventInit = {\n clientX: x,\n clientY: y,\n button,\n buttons: RELEASE_EVENTS.has(type) ? 0 : 1 << button,\n bubbles: true,\n cancelable: true,\n composed: true,\n movementX: movement?.dx ?? 0,\n movementY: movement?.dy ?? 0,\n };\n\n if (type.startsWith(\"pointer\") && typeof PointerEvent === \"function\") {\n return new PointerEvent(type, {\n ...init,\n pointerId: 1,\n pointerType: \"mouse\",\n isPrimary: true,\n });\n }\n return new MouseEvent(type, init);\n}\n\nasync function sendClick(\n xArg: unknown,\n yArg: unknown,\n button: number,\n): Promise<void> {\n const target = inputTarget();\n const element = target instanceof Element ? target : document.body;\n const rect = element.getBoundingClientRect();\n const x = rect.left + resolveCoord(xArg, rect.width);\n const y = rect.top + resolveCoord(yArg, rect.height);\n\n for (const type of [\"pointerdown\", \"mousedown\"]) {\n target.dispatchEvent(pointerLikeEvent(type, x, y, button));\n }\n await wait(30);\n for (const type of [\"pointerup\", \"mouseup\", \"click\"]) {\n target.dispatchEvent(pointerLikeEvent(type, x, y, button));\n }\n}\n\nasync function sendMove(dx: number, dy: number): Promise<void> {\n const target = inputTarget();\n const element = target instanceof Element ? target : document.body;\n const rect = element.getBoundingClientRect();\n const x = rect.left + rect.width / 2 + dx;\n const y = rect.top + rect.height / 2 + dy;\n // movementX/movementY — то, что читает камера от первого лица; clientX/Y — то, что читают\n // обычные обработчики. Заполняем оба, чтобы не гадать, какой путь у игры.\n target.dispatchEvent(pointerLikeEvent(\"mousemove\", x, y, 0, { dx, dy }));\n target.dispatchEvent(pointerLikeEvent(\"pointermove\", x, y, 0, { dx, dy }));\n await wait(16);\n}\n\n/**\n * Синтетический ввод + снимок ПОСЛЕ него (как readPage после клика).\n *\n * Ответ всегда несёт `pointerLockActive`: если игра требует захвата указателя, ввод до неё не\n * дойдёт — и агент должен прочитать это как «управление не проверено», а не «управление сломано».\n */\nasync function sendInput(raw: unknown): Promise<unknown> {\n const args = asRecord(raw) ?? {};\n const type = String(args[\"type\"] ?? \"key\");\n\n switch (type) {\n case \"key\": {\n const code = String(args[\"code\"] ?? args[\"key\"] ?? \"\");\n if (!code)\n throw new Error(\"input type 'key' needs a code, e.g. \\\"KeyW\\\"\");\n await sendKey(code, Number(args[\"ms\"] ?? 200));\n break;\n }\n case \"click\":\n await sendClick(args[\"x\"], args[\"y\"], Number(args[\"button\"] ?? 0));\n break;\n case \"move\":\n await sendMove(Number(args[\"dx\"] ?? 0), Number(args[\"dy\"] ?? 0));\n break;\n default:\n throw new Error(`unknown input type '${type}' — use key | click | move`);\n }\n\n await settle();\n // Именно на истинность, а не `!== null`: там, где Pointer Lock не поддержан вовсе, свойство\n // приходит `undefined`, и строгое сравнение объявило бы захват активным, которого нет.\n const locked = Boolean(document.pointerLockElement);\n return {\n sent: { type, ...args },\n pointerLockActive: locked,\n note: locked\n ? \"Pointer Lock is active, so the game receives this input the same way it receives the player's.\"\n : \"Synthetic input was dispatched. If the game gates controls on Pointer Lock it ignored this — Pointer Lock cannot be acquired inside the preview iframe. A module's exposeToAgent actions are the reliable path.\",\n observation: await observeRuntime({ pixels: true }),\n modules: moduleSurfaces(),\n };\n}\n\n/* ------------------------------------------------------- сериализация DOM */\n\nconst SKIP_TAGS = new Set([\n \"SCRIPT\",\n \"STYLE\",\n \"LINK\",\n \"META\",\n \"NOSCRIPT\",\n \"TEMPLATE\",\n \"HEAD\",\n]);\n\nconst INTERACTIVE_TAGS = new Set([\n \"BUTTON\",\n \"A\",\n \"INPUT\",\n \"SELECT\",\n \"TEXTAREA\",\n]);\n\nfunction isInteractive(el: Element): boolean {\n if (INTERACTIVE_TAGS.has(el.tagName)) return true;\n const role = el.getAttribute(\"role\");\n if (\n role === \"button\" ||\n role === \"link\" ||\n role === \"tab\" ||\n role === \"menuitem\"\n )\n return true;\n return el.hasAttribute(\"data-testid\") && el.hasAttribute(\"tabindex\");\n}\n\nfunction isVisible(el: Element): boolean {\n const rect = el.getBoundingClientRect();\n if (rect.width > 0 && rect.height > 0) return true;\n // Нулевой прямоугольник у контейнера — норма (например, обёртка с absolute-детьми):\n // считаем видимым, если браузер не выключил его целиком.\n const style = window.getComputedStyle(el);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n}\n\n/** Собственный текст узла — без текста детей (их напечатают они сами). */\nfunction ownText(el: Element): string {\n let text = \"\";\n for (const node of Array.from(el.childNodes)) {\n if (node.nodeType === Node.TEXT_NODE) text += node.textContent ?? \"\";\n }\n return clip(text, MAX_TEXT);\n}\n\nfunction describe(el: Element, ref: string | null): string {\n const parts: string[] = [el.tagName.toLowerCase()];\n\n const id = el.getAttribute(\"id\");\n if (id) parts[0] += `#${id}`;\n\n const cls = el.getAttribute(\"class\");\n if (cls) {\n const first = cls.trim().split(/\\s+/).slice(0, 2).join(\".\");\n if (first) parts[0] += `.${first}`;\n }\n\n if (ref) parts.push(`[${ref}]`);\n\n const label = el.getAttribute(\"aria-label\");\n const testId = el.getAttribute(\"data-testid\");\n if (testId) parts.push(`testid=${testId}`);\n\n const text = ownText(el) || (label ? clip(label, MAX_TEXT) : \"\");\n if (text) parts.push(JSON.stringify(text));\n\n const flags: string[] = [];\n if (el.hasAttribute(\"disabled\")) flags.push(\"disabled\");\n if ((el as HTMLInputElement).checked) flags.push(\"checked\");\n if (el.tagName === \"INPUT\" || el.tagName === \"TEXTAREA\") {\n const value = (el as HTMLInputElement).value;\n if (value) flags.push(`value=${JSON.stringify(clip(value, 40))}`);\n const placeholder = el.getAttribute(\"placeholder\");\n if (placeholder)\n flags.push(`placeholder=${JSON.stringify(clip(placeholder, 40))}`);\n }\n if (el.tagName === \"CANVAS\") {\n const canvas = el as HTMLCanvasElement;\n flags.push(`${canvas.width}x${canvas.height}`);\n }\n if (flags.length) parts.push(`(${flags.join(\", \")})`);\n\n return parts.join(\" \");\n}\n\nfunction readDom(): { tree: string; truncated: boolean } {\n refs = new Map<string, Element>();\n const lines: string[] = [];\n let nodes = 0;\n let refSeq = 0;\n let truncated = false;\n\n const walk = (el: Element, depth: number): void => {\n if (truncated) return;\n if (SKIP_TAGS.has(el.tagName)) return;\n if (nodes >= MAX_NODES || depth > MAX_DEPTH) {\n truncated = true;\n return;\n }\n\n const visible = isVisible(el);\n let ref: string | null = null;\n if (visible && isInteractive(el)) {\n ref = `ref_${++refSeq}`;\n refs.set(ref, el);\n }\n\n const line = `${\" \".repeat(depth)}${describe(el, ref)}${visible ? \"\" : \" (hidden)\"}`;\n lines.push(line);\n nodes++;\n\n // В скрытое поддерево не спускаемся: сам факт «модалка есть и она скрыта» полезен, её\n // внутренности — нет.\n if (!visible) return;\n\n const children = Array.from(el.children);\n const shown = children.slice(0, MAX_SIBLINGS);\n for (const child of shown) walk(child, depth + 1);\n if (children.length > shown.length) {\n lines.push(\n `${\" \".repeat(depth + 1)}… +${children.length - shown.length} more sibling(s)`,\n );\n }\n };\n\n if (document.body) walk(document.body, 0);\n\n let tree = lines.join(\"\\n\");\n if (tree.length > MAX_TREE_CHARS) {\n tree = `${tree.slice(0, MAX_TREE_CHARS)}\\n… (tree truncated)`;\n truncated = true;\n }\n\n return { tree, truncated };\n}\n\n/**\n * Полотно, которое стоит считать «главным экраном»: либо оно занимает заметную часть окна, либо в\n * дереве вообще не за что зацепиться. Маленький canvas рядом с обычным интерфейсом (график,\n * спарклайн, аватар) главным экраном не объявляем — иначе снимок каждой DOM-страницы обрастал бы\n * рассказом про отрисованную игру, которой там нет.\n */\nfunction dominantCanvas(): {\n el: HTMLCanvasElement;\n rec: CanvasRecord | null;\n} | null {\n const main = canvasesByArea()[0];\n if (!main) return null;\n\n const rect = main.el.getBoundingClientRect();\n const viewport = window.innerWidth * window.innerHeight;\n const share = viewport > 0 ? (rect.width * rect.height) / viewport : 0;\n if (share >= 0.15) return main;\n\n // Нулевой прямоугольник — не обязательно «полотна не видно»: измерять могли до раскладки. Тогда\n // судим по размеру самого буфера, иначе снимок игры с HUD-кнопкой молча терял бы весь рассказ\n // про экран (поймано прогоном под jsdom, где размеров нет вообще).\n const unmeasured = rect.width === 0 && rect.height === 0;\n if (unmeasured && main.el.width >= 200 && main.el.height >= 200) return main;\n\n return refs.size === 0 ? main : null;\n}\n\n/**\n * Снимок страницы: дерево DOM плюс — у отрисованной игры — выжимка автоматического наблюдения.\n *\n * У Three/Phaser дерево честно пустое: весь мир внутри одного `<canvas>`. Раньше здесь стояла\n * только пометка «это не пустой экран», и агент оставался ни с чем. Теперь в ту же строку уезжают\n * настоящие цифры (кадры, объекты сцены, цвет полотна) — их зонд добывает сам, без участия игры.\n */\nasync function readPage(): Promise<{ tree: string; truncated: boolean }> {\n const page = readDom();\n // Порядок важен: `dominantCanvas` смотрит на `refs`, которые заполняет `readDom`.\n if (!dominantCanvas()) return page;\n\n const exposed = Object.keys(agentModules());\n const head =\n \"\\n\\n[This screen is drawn into a <canvas>: the DOM above says nothing about what happens \" +\n \"inside it, so a tree with nothing in it is NOT an empty screen.\";\n\n let note = head;\n try {\n const summary = summarize(await observeRuntime({ pixels: true }));\n if (summary) note += ` Observed automatically: ${summary}.`;\n } catch {\n /* автоматический слой не обязан удаваться — дерево важнее и уже собрано */\n }\n\n note +=\n exposed.length > 0\n ? ` Call GetGameState for the full picture — modules exposing a debug surface: ${exposed.join(\", \")}.]`\n : ` Call GetGameState for the full picture. No module exposes a debug surface ` +\n `(ctx.exposeToAgent), so gameplay state beyond these numbers is not observable — adding that ` +\n `surface to the game module is what makes it observable.]`;\n\n return { tree: page.tree + note, truncated: page.truncated };\n}\n\n/* ------------------------------------------------------------- действия */\n\nfunction settle(): Promise<void> {\n return new Promise((resolve) => window.setTimeout(resolve, SETTLE_MS));\n}\n\nfunction resolveRef(ref: unknown): Element {\n if (typeof ref !== \"string\") throw new Error(\"ref is required\");\n const el = refs.get(ref);\n if (!el) throw new Error(`${ref} is unknown — call readPage first`);\n if (!el.isConnected)\n throw new Error(\n `${ref} is no longer in the document — call readPage again`,\n );\n return el;\n}\n\nasync function clickRef(ref: unknown): Promise<unknown> {\n const el = resolveRef(ref);\n if (typeof (el as HTMLElement).click !== \"function\")\n throw new Error(\"element is not clickable\");\n (el as HTMLElement).click();\n await settle();\n return readPage();\n}\n\nasync function typeIntoRef(ref: unknown, text: unknown): Promise<unknown> {\n const el = resolveRef(ref);\n if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement))\n throw new Error(\"element is not a text field\");\n\n // Контролируемому React-полю мало el.value = …: React слушает нативный сеттер, и без него\n // состояние компонента не обновится, а значение откатится на следующем рендере.\n const proto =\n el instanceof HTMLInputElement\n ? HTMLInputElement.prototype\n : HTMLTextAreaElement.prototype;\n const setter = Object.getOwnPropertyDescriptor(proto, \"value\")?.set;\n if (setter) setter.call(el, String(text ?? \"\"));\n else el.value = String(text ?? \"\");\n\n el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n el.dispatchEvent(new Event(\"change\", { bubbles: true }));\n await settle();\n return readPage();\n}\n\n/* ------------------------------------------------- состояние игровых модулей */\n\n/**\n * Снимок всех модулей, которые открылись агенту, плюс перечень их действий.\n *\n * Ошибку в чужом `state()` не роняем на весь ответ: один сломанный модуль не должен ослеплять\n * агента по остальным — он получит текст ошибки ровно на месте этого модуля.\n */\nfunction moduleSurfaces(): Record<string, unknown> {\n const modules = agentModules();\n const ids = Object.keys(modules);\n if (ids.length === 0) {\n return {\n available: false,\n hint:\n \"No module exposes a debug surface (ctx.exposeToAgent), so nothing beyond the automatic \" +\n \"observation above can be seen: player position, score, current turn and the ability to DRIVE \" +\n \"the game all come from that surface. If you need them, add exposeToAgent to the game module's \" +\n \"setup() — it is a few lines and it is what makes the game verifiable from here.\",\n };\n }\n\n const out: Record<string, unknown> = {};\n for (const id of ids) {\n const api = modules[id];\n try {\n out[id] = {\n state: api?.state ? api.state() : null,\n actions: api?.describeActions ?? {},\n };\n } catch (error: unknown) {\n out[id] = { error: stringifyArg(error) };\n }\n }\n return { available: true, modules: out };\n}\n\n/**\n * Ответ на `gameState`: автоматический слой ВСЕГДА, поверхности модулей — если они есть.\n *\n * Порядок именно такой и он важен: даже игра, о которой никто ничего не рассказал, отвечает\n * цифрами (полотно, кадры, сцена, цвет экрана), а не пустотой. «Мне ничего не видно» — худший\n * из возможных ответов: он неотличим от «на экране пусто» и толкает агента чинить исправное.\n */\nasync function gameState(): Promise<unknown> {\n return {\n observed: await observeRuntime({ pixels: true }),\n exposedByGame: moduleSurfaces(),\n // События между модулями: log (последние 50), listeners/emitted (кто что слушает и шлёт) и\n // mismatches — подсказки вида «слушают @1, а шлют @2».\n events: eventBusState(),\n };\n}\n\n/** Выполнить действие модуля и вернуть состояние ПОСЛЕ него — как readPage после клика. */\nasync function gameAction(\n moduleId: unknown,\n action: unknown,\n rawArgs: unknown,\n): Promise<unknown> {\n const modules = agentModules();\n const id = typeof moduleId === \"string\" ? moduleId : Object.keys(modules)[0];\n if (!id) throw new Error(\"no module exposes actions\");\n\n const api = modules[id];\n if (!api) throw new Error(`unknown module '${id}'`);\n\n const name = typeof action === \"string\" ? action : \"\";\n const fn = api.actions?.[name];\n if (!fn) {\n const known = Object.keys(api.actions ?? {}).join(\", \") || \"none\";\n throw new Error(\n `unknown action '${name}' for '${id}' — available: ${known}`,\n );\n }\n\n const callArgs =\n rawArgs && typeof rawArgs === \"object\"\n ? (rawArgs as Record<string, unknown>)\n : {};\n const result = await fn(callArgs);\n\n // Состояние после действия — то, ради чего действие и звали.\n return {\n module: id,\n action: name,\n result: result ?? null,\n state: api.state ? api.state() : null,\n };\n}\n\n/* ---------------------------------------------------------------- протокол */\n\nasync function handle(\n cmd: string,\n args: Record<string, unknown>,\n): Promise<unknown> {\n switch (cmd) {\n case \"hello\":\n return {\n ready: true,\n titleId: probeOptions.titleId ?? null,\n url: window.location.href,\n };\n\n case \"readPage\": {\n const page = await readPage();\n return {\n ...page,\n url: window.location.href,\n titleId: probeOptions.titleId ?? null,\n };\n }\n\n case \"console\":\n return { console: consoleLog.slice(), network: networkLog.slice() };\n\n case \"click\":\n return await clickRef(args[\"ref\"]);\n\n case \"type\":\n return await typeIntoRef(args[\"ref\"], args[\"text\"]);\n\n case \"gameState\":\n return await gameState();\n\n case \"gameAction\":\n return await gameAction(args[\"module\"], args[\"action\"], args[\"args\"]);\n\n case \"input\":\n return await sendInput(args[\"args\"]);\n\n default:\n throw new Error(`unknown command '${cmd}'`);\n }\n}\n\n/**\n * Ставит зонд (и дополняет его данными о тайтле при повторном вызове).\n *\n * Модуль ставит зонд САМ при импорте — см. вызов внизу файла. Это не стилистика: перехват\n * console обязан встать раньше, чем упадёт что-нибудь на старте (например config.ts, который\n * бросает при нераспознанном тайтле), а вызовы из main.tsx исполняются уже ПОСЛЕ того, как\n * отработали тела всех импортированных модулей. Поэтому в main.tsx этот импорт стоит первым.\n *\n * Вне iframe (обычный запуск игры) не делает ничего.\n */\nexport function installPreviewProbe(options: PreviewProbeOptions = {}): void {\n probeOptions = { ...probeOptions, ...options };\n if (installed) return;\n if (typeof window === \"undefined\") return;\n // Не в iframe — значит это не превью дашборда. Ни перехватов, ни слушателей.\n if (window.self === window.top) return;\n\n installed = true;\n captureConsole();\n captureNetwork();\n // Перехваты автоматического наблюдения ставятся ЗДЕСЬ, при импорте зонда, и это единственный\n // момент, когда они успевают: `getContext` надо подменить раньше, чем движок создаст полотно, а\n // глобалы `__THREE_DEVTOOLS__` и `__PIXI_*_INIT__` — раньше, чем three.js и Pixi построят свои\n // рендереры (оба смотрят на них при инициализации и второго шанса представиться не дают).\n // Phaser своего канала не имеет вовсе — его игру ищут лениво, при сборке снимка.\n captureFrames();\n captureCanvases();\n captureThree();\n capturePixi();\n\n window.addEventListener(\"message\", (event: MessageEvent) => {\n const data = event.data as ProbeRequest | undefined;\n if (!data || data.wire !== WIRE || typeof data.id !== \"string\") return;\n // Чужой встраиватель (публичный сайт) сюда не пройдёт — и не узнает, что зонд вообще есть.\n if (!isAllowedOrigin(event.origin)) return;\n\n const source = event.source as Window | null;\n if (!source) return;\n\n const reply = (payload: Record<string, unknown>): void => {\n source.postMessage({ wire: WIRE, id: data.id, ...payload }, event.origin);\n };\n\n void handle(data.cmd, data.args ?? {})\n .then((result) => reply({ ok: true, data: result }))\n .catch((error: unknown) =>\n reply({ ok: false, error: stringifyArg(error) }),\n );\n });\n}\n\n// Само-установка при импорте — см. комментарий выше.\ninstallPreviewProbe();\n"
63
115
  },
64
116
  {
65
- "path": "src/previewProbe.ts",
66
- "content": "// Зонд превью: даёт AI-кодеру ГЛАЗА на работающее приложение.\n//\n// Живёт ВНУТРИ iframe с игрой, потому что иначе никак: превью грузится с домена бандлера, и\n// родительская страница (дашборд) по правилам браузера в его DOM залезть не может — единственная\n// щель между ними это postMessage. Зонд эту щель и обслуживает: сериализует DOM в компактное\n// дерево, копит console/сеть и умеет кликать по элементам, найденным в прошлом readPage.\n//\n// Отдельный слой — АВТОМАТИЧЕСКОЕ наблюдение за отрисованной игрой (см. ниже): полотна, кадры,\n// three.js, цвет экрана. Он работает без всякого участия игры и именно поэтому нужен: контракт\n// `exposeToAgent` даёт больше, но его кто-то должен написать, а произвольной игре не напишет никто.\n//\n// ДВА ИНВАРИАНТА, которые нельзя нарушать:\n//\n// 1. Зонд молчит, пока с ним не поздоровались с РАЗРЕШЁННОГО origin. Публичный сайт\n// idosgames.com тоже встраивает игры в iframe — там hello никто не пришлёт, и весь этот код\n// останется мёртвым. Ответ всегда уходит на event.origin, никогда на \"*\".\n// 2. Никакого `import.meta` и прочего, на чём падает классический бандлер превью (см. env.ts):\n// детект среды — только рантаймовый.\n//\n// Подключается ПЕРВОЙ строкой main.tsx: тогда перехват console/ошибок стоит раньше, чем всё\n// остальное успевает упасть, и агент увидит причину падения старта, а не пустоту.\n\n/** Метка протокола: всё, что без неё, зонда не касается. */\nconst WIRE = \"idos-preview-probe/1\";\n\n/** Кто имеет право разговаривать с зондом. Всё остальное игнорируется молча. */\nconst ALLOWED_ORIGIN_HOSTS = [\"platform.idosgames.com\"];\n\n/** Сколько записей console храним. Ring buffer: старое вытесняется. */\nconst CONSOLE_LIMIT = 100;\n\n/** Сколько сетевых вызовов храним. */\nconst NETWORK_LIMIT = 50;\n\n/** Потолок дерева: узлов, глубины и символов. Держит снапшот в разумных токенах. */\nconst MAX_NODES = 400;\nconst MAX_DEPTH = 15;\nconst MAX_TREE_CHARS = 8000;\n\n/** Обрезка текста узла — модели хватает начала, а полный текст раздувает снапшот. */\nconst MAX_TEXT = 80;\n\n/** Однотипных детей печатаем не больше этого, остальные схлопываем в «… +N more». */\nconst MAX_SIBLINGS = 12;\n\n/** Пауза после клика/ввода перед новым снимком: даём React дорисовать. */\nconst SETTLE_MS = 350;\n\n/** Кольцо отметок кадров — по нему считается fps. Хватает на несколько секунд при 60 fps. */\nconst FRAME_RING = 240;\n\n/** Сетка чтения пикселей: 6×6 = 36 точек. Каждая точка — отдельный синхронный readPixels. */\nconst PIXEL_GRID = 6;\n\n/** Сколько ждём кадр перед чтением пикселей. Не дождались — это и есть ответ: петля не идёт. */\nconst FRAME_WAIT_MS = 200;\n\n/** Потолок удержания синтетической клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_INPUT_HOLD_MS = 3000;\n\ntype ProbeRequest = {\n wire: typeof WIRE;\n id: string;\n cmd: string;\n args?: Record<string, unknown>;\n};\n\ntype ConsoleEntry = { level: string; text: string; at: number };\ntype NetworkEntry = {\n method: string;\n url: string;\n status: number | string;\n ms: number;\n at: number;\n};\n\nexport type PreviewProbeOptions = {\n /** Тайтл, против которого работает приложение — агенту важно видеть DEV это или PROD. */\n titleId?: string;\n};\n\n/**\n * Debug-поверхности модулей (см. `ctx.exposeToAgent` в @idosgames/module-sdk). Их выкладывает в\n * глобал host-shell; для отрисованных игр это ЕДИНСТВЕННЫЙ способ что-то узнать — у Three/Phaser\n * весь интерфейс это один `<canvas>`, и дерево DOM про него не расскажет ничего.\n */\ntype AgentModuleApi = {\n state?: () => unknown;\n actions?: Record<\n string,\n (args?: Record<string, unknown>) => unknown | Promise<unknown>\n >;\n describeActions?: Record<string, string>;\n};\n\n/** Состояние платформы, которое выкладывает host-shell (см. `publishHostState` в app-shell). */\ntype AgentHostState = {\n screen?: string;\n loggedIn?: boolean;\n userId?: string | null;\n titleId?: string | null;\n modules?: string[];\n /** Кто рисует общий интерфейс: роль → id модуля или null (роль свободна, шаблоны рисуют своё). */\n sharedUi?: Record<string, string | null>;\n};\n\ntype AgentGlobal = {\n version?: number;\n modules?: Record<string, AgentModuleApi>;\n host?: AgentHostState;\n /** Журнал шины событий между модулями (последние события, кто что слушает, расхождения версий). */\n events?: () => unknown;\n};\n\nfunction agentGlobal(): AgentGlobal | undefined {\n return (globalThis as typeof globalThis & { __IDOS_AGENT__?: AgentGlobal })\n .__IDOS_AGENT__;\n}\n\nfunction agentModules(): Record<string, AgentModuleApi> {\n return agentGlobal()?.modules ?? {};\n}\n\nconst consoleLog: ConsoleEntry[] = [];\nconst networkLog: NetworkEntry[] = [];\n\n/** Элементы последнего снимка: клик адресуется по ref_N отсюда. */\nlet refs = new Map<string, Element>();\n\nlet installed = false;\nlet probeOptions: PreviewProbeOptions = {};\n\n/* ------------------------------------------------------------------ утилиты */\n\nfunction push<T>(buf: T[], entry: T, limit: number): void {\n buf.push(entry);\n if (buf.length > limit) buf.shift();\n}\n\nfunction clip(text: string, max: number): string {\n const flat = text.replace(/\\s+/g, \" \").trim();\n return flat.length > max ? `${flat.slice(0, max)}…` : flat;\n}\n\n/** Безопасная печать аргумента console: объекты в JSON, циклы и геттеры-бомбы не роняют зонд. */\nfunction stringifyArg(value: unknown): string {\n if (typeof value === \"string\") return value;\n if (value instanceof Error) return `${value.name}: ${value.message}`;\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return String(value);\n }\n}\n\nfunction isAllowedOrigin(origin: string): boolean {\n try {\n const url = new URL(origin);\n if (url.hostname === \"localhost\" || url.hostname === \"127.0.0.1\")\n return true;\n return ALLOWED_ORIGIN_HOSTS.includes(url.hostname);\n } catch {\n return false;\n }\n}\n\n/* -------------------------------------------------------- сбор console/сети */\n\nfunction captureConsole(): void {\n const levels = [\"log\", \"info\", \"warn\", \"error\", \"debug\"] as const;\n for (const level of levels) {\n const original = console[level].bind(console);\n console[level] = (...args: unknown[]): void => {\n push(\n consoleLog,\n {\n level,\n text: clip(args.map(stringifyArg).join(\" \"), 300),\n at: Date.now(),\n },\n CONSOLE_LIMIT,\n );\n original(...args);\n };\n }\n\n window.addEventListener(\"error\", (event) => {\n const where = event.filename\n ? ` (${event.filename}:${event.lineno}:${event.colno})`\n : \"\";\n push(\n consoleLog,\n {\n level: \"error\",\n text: clip(`Uncaught ${event.message}${where}`, 300),\n at: Date.now(),\n },\n CONSOLE_LIMIT,\n );\n });\n\n window.addEventListener(\"unhandledrejection\", (event) => {\n push(\n consoleLog,\n {\n level: \"error\",\n text: clip(`Unhandled rejection: ${stringifyArg(event.reason)}`, 300),\n at: Date.now(),\n },\n CONSOLE_LIMIT,\n );\n });\n}\n\nfunction captureNetwork(): void {\n const originalFetch = window.fetch.bind(window);\n window.fetch = async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> => {\n const started = Date.now();\n const url =\n typeof input === \"string\"\n ? input\n : input instanceof URL\n ? input.href\n : input.url;\n const method = (\n init?.method ??\n (typeof input === \"object\" && \"method\" in input ? input.method : \"GET\") ??\n \"GET\"\n ).toUpperCase();\n\n // Заголовки и тела НЕ пишем сознательно: в них сидит Bearer-тикет игрока, а лог уезжает в LLM.\n try {\n const response = await originalFetch(input, init);\n push(\n networkLog,\n {\n method,\n url: clip(url, 200),\n status: response.status,\n ms: Date.now() - started,\n at: started,\n },\n NETWORK_LIMIT,\n );\n return response;\n } catch (error: unknown) {\n push(\n networkLog,\n {\n method,\n url: clip(url, 200),\n status: `failed: ${stringifyArg(error)}`,\n ms: Date.now() - started,\n at: started,\n },\n NETWORK_LIMIT,\n );\n throw error;\n }\n };\n}\n\n/* ------------------------------------------ автоматический слой наблюдения */\n\n// Всё, что ниже, работает БЕЗ какой-либо кооперации со стороны игры — в этом весь смысл.\n// Контракт `exposeToAgent` даёт данные лучше, но его должен кто-то НАПИСАТЬ, а произвольной (и тем\n// более будущей) игре его не напишет никто. Поэтому зонд снимает сам всё, что можно снять с движка\n// и с полотна: есть ли WebGL-контекст, идут ли кадры, сколько draw-вызовов, что говорит three.js о\n// сцене и камере, и не залит ли кадр одним цветом.\n//\n// Скриншотов здесь нет и не будет (решение владельца): наружу уходят ТОЛЬКО числа. Зато число\n// «все 36 проб одного цвета #ffffff» ловит белый экран не хуже картинки.\n\n/** Настоящий rAF, снятый ДО перехвата: им зонд ждёт кадр, не накручивая собственный счётчик. */\nconst rawRaf: ((cb: FrameRequestCallback) => number) | null =\n typeof window !== \"undefined\" &&\n typeof window.requestAnimationFrame === \"function\"\n ? window.requestAnimationFrame.bind(window)\n : null;\n\ntype CanvasRecord = {\n canvas: HTMLCanvasElement;\n /** Как контекст запрашивали: \"2d\" | \"webgl\" | \"webgl2\" | \"webgpu\" | … */\n kind: string;\n gl: WebGLRenderingContext | WebGL2RenderingContext | null;\n ctx2d: CanvasRenderingContext2D | null;\n /** Отметки «кадр отрисован» (clear/clearRect) — по ним считается настоящий fps. */\n frames: number;\n /** Вызовы отрисовки: у Three/Phaser их десятки-сотни за кадр. */\n draws: number;\n lastDrawAt: number;\n};\n\n/** Сколько полотен помним. Больше игре и не нужно, а временные canvas'ы иначе растут без конца. */\nconst MAX_CANVAS_RECORDS = 24;\n\nconst canvasRecords: CanvasRecord[] = [];\n\n/** Отметки кадров: по полотну (надёжнее) и по rAF (петля жива, даже если ничего не рисуется). */\nconst glFrameTimes: number[] = [];\nconst rafFrameTimes: number[] = [];\n\nfunction markFrame(ring: number[]): void {\n ring.push(Date.now());\n if (ring.length > FRAME_RING) ring.shift();\n}\n\n/** Сколько отметок пришлось на последнюю секунду. Это и есть fps — без усреднения по сессии. */\nfunction perSecond(ring: number[]): number {\n const cutoff = Date.now() - 1000;\n let count = 0;\n for (let i = ring.length - 1; i >= 0; i--) {\n if ((ring[i] ?? 0) < cutoff) break;\n count++;\n }\n return count;\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n return value && typeof value === \"object\"\n ? (value as Record<string, unknown>)\n : null;\n}\n\nfunction num(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\n/**\n * Подменить метод объекта счётчиком. Пишем в САМ объект, а не в прототип: у WebGL-контекста метод\n * лежит на прототипе, и собственное свойство просто перекрывает его для этого экземпляра — чужие\n * контексты (и чужие вкладки) остаются нетронутыми.\n */\nfunction countCalls(target: unknown, name: string, tick: () => void): void {\n const holder = target as Record<string, unknown> | null;\n if (!holder) return;\n const current = holder[name];\n if (typeof current !== \"function\") return;\n const original = current as (...args: unknown[]) => unknown;\n holder[name] = function (this: unknown, ...args: unknown[]): unknown {\n try {\n tick();\n } catch {\n /* счётчик не имеет права ломать отрисовку */\n }\n return original.apply(this, args);\n };\n}\n\n/** Инструментовка полотна: кадры отдельно, вызовы отрисовки отдельно. */\nfunction instrument(rec: CanvasRecord): void {\n const frame = (): void => {\n rec.frames++;\n rec.lastDrawAt = Date.now();\n // В общий счётчик fps идут только полотна НА СТРАНИЦЕ: временные canvas'ы, на которых движки\n // рисуют текстуры и текст, чистятся так же часто и накрутили бы «кадры» на пустом месте.\n if (rec.canvas.isConnected) markFrame(glFrameTimes);\n };\n const draw = (): void => {\n rec.draws++;\n };\n\n if (rec.gl) {\n // clear зовут один раз за кадр практически все движки — он и служит границей кадра.\n countCalls(rec.gl, \"clear\", frame);\n for (const method of [\n \"drawArrays\",\n \"drawElements\",\n \"drawArraysInstanced\",\n \"drawElementsInstanced\",\n ]) {\n countCalls(rec.gl, method, draw);\n }\n return;\n }\n\n if (rec.ctx2d) {\n countCalls(rec.ctx2d, \"clearRect\", frame);\n for (const method of [\n \"drawImage\",\n \"fillRect\",\n \"fill\",\n \"stroke\",\n \"fillText\",\n ]) {\n countCalls(rec.ctx2d, method, draw);\n }\n }\n}\n\nfunction registerContext(\n canvas: HTMLCanvasElement,\n kind: string,\n ctx: unknown,\n): void {\n // Повторный getContext возвращает тот же объект — второй раз инструментовать нельзя.\n if (canvasRecords.some((rec) => rec.canvas === canvas && rec.kind === kind))\n return;\n\n // Потолок списка обязателен: движки создают временные полотна пачками (текстуры из текста,\n // атласы), и без него зонд держал бы ссылку на каждое — утечка памяти плюс линейный поиск,\n // который растёт с каждым кадром. Первыми уходят полотна, которых уже нет на странице.\n if (canvasRecords.length >= MAX_CANVAS_RECORDS) {\n for (let i = canvasRecords.length - 1; i >= 0; i--) {\n if (!canvasRecords[i]?.canvas.isConnected) canvasRecords.splice(i, 1);\n }\n while (canvasRecords.length >= MAX_CANVAS_RECORDS) canvasRecords.shift();\n }\n\n const isGl =\n kind === \"webgl\" || kind === \"webgl2\" || kind === \"experimental-webgl\";\n const rec: CanvasRecord = {\n canvas,\n kind,\n gl: isGl ? (ctx as WebGLRenderingContext | WebGL2RenderingContext) : null,\n ctx2d: kind === \"2d\" ? (ctx as CanvasRenderingContext2D) : null,\n frames: 0,\n draws: 0,\n lastDrawAt: 0,\n };\n canvasRecords.push(rec);\n instrument(rec);\n}\n\n/**\n * Перехват `getContext`. Ставится ДО импорта движка (зонд — первый импорт main.tsx), поэтому ни\n * одно полотно мимо не проходит, чем бы игра ни рисовала: Three, Phaser, Pixi, сырой WebGL, 2d.\n *\n * Заодно ТОЛЬКО В ПРЕВЬЮ навязывается `preserveDrawingBuffer: true`. По умолчанию содержимое\n * WebGL-буфера действительно лишь до композитинга кадра, и снаружи кадра оттуда читается пустота —\n * то есть статичная сцена, которая рисуется один раз, выглядела бы «чёрным экраном». С флагом\n * последний нарисованный кадр остаётся читаемым в любой момент. Цена — небольшая потеря\n * производительности, и платит её только вкладка с превью: в собранной игре зонда нет.\n */\nfunction captureCanvases(): void {\n type GetContext = (\n this: HTMLCanvasElement,\n id: string,\n options?: unknown,\n ) => unknown;\n\n const proto = HTMLCanvasElement.prototype;\n const original = proto.getContext as unknown as GetContext;\n\n const patched: GetContext = function (this: HTMLCanvasElement, id, options) {\n let effective = options;\n try {\n const kind = String(id);\n if (\n kind === \"webgl\" ||\n kind === \"webgl2\" ||\n kind === \"experimental-webgl\"\n ) {\n // Копия, а не правка чужого объекта: игра могла передать свой конфиг и переиспользовать его.\n effective = {\n ...(asRecord(options) ?? {}),\n preserveDrawingBuffer: true,\n };\n }\n } catch {\n effective = options;\n }\n\n const ctx = original.call(this, id, effective);\n try {\n if (ctx) registerContext(this, String(id), ctx);\n } catch {\n /* наблюдение не имеет права мешать игре получить контекст */\n }\n return ctx;\n };\n\n proto.getContext = patched as unknown as typeof proto.getContext;\n}\n\n/** Перехват rAF: показывает, что цикл приложения вообще крутится, даже если полотна нет. */\nfunction captureFrames(): void {\n if (!rawRaf) return;\n window.requestAnimationFrame = (callback: FrameRequestCallback): number =>\n rawRaf((time) => {\n markFrame(rafFrameTimes);\n callback(time);\n });\n}\n\n/* ---------------------------------------------------------------- three.js */\n\nconst three: {\n renderer: Record<string, unknown> | null;\n scene: Record<string, unknown> | null;\n camera: Record<string, unknown> | null;\n scenes: number;\n} = { renderer: null, scene: null, camera: null, scenes: 0 };\n\n/**\n * Канал, по которому three.js сам представляется наблюдателю.\n *\n * `WebGLRenderer` и `Scene` в своих конструкторах проверяют глобал `__THREE_DEVTOOLS__` и, если он\n * есть, шлют в него событие `observe` со ссылкой на себя. Задуман он для расширения-девтулзов, но\n * это ровно то, что нужно здесь: никакой правки игры, а на выходе живой рендерер со счётчиками.\n * Глобал обязан существовать ДО создания рендерера — отсюда установка при импорте зонда.\n */\nfunction captureThree(): void {\n const holder = globalThis as typeof globalThis & {\n __THREE_DEVTOOLS__?: EventTarget;\n };\n\n // Если глобал уже кто-то поставил (расширение three-devtools) — подписываемся, а не затираем.\n const target: EventTarget = holder.__THREE_DEVTOOLS__ ?? new EventTarget();\n holder.__THREE_DEVTOOLS__ = target;\n\n target.addEventListener(\"observe\", (event: Event) => {\n try {\n const detail = asRecord((event as CustomEvent<unknown>).detail);\n if (!detail) return;\n\n if (detail[\"isScene\"] === true) {\n three.scenes++;\n three.scene ??= detail;\n return;\n }\n\n // Рендерер узнаём по паре info + domElement: это его, и только его, поверхность.\n if (detail[\"info\"] && detail[\"domElement\"]) {\n three.renderer = detail;\n watchRender(detail);\n }\n } catch {\n /* чужой объект не обязан быть таким, как мы ждём */\n }\n });\n}\n\n/**\n * Подмена `renderer.render(scene, camera)`: это единственный способ узнать, какие сцену и камеру\n * игра рисует ПРЯМО СЕЙЧАС. Событие `observe` про камеру не рассказывает вообще, а сцен у игры\n * может быть несколько (меню, мир, миникарта).\n */\nfunction watchRender(renderer: Record<string, unknown>): void {\n if (renderer[\"__idosProbeWatched\"] === true) return;\n const original = renderer[\"render\"];\n if (typeof original !== \"function\") return;\n\n renderer[\"__idosProbeWatched\"] = true;\n const call = original as (...args: unknown[]) => unknown;\n renderer[\"render\"] = function (this: unknown, ...args: unknown[]): unknown {\n const scene = asRecord(args[0]);\n const camera = asRecord(args[1]);\n if (scene) three.scene = scene;\n if (camera) three.camera = camera;\n return call.apply(this, args);\n };\n}\n\nfunction threeSnapshot(): Record<string, unknown> | null {\n if (!three.renderer && !three.scene) return null;\n const out: Record<string, unknown> = {};\n\n const info = asRecord(three.renderer?.[\"info\"]);\n const render = asRecord(info?.[\"render\"]);\n const memory = asRecord(info?.[\"memory\"]);\n if (render) {\n // `calls` у WebGLRenderer, `drawCalls` у WebGPURenderer — представляются они одинаково.\n out[\"drawCalls\"] = num(render[\"calls\"]) ?? num(render[\"drawCalls\"]);\n out[\"triangles\"] = num(render[\"triangles\"]);\n out[\"lines\"] = num(render[\"lines\"]);\n out[\"points\"] = num(render[\"points\"]);\n out[\"framesRendered\"] = num(render[\"frame\"]);\n }\n if (memory) {\n out[\"geometries\"] = num(memory[\"geometries\"]);\n out[\"textures\"] = num(memory[\"textures\"]);\n }\n\n const scene = three.scene;\n const traverse = scene?.[\"traverse\"];\n if (scene && typeof traverse === \"function\") {\n let total = 0;\n let meshes = 0;\n let lights = 0;\n let hidden = 0;\n try {\n (traverse as (cb: (obj: unknown) => void) => void).call(\n scene,\n (obj: unknown) => {\n if (total > 20000) return; // защита от сцены-монстра: считаем, но не вечно\n total++;\n const node = asRecord(obj);\n if (!node) return;\n if (node[\"isMesh\"] === true) meshes++;\n if (node[\"isLight\"] === true) lights++;\n if (node[\"visible\"] === false) hidden++;\n },\n );\n out[\"scene\"] = {\n name: typeof scene[\"name\"] === \"string\" ? scene[\"name\"] : null,\n objects: total,\n meshes,\n lights,\n hidden,\n scenesCreated: three.scenes,\n };\n } catch {\n out[\"scene\"] = { error: \"scene.traverse failed\" };\n }\n }\n\n // Позиция и направление камеры — прямо из мировой матрицы: третий столбец это её «взгляд»\n // (с минусом — камера в three смотрит вдоль -Z). Так не нужен импорт three ради Vector3.\n const elements = asRecord(three.camera?.[\"matrixWorld\"])?.[\"elements\"] as\n ArrayLike<number> | undefined;\n if (elements && elements.length >= 16) {\n const dx = -(elements[8] ?? 0);\n const dy = -(elements[9] ?? 0);\n const dz = -(elements[10] ?? 0);\n const len = Math.hypot(dx, dy, dz) || 1;\n out[\"camera\"] = {\n pos: {\n x: round(elements[12] ?? 0),\n y: round(elements[13] ?? 0),\n z: round(elements[14] ?? 0),\n },\n lookDir: {\n x: round(dx / len),\n y: round(dy / len),\n z: round(dz / len),\n },\n fov: num(three.camera?.[\"fov\"]),\n };\n }\n\n return out;\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\n/* ------------------------------------------------------------------- Pixi */\n\nconst pixi: {\n app: Record<string, unknown> | null;\n renderer: Record<string, unknown> | null;\n version: string | null;\n} = { app: null, renderer: null, version: null };\n\n/**\n * Канал, по которому PixiJS сам представляется наблюдателю — точный аналог `__THREE_DEVTOOLS__`.\n *\n * Pixi 8 при инициализации зовёт `globalThis.__PIXI_APP_INIT__(app, VERSION)`, а его рендерер —\n * `__PIXI_RENDERER_INIT__(renderer, VERSION)` (см. `utils/global/globalHooks`). Хуки задуманы для\n * расширения-девтулзов, и это ровно то, что нужно: игру править не надо, а на выходе живые\n * Application и Renderer. Ставить их обязательно ДО инициализации Pixi — отсюда установка при\n * импорте зонда.\n *\n * Оба хука ставятся не «вместо», а «поверх»: прежний (расширение браузера) вызывается следом.\n */\nfunction capturePixi(): void {\n type Hook = (target: unknown, version?: string) => void;\n const holder = globalThis as typeof globalThis & {\n __PIXI_APP_INIT__?: Hook;\n __PIXI_RENDERER_INIT__?: Hook;\n };\n\n const previousApp = holder.__PIXI_APP_INIT__;\n holder.__PIXI_APP_INIT__ = (app: unknown, version?: string): void => {\n try {\n pixi.app = asRecord(app);\n pixi.version = version ?? pixi.version;\n } catch {\n /* чужой объект не обязан быть таким, как мы ждём */\n }\n previousApp?.(app, version);\n };\n\n const previousRenderer = holder.__PIXI_RENDERER_INIT__;\n holder.__PIXI_RENDERER_INIT__ = (\n renderer: unknown,\n version?: string,\n ): void => {\n try {\n pixi.renderer = asRecord(renderer);\n pixi.version = version ?? pixi.version;\n } catch {\n /* см. выше */\n }\n previousRenderer?.(renderer, version);\n };\n}\n\n/** Обход дерева отображения Pixi: у него нет `traverse`, только `children`. */\nfunction countPixiTree(root: Record<string, unknown>): Record<string, unknown> {\n let total = 0;\n let hidden = 0;\n let depth = 0;\n\n const walk = (node: Record<string, unknown>, level: number): void => {\n if (total > 20000) return; // потолок как у three: считаем, но не вечно\n total++;\n if (node[\"visible\"] === false || node[\"renderable\"] === false) hidden++;\n if (level > depth) depth = level;\n\n const children = node[\"children\"];\n if (!Array.isArray(children)) return;\n for (const child of children) {\n const record = asRecord(child);\n if (record) walk(record, level + 1);\n }\n };\n\n walk(root, 0);\n // Сам корень объектом сцены не считаем — интересно, что В нём.\n return { objects: Math.max(total - 1, 0), hidden, depth };\n}\n\nfunction pixiSnapshot(): Record<string, unknown> | null {\n const app = pixi.app;\n const renderer = pixi.renderer ?? asRecord(app?.[\"renderer\"]);\n if (!app && !renderer) return null;\n\n const out: Record<string, unknown> = { version: pixi.version };\n\n if (renderer) {\n const type = renderer[\"type\"];\n out[\"renderer\"] = {\n // `name` у Pixi 8 это \"webgl\"/\"webgpu\"; `type` — числовой флаг того же самого.\n backend:\n typeof renderer[\"name\"] === \"string\"\n ? renderer[\"name\"]\n : (num(type) ?? null),\n width: num(renderer[\"width\"]),\n height: num(renderer[\"height\"]),\n resolution: num(renderer[\"resolution\"]),\n };\n }\n\n const ticker = asRecord(app?.[\"ticker\"]);\n if (ticker) {\n const fps = num(ticker[\"FPS\"]);\n out[\"ticker\"] = {\n fps: fps === null ? null : round(fps, 1),\n started: ticker[\"started\"] ?? null,\n };\n }\n\n const stage = asRecord(app?.[\"stage\"]);\n if (stage) {\n const tree = countPixiTree(stage);\n out[\"stage\"] = {\n ...tree,\n label:\n typeof stage[\"label\"] === \"string\"\n ? stage[\"label\"]\n : typeof stage[\"name\"] === \"string\"\n ? stage[\"name\"]\n : null,\n };\n }\n\n return out;\n}\n\n/* ----------------------------------------------------------------- Phaser */\n\n/**\n * У Phaser канала самопредставления НЕТ — и это проверено, а не предположено: `Game.boot` пишет\n * `window.PHASER_GAME = this` только под флагом `WEBGL_DEBUG`, а из готовых сборок\n * (`dist/phaser.esm.js`, которые и ставит npm) эта ветка вырезана целиком.\n *\n * Поэтому игру приходится ИСКАТЬ, а не ждать. Два источника, оба без кооперации игры:\n * 1. `window.PHASER_GAME` — есть в отладочных сборках и в превью, если бандлер взял `main`\n * (у Phaser это исходники, а не dist);\n * 2. скан собственных ключей `window` на сигнатуру `Phaser.Game`.\n *\n * Поиск ленивый (только при сборке снимка) и с кэшем: перебирать глобалы каждый кадр незачем.\n */\n// Кэшируется ИМЯ глобала, а не сам объект: игру можно пересоздать (host-shell перемонтирует сцену\n// при смене режима), и ссылка на прежнюю осталась бы живой в памяти — зонд честно показывал бы\n// уничтоженную игру. Перечитывание по ключу всегда отдаёт текущую.\nlet phaserKey: string | null = null;\n\nfunction looksLikePhaserGame(value: unknown): boolean {\n const game = asRecord(value);\n if (!game) return false;\n return (\n typeof game[\"isBooted\"] === \"boolean\" &&\n asRecord(game[\"scene\"]) !== null &&\n Array.isArray(asRecord(game[\"scene\"])?.[\"scenes\"]) &&\n asRecord(game[\"loop\"]) !== null\n );\n}\n\nfunction findPhaserGame(): Record<string, unknown> | null {\n const holder = globalThis as typeof globalThis & Record<string, unknown>;\n\n const at = (key: string): Record<string, unknown> | null => {\n try {\n return looksLikePhaserGame(holder[key]) ? asRecord(holder[key]) : null;\n } catch {\n // Чтение чужого свойства window может бросить (кросс-доменный фрейм) — не наша забота.\n return null;\n }\n };\n\n if (phaserKey) {\n const cached = at(phaserKey);\n if (cached) return cached;\n phaserKey = null;\n }\n\n for (const key of [\"PHASER_GAME\", ...Object.keys(holder)]) {\n const found = at(key);\n if (found) {\n phaserKey = key;\n return found;\n }\n }\n return null;\n}\n\nfunction phaserSnapshot(): Record<string, unknown> | null {\n const game = findPhaserGame();\n if (!game) return null;\n\n const out: Record<string, unknown> = {\n booted: game[\"isBooted\"] ?? null,\n running: game[\"isRunning\"] ?? null,\n };\n\n const fps = num(asRecord(game[\"loop\"])?.[\"actualFps\"]);\n if (fps !== null) out[\"fps\"] = round(fps, 1);\n\n const renderer = asRecord(game[\"renderer\"]);\n if (renderer) {\n // У Phaser `type` — числовая константа: 1 = CANVAS, 2 = WEBGL.\n const type = num(renderer[\"type\"]);\n out[\"renderer\"] = {\n backend: type === 2 ? \"webgl\" : type === 1 ? \"canvas\" : (type ?? null),\n width: num(renderer[\"width\"]),\n height: num(renderer[\"height\"]),\n };\n }\n\n const scenes = asRecord(game[\"scene\"])?.[\"scenes\"];\n if (Array.isArray(scenes)) {\n const list: Record<string, unknown>[] = [];\n for (const raw of scenes.slice(0, 12)) {\n const sys = asRecord(asRecord(raw)?.[\"sys\"]);\n const settings = asRecord(sys?.[\"settings\"]);\n const displayList = asRecord(sys?.[\"displayList\"]);\n const camera = asRecord(asRecord(sys?.[\"cameras\"])?.[\"main\"]);\n\n const entry: Record<string, unknown> = {\n key: settings?.[\"key\"] ?? null,\n active: settings?.[\"active\"] ?? null,\n visible: settings?.[\"visible\"] ?? null,\n objects: Array.isArray(displayList?.[\"list\"])\n ? (displayList[\"list\"] as unknown[]).length\n : null,\n };\n if (camera) {\n entry[\"camera\"] = {\n scrollX: round(num(camera[\"scrollX\"]) ?? 0),\n scrollY: round(num(camera[\"scrollY\"]) ?? 0),\n zoom: round(num(camera[\"zoom\"]) ?? 1),\n };\n }\n list.push(entry);\n }\n out[\"scenes\"] = list;\n // «Активная» сцена — та, что реально обновляется: по ней и судят, что происходит на экране.\n out[\"activeScenes\"] = list.filter((s) => s[\"active\"] === true).length;\n }\n\n return out;\n}\n\n/* ------------------------------------------------------------- пиксели */\n\nfunction hex(r: number, g: number, b: number): string {\n const part = (v: number): string => v.toString(16).padStart(2, \"0\");\n return `#${part(r)}${part(g)}${part(b)}`;\n}\n\n/**\n * Чтение редкой сетки пикселей — 36 точек вместо картинки: скриншотов здесь нет по решению\n * владельца, а «все 36 проб одного цвета» ловит белый экран ничуть не хуже.\n *\n * Читать можно в любой момент, потому что зонд принудительно включает `preserveDrawingBuffer`\n * (см. `captureCanvases`); без него содержимое буфера пропадало бы сразу после композитинга кадра.\n */\nfunction readGrid(rec: CanvasRecord): number[][] | null {\n const width = rec.canvas.width;\n const height = rec.canvas.height;\n if (width < 2 || height < 2) return null;\n\n const at = (i: number, size: number): number =>\n Math.min(size - 1, Math.floor(((i + 0.5) / PIXEL_GRID) * size));\n\n if (rec.gl) {\n const gl = rec.gl;\n if (gl.isContextLost()) return null;\n\n // Игра могла оставить привязанным свой render target — тогда мы прочли бы не экран. Снимаем\n // привязку на время чтения и возвращаем ровно ту, что была.\n const previous = gl.getParameter(\n gl.FRAMEBUFFER_BINDING,\n ) as WebGLFramebuffer | null;\n if (previous) gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\n const pixel = new Uint8Array(4);\n const samples: number[][] = [];\n for (let iy = 0; iy < PIXEL_GRID; iy++) {\n for (let ix = 0; ix < PIXEL_GRID; ix++) {\n gl.readPixels(\n at(ix, width),\n at(iy, height),\n 1,\n 1,\n gl.RGBA,\n gl.UNSIGNED_BYTE,\n pixel,\n );\n samples.push([\n pixel[0] ?? 0,\n pixel[1] ?? 0,\n pixel[2] ?? 0,\n pixel[3] ?? 0,\n ]);\n }\n }\n\n if (previous) gl.bindFramebuffer(gl.FRAMEBUFFER, previous);\n return samples;\n }\n\n if (rec.ctx2d) {\n const samples: number[][] = [];\n for (let iy = 0; iy < PIXEL_GRID; iy++) {\n for (let ix = 0; ix < PIXEL_GRID; ix++) {\n const data = rec.ctx2d.getImageData(\n at(ix, width),\n at(iy, height),\n 1,\n 1,\n ).data;\n samples.push([data[0] ?? 0, data[1] ?? 0, data[2] ?? 0, data[3] ?? 0]);\n }\n }\n return samples;\n }\n\n return null;\n}\n\nfunction pixelStats(samples: number[][]): Record<string, unknown> {\n const colours = new Set<string>();\n let transparent = 0;\n let r = 0;\n let g = 0;\n let b = 0;\n\n for (const [pr, pg, pb, pa] of samples) {\n const alpha = pa ?? 0;\n if (alpha < 8) transparent++;\n r += pr ?? 0;\n g += pg ?? 0;\n b += pb ?? 0;\n colours.add(hex(pr ?? 0, pg ?? 0, pb ?? 0));\n }\n\n const n = samples.length || 1;\n const uniform = colours.size <= 1;\n const average = hex(Math.round(r / n), Math.round(g / n), Math.round(b / n));\n\n return {\n sampled: samples.length,\n distinctColours: colours.size,\n uniform,\n colour: uniform ? ([...colours][0] ?? null) : null,\n averageColour: average,\n transparentSamples: transparent,\n note: uniform\n ? `every sampled pixel is the same colour — the frame is a flat fill (a blank/white/black screen looks exactly like this)`\n : `${colours.size} distinct colours across ${samples.length} samples — something is actually drawn`,\n };\n}\n\n/** Прочитать сетку прямо сейчас, обернув всё, что может пойти не так, в ответ, а не в исключение. */\nfunction readNow(\n rec: CanvasRecord,\n extra: Record<string, unknown>,\n): Record<string, unknown> {\n try {\n const samples = readGrid(rec);\n return samples\n ? { ...pixelStats(samples), ...extra }\n : { sampled: 0, note: \"could not read pixels from this canvas\" };\n } catch (error: unknown) {\n return { sampled: 0, note: `pixel read failed: ${stringifyArg(error)}` };\n }\n}\n\n/**\n * Прочитать пиксели, по возможности — в кадре, где что-то нарисовано.\n *\n * Сначала ждём кадр с отрисовкой: у живой игры это самая свежая картинка. Не дождались — читаем всё\n * равно: буфер сохраняется принудительно (см. `captureCanvases`), поэтому там лежит ПОСЛЕДНИЙ\n * нарисованный кадр. Отдать в такой ситуации «ничего не вижу» было бы худшим из ответов: именно\n * когда петля встала, картинка нужнее всего.\n */\nfunction samplePixels(rec: CanvasRecord): Promise<Record<string, unknown>> {\n return new Promise((resolve) => {\n if (!rawRaf) {\n resolve(\n readNow(rec, {\n drewDuringSample: false,\n frameNote: \"read outside an animation frame\",\n }),\n );\n return;\n }\n\n let attempts = 0;\n let settled = false;\n const finish = (value: Record<string, unknown>): void => {\n if (settled) return;\n settled = true;\n window.clearTimeout(timer);\n resolve(value);\n };\n\n const timer = window.setTimeout(\n () =>\n finish(\n readNow(rec, {\n drewDuringSample: false,\n staleFrame: true,\n frameNote:\n `nothing was drawn within ${FRAME_WAIT_MS}ms, so this is the LAST frame the game ` +\n \"rendered, not a live one — the render loop is stopped, the scene only redraws on \" +\n \"demand, or the preview tab is in the background\",\n }),\n ),\n FRAME_WAIT_MS,\n );\n\n const tick = (): void => {\n if (settled) return;\n const before = rec.frames + rec.draws;\n rawRaf(() => {\n if (settled) return;\n const drew = rec.frames + rec.draws > before;\n attempts++;\n // Ещё один шанс поймать кадр с отрисовкой; на третьей попытке читаем как есть.\n if (!drew && attempts < 3) {\n tick();\n return;\n }\n finish(readNow(rec, { drewDuringSample: drew }));\n });\n };\n\n tick();\n });\n}\n\n/* --------------------------------------------------------- сборка снимка */\n\n/** Полотна документа, самое большое — первым: оно почти всегда и есть игра. */\nfunction canvasesByArea(): {\n el: HTMLCanvasElement;\n rec: CanvasRecord | null;\n}[] {\n const list = Array.from(document.querySelectorAll(\"canvas\")).map((el) => ({\n el,\n rec: canvasRecords.find((rec) => rec.canvas === el) ?? null,\n }));\n return list.sort(\n (a, b) => b.el.width * b.el.height - a.el.width * a.el.height,\n );\n}\n\nfunction describeCanvas(entry: {\n el: HTMLCanvasElement;\n rec: CanvasRecord | null;\n}): Record<string, unknown> {\n const rect = entry.el.getBoundingClientRect();\n const rec = entry.rec;\n return {\n context: rec?.kind ?? \"unknown (context created before the probe, or none)\",\n buffer: `${entry.el.width}x${entry.el.height}`,\n onScreen: `${Math.round(rect.width)}x${Math.round(rect.height)}`,\n visible: rect.width > 0 && rect.height > 0,\n framesDrawn: rec?.frames ?? null,\n drawCalls: rec?.draws ?? null,\n msSinceLastDraw:\n rec && rec.lastDrawAt > 0 ? Date.now() - rec.lastDrawAt : null,\n };\n}\n\nfunction platformState(): Record<string, unknown> {\n const host = agentGlobal()?.host;\n return {\n titleId: probeOptions.titleId ?? host?.titleId ?? null,\n url: window.location.href,\n // Экран хоста: loading | login | game. «Игрок не залогинен» — самая частая причина того, что\n // «игра не работает», и без этой строки агент ищет причину в коде игры.\n screen: host?.screen ?? \"unknown (host state not published)\",\n loggedIn: host?.loggedIn ?? null,\n userId: host?.userId ?? null,\n modulesInstalled: host?.modules ?? null,\n // Роли общего интерфейса. Отвечает на «почему у шаблона пропал кошелёк/баланс»: его взял\n // поставщик роли (обычно game-hud), это не поломка.\n sharedUi: host?.sharedUi ?? null,\n };\n}\n\n/** Журнал шины событий. Чужой код — поэтому под try: сломанный журнал не должен ронять ответ. */\nfunction eventBusState(): unknown {\n const read = agentGlobal()?.events;\n if (typeof read !== \"function\") return null;\n try {\n return read();\n } catch (error: unknown) {\n return { error: stringifyArg(error) };\n }\n}\n\n/**\n * Автоматический слой: что видно в приложении БЕЗ его участия.\n *\n * Возвращается всегда — и когда модули открылись агенту, и когда нет. Слепой тишины быть не должно:\n * даже у игры, о которой никто ничего не рассказал, есть полотно, кадры и цвет экрана.\n */\nasync function observeRuntime(options: {\n pixels: boolean;\n}): Promise<Record<string, unknown>> {\n const canvases = canvasesByArea();\n const main = canvases[0] ?? null;\n const notes: string[] = [];\n\n const glFps = perSecond(glFrameTimes);\n const rafFps = perSecond(rafFrameTimes);\n const rendering: Record<string, unknown> = {\n fps: glFps > 0 ? glFps : rafFps,\n fpsSource:\n glFps > 0 ? \"canvas clear() calls\" : \"requestAnimationFrame callbacks\",\n animationFramesPerSecond: rafFps,\n canvasFramesPerSecond: glFps,\n };\n\n // Движки опрашиваются ДО заметок про «ничего не анимируется»: их собственный счётчик кадров эту\n // заметку отменяет, и выдать обе разом значило бы противоречить самому себе в одном ответе.\n const threeInfo = threeSnapshot();\n const pixiInfo = pixiSnapshot();\n const phaserInfo = phaserSnapshot();\n\n // Кадры, о которых отчитывается САМ движок. Им веры больше, чем счётчику по полотну: движок не\n // обязан чистить буфер каждый кадр, и тогда наш счётчик занижает. Поймано вживую — Phaser\n // сообщал 60, а полотно давало 1.\n const engineFps =\n num(phaserInfo?.[\"fps\"]) ?? num(asRecord(pixiInfo?.[\"ticker\"])?.[\"fps\"]);\n const canvasFps = glFps > 0 ? glFps : rafFps;\n const engineRunning = engineFps !== null && engineFps > 5;\n if (engineFps !== null) rendering[\"engineFps\"] = engineFps;\n\n if (!main) {\n notes.push(\n \"No <canvas> in the document: this is a DOM app (or the game has not mounted its canvas yet). Read the page instead.\",\n );\n } else if (engineRunning && canvasFps <= 5) {\n notes.push(\n `The engine reports ${engineFps} fps while the canvas counter sees almost none. Trust the engine: the counter only sees frames that clear the buffer, and a page that has just rebuilt has not accumulated any yet. The game IS running.`,\n );\n } else if (rafFps === 0 && glFps === 0) {\n // Порядок причин здесь не случаен: пауза стоит ПЕРВОЙ, потому что она самая частая и самая\n // безобидная. Живой прогон показал именно её — игра ждала клика по полотну, а заметка звучала\n // как «петля мертва», то есть звала чинить исправное.\n notes.push(\n \"Nothing is animating: no animation frame ran in the last second. Most often the game is simply PAUSED and waiting for the player to click the canvas (a click via SendInput starts it — check the frames again after that). It is also normal for a scene that only redraws on demand. Only if neither applies is the render loop actually dead (an exception inside it, or it was never started) — the console says which.\",\n );\n }\n\n if (!threeInfo && !pixiInfo && !phaserInfo && main?.rec) {\n notes.push(\n \"A canvas is present but no engine identified itself: three.js and PixiJS announce themselves automatically, and Phaser is looked up in the page globals. So this is either another engine (Babylon, raw WebGL/2d), or Phaser that keeps its Game object out of reach. The canvas numbers above (fps, draw calls, pixels) still hold — only the scene/camera detail is missing.\",\n );\n }\n\n let pixels: Record<string, unknown> | null = null;\n if (options.pixels && main?.rec) {\n pixels = await samplePixels(main.rec);\n if (pixels[\"uniform\"] === true) {\n notes.push(\n \"The sampled frame is ONE flat colour. Together with a live fps that usually means the scene renders but nothing is in view (camera inside geometry, everything culled, materials/lights missing); with fps 0 it means nothing is being drawn at all.\",\n );\n }\n }\n\n return {\n platform: platformState(),\n canvas: main ? describeCanvas(main) : null,\n otherCanvases: canvases.length > 1 ? canvases.length - 1 : 0,\n rendering,\n three: threeInfo,\n pixi: pixiInfo,\n phaser: phaserInfo,\n pixels,\n notes,\n };\n}\n\n/** Однострочная выжимка автоматического слоя — она подмешивается в снимок страницы. */\nfunction summarize(observation: Record<string, unknown>): string {\n const parts: string[] = [];\n\n const canvas = asRecord(observation[\"canvas\"]);\n if (canvas)\n parts.push(\n `canvas ${String(canvas[\"buffer\"])} (${String(canvas[\"context\"])})`,\n );\n\n const rendering = asRecord(observation[\"rendering\"]);\n if (rendering) parts.push(`${String(rendering[\"fps\"])} fps`);\n\n const threeInfo = asRecord(observation[\"three\"]);\n if (threeInfo) {\n const scene = asRecord(threeInfo[\"scene\"]);\n if (scene)\n parts.push(`three.js scene: ${String(scene[\"objects\"])} objects`);\n if (threeInfo[\"drawCalls\"] !== null && threeInfo[\"drawCalls\"] !== undefined)\n parts.push(`${String(threeInfo[\"drawCalls\"])} draw calls`);\n }\n\n const pixiInfo = asRecord(observation[\"pixi\"]);\n const pixiStage = asRecord(pixiInfo?.[\"stage\"]);\n if (pixiStage)\n parts.push(`PixiJS stage: ${String(pixiStage[\"objects\"])} display objects`);\n\n const phaserInfo = asRecord(observation[\"phaser\"]);\n if (phaserInfo) {\n const scenes = phaserInfo[\"scenes\"];\n parts.push(\n `Phaser: ${String(phaserInfo[\"activeScenes\"] ?? 0)} active scene(s)` +\n (Array.isArray(scenes) ? ` of ${scenes.length}` : \"\"),\n );\n }\n\n const pixels = asRecord(observation[\"pixels\"]);\n if (pixels && num(pixels[\"sampled\"])) {\n parts.push(\n pixels[\"uniform\"] === true\n ? `the whole frame is ${String(pixels[\"colour\"])}`\n : `${String(pixels[\"distinctColours\"])} distinct colours on screen`,\n );\n }\n\n const platform = asRecord(observation[\"platform\"]);\n if (platform && platform[\"screen\"])\n parts.push(`host screen: ${String(platform[\"screen\"])}`);\n\n return parts.join(\", \");\n}\n\n/* ------------------------------------------------------ синтетический ввод */\n\n// Универсальные «руки» — для игр, которые НЕ описали свои действия через `exposeToAgent`.\n// Работает не всегда, и это честно сказано в ответе: движок, который гейтит ввод на Pointer Lock,\n// синтетические события игнорирует, а Pointer Lock в кросс-доменном iframe запрещён браузером.\n\nconst KEY_CODES: Record<string, number> = {\n Space: 32,\n Enter: 13,\n Escape: 27,\n Tab: 9,\n Backspace: 8,\n ArrowLeft: 37,\n ArrowUp: 38,\n ArrowRight: 39,\n ArrowDown: 40,\n ShiftLeft: 16,\n ShiftRight: 16,\n ControlLeft: 17,\n ControlRight: 17,\n};\n\n/** `key` по `code`: движки читают то одно, то другое, поэтому заполняем оба. */\nfunction keyFromCode(code: string): string {\n if (code.startsWith(\"Key\")) return code.slice(3).toLowerCase();\n if (code.startsWith(\"Digit\")) return code.slice(5);\n if (code === \"Space\") return \" \";\n if (code.startsWith(\"Shift\")) return \"Shift\";\n if (code.startsWith(\"Control\")) return \"Control\";\n if (code.startsWith(\"Alt\")) return \"Alt\";\n return code;\n}\n\nfunction legacyKeyCode(code: string): number {\n const known = KEY_CODES[code];\n if (known) return known;\n if (code.startsWith(\"Key\")) return code.charCodeAt(3);\n if (code.startsWith(\"Digit\")) return 48 + Number(code.slice(5));\n return 0;\n}\n\n/** Кого считаем игрой: самое большое полотно, иначе — активный элемент. */\nfunction inputTarget(): EventTarget {\n const main = canvasesByArea()[0];\n return main?.el ?? document.activeElement ?? document.body ?? window;\n}\n\nfunction keyEvent(type: string, code: string): KeyboardEvent {\n const event = new KeyboardEvent(type, {\n code,\n key: keyFromCode(code),\n bubbles: true,\n cancelable: true,\n composed: true,\n });\n // keyCode/which в конструкторе не поддерживаются, а игры на них до сих пор смотрят.\n const legacy = legacyKeyCode(code);\n Object.defineProperty(event, \"keyCode\", { get: () => legacy });\n Object.defineProperty(event, \"which\", { get: () => legacy });\n return event;\n}\n\nfunction wait(ms: number): Promise<void> {\n return new Promise((resolve) => window.setTimeout(resolve, ms));\n}\n\nasync function sendKey(code: string, ms: number): Promise<void> {\n const target = inputTarget();\n if (target instanceof HTMLElement) target.focus?.();\n target.dispatchEvent(keyEvent(\"keydown\", code));\n await wait(Math.min(Math.max(ms, 16), MAX_INPUT_HOLD_MS));\n target.dispatchEvent(keyEvent(\"keyup\", code));\n}\n\n/** Координата: 0..1 читается как доля полотна, больше — как CSS-пиксели. */\nfunction resolveCoord(value: unknown, size: number): number {\n const n = typeof value === \"number\" && Number.isFinite(value) ? value : 0.5;\n return n >= 0 && n <= 1 ? n * size : n;\n}\n\n/** Типы, после которых кнопка уже отпущена: у них `buttons` обязан быть нулём. */\nconst RELEASE_EVENTS = new Set([\n \"mouseup\",\n \"pointerup\",\n \"click\",\n \"mousemove\",\n \"pointermove\",\n]);\n\n/**\n * Событие указателя. Для `pointer*` строим именно PointerEvent: движки на pointer-событиях\n * (Phaser 4) читают у него `pointerId`/`isPrimary`, и обычный MouseEvent они отбрасывают.\n */\nfunction pointerLikeEvent(\n type: string,\n x: number,\n y: number,\n button: number,\n movement?: { dx: number; dy: number },\n): MouseEvent {\n const init: PointerEventInit = {\n clientX: x,\n clientY: y,\n button,\n buttons: RELEASE_EVENTS.has(type) ? 0 : 1 << button,\n bubbles: true,\n cancelable: true,\n composed: true,\n movementX: movement?.dx ?? 0,\n movementY: movement?.dy ?? 0,\n };\n\n if (type.startsWith(\"pointer\") && typeof PointerEvent === \"function\") {\n return new PointerEvent(type, {\n ...init,\n pointerId: 1,\n pointerType: \"mouse\",\n isPrimary: true,\n });\n }\n return new MouseEvent(type, init);\n}\n\nasync function sendClick(\n xArg: unknown,\n yArg: unknown,\n button: number,\n): Promise<void> {\n const target = inputTarget();\n const element = target instanceof Element ? target : document.body;\n const rect = element.getBoundingClientRect();\n const x = rect.left + resolveCoord(xArg, rect.width);\n const y = rect.top + resolveCoord(yArg, rect.height);\n\n for (const type of [\"pointerdown\", \"mousedown\"]) {\n target.dispatchEvent(pointerLikeEvent(type, x, y, button));\n }\n await wait(30);\n for (const type of [\"pointerup\", \"mouseup\", \"click\"]) {\n target.dispatchEvent(pointerLikeEvent(type, x, y, button));\n }\n}\n\nasync function sendMove(dx: number, dy: number): Promise<void> {\n const target = inputTarget();\n const element = target instanceof Element ? target : document.body;\n const rect = element.getBoundingClientRect();\n const x = rect.left + rect.width / 2 + dx;\n const y = rect.top + rect.height / 2 + dy;\n // movementX/movementY — то, что читает камера от первого лица; clientX/Y — то, что читают\n // обычные обработчики. Заполняем оба, чтобы не гадать, какой путь у игры.\n target.dispatchEvent(pointerLikeEvent(\"mousemove\", x, y, 0, { dx, dy }));\n target.dispatchEvent(pointerLikeEvent(\"pointermove\", x, y, 0, { dx, dy }));\n await wait(16);\n}\n\n/**\n * Синтетический ввод + снимок ПОСЛЕ него (как readPage после клика).\n *\n * Ответ всегда несёт `pointerLockActive`: если игра требует захвата указателя, ввод до неё не\n * дойдёт — и агент должен прочитать это как «управление не проверено», а не «управление сломано».\n */\nasync function sendInput(raw: unknown): Promise<unknown> {\n const args = asRecord(raw) ?? {};\n const type = String(args[\"type\"] ?? \"key\");\n\n switch (type) {\n case \"key\": {\n const code = String(args[\"code\"] ?? args[\"key\"] ?? \"\");\n if (!code)\n throw new Error(\"input type 'key' needs a code, e.g. \\\"KeyW\\\"\");\n await sendKey(code, Number(args[\"ms\"] ?? 200));\n break;\n }\n case \"click\":\n await sendClick(args[\"x\"], args[\"y\"], Number(args[\"button\"] ?? 0));\n break;\n case \"move\":\n await sendMove(Number(args[\"dx\"] ?? 0), Number(args[\"dy\"] ?? 0));\n break;\n default:\n throw new Error(`unknown input type '${type}' — use key | click | move`);\n }\n\n await settle();\n // Именно на истинность, а не `!== null`: там, где Pointer Lock не поддержан вовсе, свойство\n // приходит `undefined`, и строгое сравнение объявило бы захват активным, которого нет.\n const locked = Boolean(document.pointerLockElement);\n return {\n sent: { type, ...args },\n pointerLockActive: locked,\n note: locked\n ? \"Pointer Lock is active, so the game receives this input the same way it receives the player's.\"\n : \"Synthetic input was dispatched. If the game gates controls on Pointer Lock it ignored this — Pointer Lock cannot be acquired inside the preview iframe. A module's exposeToAgent actions are the reliable path.\",\n observation: await observeRuntime({ pixels: true }),\n modules: moduleSurfaces(),\n };\n}\n\n/* ------------------------------------------------------- сериализация DOM */\n\nconst SKIP_TAGS = new Set([\n \"SCRIPT\",\n \"STYLE\",\n \"LINK\",\n \"META\",\n \"NOSCRIPT\",\n \"TEMPLATE\",\n \"HEAD\",\n]);\n\nconst INTERACTIVE_TAGS = new Set([\n \"BUTTON\",\n \"A\",\n \"INPUT\",\n \"SELECT\",\n \"TEXTAREA\",\n]);\n\nfunction isInteractive(el: Element): boolean {\n if (INTERACTIVE_TAGS.has(el.tagName)) return true;\n const role = el.getAttribute(\"role\");\n if (\n role === \"button\" ||\n role === \"link\" ||\n role === \"tab\" ||\n role === \"menuitem\"\n )\n return true;\n return el.hasAttribute(\"data-testid\") && el.hasAttribute(\"tabindex\");\n}\n\nfunction isVisible(el: Element): boolean {\n const rect = el.getBoundingClientRect();\n if (rect.width > 0 && rect.height > 0) return true;\n // Нулевой прямоугольник у контейнера — норма (например, обёртка с absolute-детьми):\n // считаем видимым, если браузер не выключил его целиком.\n const style = window.getComputedStyle(el);\n return style.display !== \"none\" && style.visibility !== \"hidden\";\n}\n\n/** Собственный текст узла — без текста детей (их напечатают они сами). */\nfunction ownText(el: Element): string {\n let text = \"\";\n for (const node of Array.from(el.childNodes)) {\n if (node.nodeType === Node.TEXT_NODE) text += node.textContent ?? \"\";\n }\n return clip(text, MAX_TEXT);\n}\n\nfunction describe(el: Element, ref: string | null): string {\n const parts: string[] = [el.tagName.toLowerCase()];\n\n const id = el.getAttribute(\"id\");\n if (id) parts[0] += `#${id}`;\n\n const cls = el.getAttribute(\"class\");\n if (cls) {\n const first = cls.trim().split(/\\s+/).slice(0, 2).join(\".\");\n if (first) parts[0] += `.${first}`;\n }\n\n if (ref) parts.push(`[${ref}]`);\n\n const label = el.getAttribute(\"aria-label\");\n const testId = el.getAttribute(\"data-testid\");\n if (testId) parts.push(`testid=${testId}`);\n\n const text = ownText(el) || (label ? clip(label, MAX_TEXT) : \"\");\n if (text) parts.push(JSON.stringify(text));\n\n const flags: string[] = [];\n if (el.hasAttribute(\"disabled\")) flags.push(\"disabled\");\n if ((el as HTMLInputElement).checked) flags.push(\"checked\");\n if (el.tagName === \"INPUT\" || el.tagName === \"TEXTAREA\") {\n const value = (el as HTMLInputElement).value;\n if (value) flags.push(`value=${JSON.stringify(clip(value, 40))}`);\n const placeholder = el.getAttribute(\"placeholder\");\n if (placeholder)\n flags.push(`placeholder=${JSON.stringify(clip(placeholder, 40))}`);\n }\n if (el.tagName === \"CANVAS\") {\n const canvas = el as HTMLCanvasElement;\n flags.push(`${canvas.width}x${canvas.height}`);\n }\n if (flags.length) parts.push(`(${flags.join(\", \")})`);\n\n return parts.join(\" \");\n}\n\nfunction readDom(): { tree: string; truncated: boolean } {\n refs = new Map<string, Element>();\n const lines: string[] = [];\n let nodes = 0;\n let refSeq = 0;\n let truncated = false;\n\n const walk = (el: Element, depth: number): void => {\n if (truncated) return;\n if (SKIP_TAGS.has(el.tagName)) return;\n if (nodes >= MAX_NODES || depth > MAX_DEPTH) {\n truncated = true;\n return;\n }\n\n const visible = isVisible(el);\n let ref: string | null = null;\n if (visible && isInteractive(el)) {\n ref = `ref_${++refSeq}`;\n refs.set(ref, el);\n }\n\n const line = `${\" \".repeat(depth)}${describe(el, ref)}${visible ? \"\" : \" (hidden)\"}`;\n lines.push(line);\n nodes++;\n\n // В скрытое поддерево не спускаемся: сам факт «модалка есть и она скрыта» полезен, её\n // внутренности — нет.\n if (!visible) return;\n\n const children = Array.from(el.children);\n const shown = children.slice(0, MAX_SIBLINGS);\n for (const child of shown) walk(child, depth + 1);\n if (children.length > shown.length) {\n lines.push(\n `${\" \".repeat(depth + 1)}… +${children.length - shown.length} more sibling(s)`,\n );\n }\n };\n\n if (document.body) walk(document.body, 0);\n\n let tree = lines.join(\"\\n\");\n if (tree.length > MAX_TREE_CHARS) {\n tree = `${tree.slice(0, MAX_TREE_CHARS)}\\n… (tree truncated)`;\n truncated = true;\n }\n\n return { tree, truncated };\n}\n\n/**\n * Полотно, которое стоит считать «главным экраном»: либо оно занимает заметную часть окна, либо в\n * дереве вообще не за что зацепиться. Маленький canvas рядом с обычным интерфейсом (график,\n * спарклайн, аватар) главным экраном не объявляем — иначе снимок каждой DOM-страницы обрастал бы\n * рассказом про отрисованную игру, которой там нет.\n */\nfunction dominantCanvas(): {\n el: HTMLCanvasElement;\n rec: CanvasRecord | null;\n} | null {\n const main = canvasesByArea()[0];\n if (!main) return null;\n\n const rect = main.el.getBoundingClientRect();\n const viewport = window.innerWidth * window.innerHeight;\n const share = viewport > 0 ? (rect.width * rect.height) / viewport : 0;\n if (share >= 0.15) return main;\n\n // Нулевой прямоугольник — не обязательно «полотна не видно»: измерять могли до раскладки. Тогда\n // судим по размеру самого буфера, иначе снимок игры с HUD-кнопкой молча терял бы весь рассказ\n // про экран (поймано прогоном под jsdom, где размеров нет вообще).\n const unmeasured = rect.width === 0 && rect.height === 0;\n if (unmeasured && main.el.width >= 200 && main.el.height >= 200) return main;\n\n return refs.size === 0 ? main : null;\n}\n\n/**\n * Снимок страницы: дерево DOM плюс — у отрисованной игры — выжимка автоматического наблюдения.\n *\n * У Three/Phaser дерево честно пустое: весь мир внутри одного `<canvas>`. Раньше здесь стояла\n * только пометка «это не пустой экран», и агент оставался ни с чем. Теперь в ту же строку уезжают\n * настоящие цифры (кадры, объекты сцены, цвет полотна) — их зонд добывает сам, без участия игры.\n */\nasync function readPage(): Promise<{ tree: string; truncated: boolean }> {\n const page = readDom();\n // Порядок важен: `dominantCanvas` смотрит на `refs`, которые заполняет `readDom`.\n if (!dominantCanvas()) return page;\n\n const exposed = Object.keys(agentModules());\n const head =\n \"\\n\\n[This screen is drawn into a <canvas>: the DOM above says nothing about what happens \" +\n \"inside it, so a tree with nothing in it is NOT an empty screen.\";\n\n let note = head;\n try {\n const summary = summarize(await observeRuntime({ pixels: true }));\n if (summary) note += ` Observed automatically: ${summary}.`;\n } catch {\n /* автоматический слой не обязан удаваться — дерево важнее и уже собрано */\n }\n\n note +=\n exposed.length > 0\n ? ` Call GetGameState for the full picture — modules exposing a debug surface: ${exposed.join(\", \")}.]`\n : ` Call GetGameState for the full picture. No module exposes a debug surface ` +\n `(ctx.exposeToAgent), so gameplay state beyond these numbers is not observable — adding that ` +\n `surface to the game module is what makes it observable.]`;\n\n return { tree: page.tree + note, truncated: page.truncated };\n}\n\n/* ------------------------------------------------------------- действия */\n\nfunction settle(): Promise<void> {\n return new Promise((resolve) => window.setTimeout(resolve, SETTLE_MS));\n}\n\nfunction resolveRef(ref: unknown): Element {\n if (typeof ref !== \"string\") throw new Error(\"ref is required\");\n const el = refs.get(ref);\n if (!el) throw new Error(`${ref} is unknown — call readPage first`);\n if (!el.isConnected)\n throw new Error(\n `${ref} is no longer in the document — call readPage again`,\n );\n return el;\n}\n\nasync function clickRef(ref: unknown): Promise<unknown> {\n const el = resolveRef(ref);\n if (typeof (el as HTMLElement).click !== \"function\")\n throw new Error(\"element is not clickable\");\n (el as HTMLElement).click();\n await settle();\n return readPage();\n}\n\nasync function typeIntoRef(ref: unknown, text: unknown): Promise<unknown> {\n const el = resolveRef(ref);\n if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement))\n throw new Error(\"element is not a text field\");\n\n // Контролируемому React-полю мало el.value = …: React слушает нативный сеттер, и без него\n // состояние компонента не обновится, а значение откатится на следующем рендере.\n const proto =\n el instanceof HTMLInputElement\n ? HTMLInputElement.prototype\n : HTMLTextAreaElement.prototype;\n const setter = Object.getOwnPropertyDescriptor(proto, \"value\")?.set;\n if (setter) setter.call(el, String(text ?? \"\"));\n else el.value = String(text ?? \"\");\n\n el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n el.dispatchEvent(new Event(\"change\", { bubbles: true }));\n await settle();\n return readPage();\n}\n\n/* ------------------------------------------------- состояние игровых модулей */\n\n/**\n * Снимок всех модулей, которые открылись агенту, плюс перечень их действий.\n *\n * Ошибку в чужом `state()` не роняем на весь ответ: один сломанный модуль не должен ослеплять\n * агента по остальным — он получит текст ошибки ровно на месте этого модуля.\n */\nfunction moduleSurfaces(): Record<string, unknown> {\n const modules = agentModules();\n const ids = Object.keys(modules);\n if (ids.length === 0) {\n return {\n available: false,\n hint:\n \"No module exposes a debug surface (ctx.exposeToAgent), so nothing beyond the automatic \" +\n \"observation above can be seen: player position, score, current turn and the ability to DRIVE \" +\n \"the game all come from that surface. If you need them, add exposeToAgent to the game module's \" +\n \"setup() — it is a few lines and it is what makes the game verifiable from here.\",\n };\n }\n\n const out: Record<string, unknown> = {};\n for (const id of ids) {\n const api = modules[id];\n try {\n out[id] = {\n state: api?.state ? api.state() : null,\n actions: api?.describeActions ?? {},\n };\n } catch (error: unknown) {\n out[id] = { error: stringifyArg(error) };\n }\n }\n return { available: true, modules: out };\n}\n\n/**\n * Ответ на `gameState`: автоматический слой ВСЕГДА, поверхности модулей — если они есть.\n *\n * Порядок именно такой и он важен: даже игра, о которой никто ничего не рассказал, отвечает\n * цифрами (полотно, кадры, сцена, цвет экрана), а не пустотой. «Мне ничего не видно» — худший\n * из возможных ответов: он неотличим от «на экране пусто» и толкает агента чинить исправное.\n */\nasync function gameState(): Promise<unknown> {\n return {\n observed: await observeRuntime({ pixels: true }),\n exposedByGame: moduleSurfaces(),\n // События между модулями: log (последние 50), listeners/emitted (кто что слушает и шлёт) и\n // mismatches — подсказки вида «слушают @1, а шлют @2».\n events: eventBusState(),\n };\n}\n\n/** Выполнить действие модуля и вернуть состояние ПОСЛЕ него — как readPage после клика. */\nasync function gameAction(\n moduleId: unknown,\n action: unknown,\n rawArgs: unknown,\n): Promise<unknown> {\n const modules = agentModules();\n const id = typeof moduleId === \"string\" ? moduleId : Object.keys(modules)[0];\n if (!id) throw new Error(\"no module exposes actions\");\n\n const api = modules[id];\n if (!api) throw new Error(`unknown module '${id}'`);\n\n const name = typeof action === \"string\" ? action : \"\";\n const fn = api.actions?.[name];\n if (!fn) {\n const known = Object.keys(api.actions ?? {}).join(\", \") || \"none\";\n throw new Error(\n `unknown action '${name}' for '${id}' — available: ${known}`,\n );\n }\n\n const callArgs =\n rawArgs && typeof rawArgs === \"object\"\n ? (rawArgs as Record<string, unknown>)\n : {};\n const result = await fn(callArgs);\n\n // Состояние после действия — то, ради чего действие и звали.\n return {\n module: id,\n action: name,\n result: result ?? null,\n state: api.state ? api.state() : null,\n };\n}\n\n/* ---------------------------------------------------------------- протокол */\n\nasync function handle(\n cmd: string,\n args: Record<string, unknown>,\n): Promise<unknown> {\n switch (cmd) {\n case \"hello\":\n return {\n ready: true,\n titleId: probeOptions.titleId ?? null,\n url: window.location.href,\n };\n\n case \"readPage\": {\n const page = await readPage();\n return {\n ...page,\n url: window.location.href,\n titleId: probeOptions.titleId ?? null,\n };\n }\n\n case \"console\":\n return { console: consoleLog.slice(), network: networkLog.slice() };\n\n case \"click\":\n return await clickRef(args[\"ref\"]);\n\n case \"type\":\n return await typeIntoRef(args[\"ref\"], args[\"text\"]);\n\n case \"gameState\":\n return await gameState();\n\n case \"gameAction\":\n return await gameAction(args[\"module\"], args[\"action\"], args[\"args\"]);\n\n case \"input\":\n return await sendInput(args[\"args\"]);\n\n default:\n throw new Error(`unknown command '${cmd}'`);\n }\n}\n\n/**\n * Ставит зонд (и дополняет его данными о тайтле при повторном вызове).\n *\n * Модуль ставит зонд САМ при импорте — см. вызов внизу файла. Это не стилистика: перехват\n * console обязан встать раньше, чем упадёт что-нибудь на старте (например config.ts, который\n * бросает при нераспознанном тайтле), а вызовы из main.tsx исполняются уже ПОСЛЕ того, как\n * отработали тела всех импортированных модулей. Поэтому в main.tsx этот импорт стоит первым.\n *\n * Вне iframe (обычный запуск игры) не делает ничего.\n */\nexport function installPreviewProbe(options: PreviewProbeOptions = {}): void {\n probeOptions = { ...probeOptions, ...options };\n if (installed) return;\n if (typeof window === \"undefined\") return;\n // Не в iframe — значит это не превью дашборда. Ни перехватов, ни слушателей.\n if (window.self === window.top) return;\n\n installed = true;\n captureConsole();\n captureNetwork();\n // Перехваты автоматического наблюдения ставятся ЗДЕСЬ, при импорте зонда, и это единственный\n // момент, когда они успевают: `getContext` надо подменить раньше, чем движок создаст полотно, а\n // глобалы `__THREE_DEVTOOLS__` и `__PIXI_*_INIT__` — раньше, чем three.js и Pixi построят свои\n // рендереры (оба смотрят на них при инициализации и второго шанса представиться не дают).\n // Phaser своего канала не имеет вовсе — его игру ищут лениво, при сборке снимка.\n captureFrames();\n captureCanvases();\n captureThree();\n capturePixi();\n\n window.addEventListener(\"message\", (event: MessageEvent) => {\n const data = event.data as ProbeRequest | undefined;\n if (!data || data.wire !== WIRE || typeof data.id !== \"string\") return;\n // Чужой встраиватель (публичный сайт) сюда не пройдёт — и не узнает, что зонд вообще есть.\n if (!isAllowedOrigin(event.origin)) return;\n\n const source = event.source as Window | null;\n if (!source) return;\n\n const reply = (payload: Record<string, unknown>): void => {\n source.postMessage({ wire: WIRE, id: data.id, ...payload }, event.origin);\n };\n\n void handle(data.cmd, data.args ?? {})\n .then((result) => reply({ ok: true, data: result }))\n .catch((error: unknown) =>\n reply({ ok: false, error: stringifyArg(error) }),\n );\n });\n}\n\n// Само-установка при импорте — см. комментарий выше.\ninstallPreviewProbe();\n"
117
+ "path": "src/ui.config.ts",
118
+ "content": "import { defineUiConfig } from \"@idosgames/react/ui\";\n\n// The game's interface in ONE place: colours, animations and sounds of every module built on\n// @idosgames/react/ui (shop, inventory, heroes, rewards, marketplace, …). Change them here — never in\n// a module: an edited catalog module is marked \"customized\" and stops getting catalog updates.\n//\n// Everything is optional; what you leave out keeps its default. Uncomment and edit.\nexport default defineUiConfig({\n theme: {\n // bgTop: \"#232a6b\", // screen background, top of the gradient\n // bgBottom: \"#0d1033\", // … and bottom\n // panel: \"#2c3480\", // cards and popups\n // panelEdge: \"#4f5bc9\", // their frame\n // gold: \"#ffd24a\", // prices, highlights, the \"claim\" button\n // green: \"#59d35b\", // main action buttons\n // radius: 18, // corner radius of cards, px\n // buttonRadius: 14, // corner radius of buttons, px\n // font: \"'Baloo 2', 'Nunito', system-ui, sans-serif\",\n // rarity: { Mythic: \"#ff3df5\" }, // frame colour of a rarity id (adds to Common…Legendary)\n },\n motion: {\n // enabled: true, // false — no animations at all\n // speed: 1, // 2 — twice as fast, 0.5 — twice as slow\n // intensity: 1, // bounce/overshoot strength: 0 none … 2 cartoonish\n // rewardFlight: true, // reward icons fly to the balance counters\n // particles: true, // particle bursts on claims and purchases\n // stagger: true, // lists appear item by item\n // respectReducedMotion: true,// follow the system \"reduce motion\" setting\n },\n sound: {\n // enabled: true, // false — a silent interface\n // volume: 0.6, // master volume 0..1 (the player's own volume multiplies it)\n events: {\n // Built-in sounds are synthesized in code (no files). Per event:\n // \"coin\" — the built-in sound of another event\n // { url: \"https://…/click.mp3\" } — your own file (optional volume: 0..1)\n // { synth: { notes: [{ freq: 880, dur: 0.08, wave: \"triangle\" }] } } — your own synth\n // false — silence\n // Events: click, tab, open, close, reward, coin, purchase, claim, error, notify, lootbox,\n // levelUp, countTick.\n // click: \"tab\",\n // countTick: false,\n },\n },\n});\n"
67
119
  },
68
120
  {
69
121
  "path": "src/vite-env.d.ts",
@@ -71,15 +123,15 @@
71
123
  },
72
124
  {
73
125
  "path": "src/walletLogin.tsx",
74
- "content": "import { LazyWalletLogin } from \"@idosgames/wallet/react/lazy\";\nimport type { RenderLinkWallet } from \"@idosgames/app-shell\";\nimport type { LoginScreenExtras } from \"./LoginScreen\";\nimport { ENV_WALLETCONNECT_PROJECT_ID } from \"./env\";\nimport {\n IDOS_WEB3_NETWORK_ID,\n IDOS_WEB3_WALLETCONNECT_PROJECT_ID,\n} from \"./idos.title\";\n\n// Wallet sign-in seam — ON by default: every project offers \"Sign in with wallet\".\n//\n// The button uses the SDK's default EVM chain set (no `chains` prop). The challenge network comes\n// from the title (IDOS_WEB3_NETWORK_ID, baked by the platform); the \"bsc\" fallback keeps the button\n// alive on titles that did not pick a network at creation. A title whose Blockchain config is empty\n// will surface the server's error under the button — fix that by configuring the network (backend\n// MCP: get_blockchain / save_blockchain), not by hiding the button. To restrict the chains, import\n// them from this same subpath (mainnet, bsc, polygon, base, arbitrum, optimism, sepolia,\n// polygonAmoy — plain objects, safe to import) and pass `chains={[…]}`.\n//\n// For a title created with the web3 toggle the platform regenerates this file with the title's own\n// network. Keeping the seam in its own file is what makes that swap (and your own edits) safe.\n//\n// The import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\" — that matters. The\n// wallet stack (Reown AppKit) reaches `viem/chains` → `ox`, whose top-level `2n ** (24n - 1n)`\n// constants the live preview's classic bundler compiles into `Math.pow` — a Number-only API that\n// throws on BigInt, blanking the preview before any game code runs. The lazy entry keeps all of that\n// out of the page until the player actually taps sign-in, so the preview stays usable; real builds\n// behave identically either way.\n//\n// walletConnectProjectId adds MOBILE wallets via the WalletConnect QR/deep-link modal; without it\n// the button only works with injected wallets (MetaMask & co) and mobile login is unavailable. It\n// comes from the title's blockchain config (IDOS_WEB3_WALLETCONNECT_PROJECT_ID, baked by the\n// platform); VITE_WALLETCONNECT_PROJECT_ID in .env.local is a local-dev fallback.\n//\n// No `chains` is passed on purpose: the button falls back to the SDK's default EVM chain set, which\n// is the SAME list the in-game wallet panel uses. That shared list is what lets the login and the\n// panel share one wallet config, so a wallet connected here stays connected in the game.\n\nexport const renderWalletLogin: LoginScreenExtras[\"renderWalletLogin\"] = ({\n client,\n onAuthenticated,\n disabled,\n style,\n}) => (\n <LazyWalletLogin\n client={client}\n networkID={IDOS_WEB3_NETWORK_ID || \"bsc\"}\n onAuthenticated={onAuthenticated}\n disabled={disabled}\n style={style}\n walletConnectProjectId={\n IDOS_WEB3_WALLETCONNECT_PROJECT_ID ||\n ENV_WALLETCONNECT_PROJECT_ID ||\n undefined\n }\n />\n);\n\n// \"Link wallet\" on the play-access screen (./PlayAccessScreen) — the same button in link mode. When\n// the title's login mode requires a token balance, a player who signed in another way attaches a\n// wallet to the account by signature, and the server checks the balance against exactly that\n// wallet. `networkID` is the network a requirement needs; the title's own network otherwise.\nexport const renderWalletLink: RenderLinkWallet = ({\n client,\n networkID,\n onLinked,\n disabled,\n style,\n}) => (\n <LazyWalletLogin\n mode=\"link\"\n client={client}\n networkID={networkID || IDOS_WEB3_NETWORK_ID || \"bsc\"}\n onLinked={onLinked}\n disabled={disabled}\n style={style}\n walletConnectProjectId={\n IDOS_WEB3_WALLETCONNECT_PROJECT_ID ||\n ENV_WALLETCONNECT_PROJECT_ID ||\n undefined\n }\n />\n);\n"
126
+ "content": "import { LazyWalletLogin } from \"@idosgames/wallet/react/lazy\";\nimport type { RenderLinkWallet } from \"@idosgames/app-shell\";\nimport type { LoginScreenExtras } from \"./base/login/LoginScreen\";\nimport { ENV_WALLETCONNECT_PROJECT_ID } from \"./env\";\nimport {\n IDOS_WEB3_NETWORK_ID,\n IDOS_WEB3_WALLETCONNECT_PROJECT_ID,\n} from \"./idos.title\";\n\n// Wallet sign-in seam — ON by default: every project offers \"Sign in with wallet\".\n//\n// The button uses the SDK's default EVM chain set (no `chains` prop). The challenge network comes\n// from the title (IDOS_WEB3_NETWORK_ID, baked by the platform); the \"bsc\" fallback keeps the button\n// alive on titles that did not pick a network at creation. A title whose Blockchain config is empty\n// will surface the server's error under the button — fix that by configuring the network (backend\n// MCP: get_blockchain / save_blockchain), not by hiding the button. To restrict the chains, import\n// them from this same subpath (mainnet, bsc, polygon, base, arbitrum, optimism, sepolia,\n// polygonAmoy — plain objects, safe to import) and pass `chains={[…]}`.\n//\n// For a title created with the web3 toggle the platform regenerates this file with the title's own\n// network. Keeping the seam in its own file is what makes that swap (and your own edits) safe.\n//\n// The import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\". The wallet stack\n// (Reown AppKit + wagmi/viem) is megabytes of code most players never need; the lazy entry keeps it\n// out of the first load until the player actually taps sign-in.\n//\n// walletConnectProjectId adds MOBILE wallets via the WalletConnect QR/deep-link modal; without it\n// the button only works with injected wallets (MetaMask & co) and mobile login is unavailable. It\n// comes from the title's blockchain config (IDOS_WEB3_WALLETCONNECT_PROJECT_ID, baked by the\n// platform); VITE_WALLETCONNECT_PROJECT_ID in .env.local is a local-dev fallback.\n//\n// No `chains` is passed on purpose: the button falls back to the SDK's default EVM chain set, which\n// is the SAME list the in-game wallet panel uses. That shared list is what lets the login and the\n// panel share one wallet config, so a wallet connected here stays connected in the game.\n\nexport const renderWalletLogin: LoginScreenExtras[\"renderWalletLogin\"] = ({\n client,\n onAuthenticated,\n disabled,\n style,\n}) => (\n <LazyWalletLogin\n client={client}\n networkID={IDOS_WEB3_NETWORK_ID || \"bsc\"}\n onAuthenticated={onAuthenticated}\n disabled={disabled}\n style={style}\n walletConnectProjectId={\n IDOS_WEB3_WALLETCONNECT_PROJECT_ID ||\n ENV_WALLETCONNECT_PROJECT_ID ||\n undefined\n }\n />\n);\n\n// \"Link wallet\" on the play-access screen (./base/login/PlayAccessScreen) — the same button in link mode. When\n// the title's login mode requires a token balance, a player who signed in another way attaches a\n// wallet to the account by signature, and the server checks the balance against exactly that\n// wallet. `networkID` is the network a requirement needs; the title's own network otherwise.\nexport const renderWalletLink: RenderLinkWallet = ({\n client,\n networkID,\n onLinked,\n disabled,\n style,\n}) => (\n <LazyWalletLogin\n mode=\"link\"\n client={client}\n networkID={networkID || IDOS_WEB3_NETWORK_ID || \"bsc\"}\n onLinked={onLinked}\n disabled={disabled}\n style={style}\n walletConnectProjectId={\n IDOS_WEB3_WALLETCONNECT_PROJECT_ID ||\n ENV_WALLETCONNECT_PROJECT_ID ||\n undefined\n }\n />\n);\n"
75
127
  },
76
128
  {
77
129
  "path": "tsconfig.json",
78
- "content": "{\n \"$schema\": \"https://json.schemastore.org/tsconfig\",\n // Self-contained on purpose: this template is copied out of the monorepo (as the seed for an AI\n // Coder project) and must typecheck on its own, so it does not `extends` tsconfig.base.json. Keep\n // the shared options below in sync with tsconfig.base.json.\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n\n \"strict\": true,\n \"noUncheckedIndexedAccess\": true,\n \"noImplicitOverride\": true,\n \"noFallthroughCasesInSwitch\": true,\n \"useUnknownInCatchVariables\": true,\n\n \"verbatimModuleSyntax\": true,\n \"isolatedModules\": true,\n \"esModuleInterop\": true,\n \"resolveJsonModule\": true,\n \"skipLibCheck\": true,\n \"forceConsistentCasingInFileNames\": true,\n\n \"noEmit\": true,\n \"jsx\": \"react-jsx\",\n\n // Inside the monorepo these point typecheck at the SDK/host source, so no SDK build is needed.\n // Outside it the paths do not exist and TypeScript falls back to node_modules resolution.\n \"paths\": {\n \"@idosgames/core\": [\"../../packages/core/src/index.ts\"],\n \"@idosgames/module-sdk\": [\"../../packages/module-sdk/src/index.ts\"],\n \"@idosgames/react\": [\"../../packages/react/src/index.ts\"],\n \"@idosgames/app-shell\": [\"../../packages/app-shell/src/index.ts\"]\n }\n },\n \"include\": [\"src\"],\n // Tests are not part of the game: the platform builds with no test runner installed, so a\n // `*.test.ts` that imports vitest would fail every release build. Keep them out of the typecheck.\n \"exclude\": [\n \"src/**/*.test.ts\",\n \"src/**/*.test.tsx\",\n \"src/**/*.spec.ts\",\n \"src/**/*.spec.tsx\"\n ]\n}\n"
130
+ "content": "{\n \"$schema\": \"https://json.schemastore.org/tsconfig\",\n // Self-contained on purpose: this template is copied out of the monorepo (as the seed for an AI\n // Coder project) and must typecheck on its own, so it does not `extends` tsconfig.base.json. Keep\n // the shared options below in sync with tsconfig.base.json.\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n\n \"strict\": true,\n \"noUncheckedIndexedAccess\": true,\n \"noImplicitOverride\": true,\n \"noFallthroughCasesInSwitch\": true,\n \"useUnknownInCatchVariables\": true,\n\n \"verbatimModuleSyntax\": true,\n \"isolatedModules\": true,\n \"esModuleInterop\": true,\n \"resolveJsonModule\": true,\n \"skipLibCheck\": true,\n \"forceConsistentCasingInFileNames\": true,\n\n \"noEmit\": true,\n \"jsx\": \"react-jsx\",\n\n // Inside the monorepo these point typecheck at the SDK/host source, so no SDK build is needed.\n // Outside it the paths do not exist and TypeScript falls back to node_modules resolution.\n \"paths\": {\n \"@idosgames/core\": [\"../../packages/core/src/index.ts\"],\n \"@idosgames/core/realtime\": [\"../../packages/core/src/realtime/index.ts\"],\n \"@idosgames/module-sdk\": [\"../../packages/module-sdk/src/index.ts\"],\n \"@idosgames/react/ui\": [\"../../packages/react/src/ui/index.ts\"],\n \"@idosgames/react\": [\"../../packages/react/src/index.ts\"],\n \"@idosgames/app-shell\": [\"../../packages/app-shell/src/index.ts\"]\n }\n },\n \"include\": [\"src\"],\n // Tests are not part of the game: the platform builds with no test runner installed, so a\n // `*.test.ts` that imports vitest would fail every release build. Keep them out of the typecheck.\n \"exclude\": [\n \"src/**/*.test.ts\",\n \"src/**/*.test.tsx\",\n \"src/**/*.spec.ts\",\n \"src/**/*.spec.tsx\"\n ]\n}\n"
79
131
  },
80
132
  {
81
133
  "path": "vite.config.ts",
82
- "content": "import { existsSync } from \"node:fs\";\nimport { fileURLToPath } from \"node:url\";\nimport { defineConfig, loadEnv } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\nconst fromHere = (path: string): string =>\n fileURLToPath(new URL(path, import.meta.url));\n\n// Inside this monorepo, resolve the @idosgames/* packages to their source for instant HMR (no SDK/\n// host rebuild needed in dev). A standalone copy of this template — a cloned starter, or an AI Coder\n// project built in a container — has no such paths and resolves each package from node_modules.\nconst sourceAliases: Record<string, string> = {\n \"@idosgames/core\": fromHere(\"../../packages/core/src/index.ts\"),\n \"@idosgames/module-sdk\": fromHere(\"../../packages/module-sdk/src/index.ts\"),\n \"@idosgames/react\": fromHere(\"../../packages/react/src/index.ts\"),\n \"@idosgames/app-shell\": fromHere(\"../../packages/app-shell/src/index.ts\"),\n};\n\nexport default defineConfig(({ mode }) => {\n const env = loadEnv(mode, process.cwd(), \"\");\n const alias = Object.fromEntries(\n Object.entries(sourceAliases).filter(([, path]) => existsSync(path)),\n );\n return {\n plugins: [react()],\n resolve: { alias },\n server: { port: 5180 },\n // Env is injected via the __IDOS_ENV__ global (not import.meta.env) so the same files build under\n // both real vite and the classic preview bundler (Sandpack), which can't parse import.meta.\n // See src/env.ts.\n define: {\n __IDOS_ENV__: JSON.stringify({\n DEV: mode !== \"production\",\n TITLE_ID: env.VITE_IDOS_TITLE_ID ?? \"\",\n BUILD_KEY: env.VITE_IDOS_BUILD_KEY ?? \"\",\n // \"dev\" | \"prod\" — which copy of the title a LOCAL run talks to (src/config.ts). Hosted\n // builds take it from their address instead; the link never decides it.\n ENV: env.VITE_IDOS_ENV ?? \"\",\n WALLETCONNECT_PROJECT_ID: env.VITE_WALLETCONNECT_PROJECT_ID ?? \"\",\n GOOGLE_CLIENT_ID: env.VITE_IDOS_GOOGLE_CLIENT_ID ?? \"\",\n }),\n },\n };\n});\n"
134
+ "content": "import { existsSync } from \"node:fs\";\nimport { fileURLToPath } from \"node:url\";\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\nconst fromHere = (path: string): string =>\n fileURLToPath(new URL(path, import.meta.url));\n\n// Inside this monorepo, resolve the @idosgames/* packages to their source for instant HMR (no SDK/\n// host rebuild needed in dev). A standalone copy of this template — a cloned starter, or an AI Coder\n// project built in a container — has no such paths and resolves each package from node_modules.\nconst sourceAliases: Record<string, string> = {\n // ⚠ Subpaths BEFORE the package: a string alias matches its prefix too, so \"@idosgames/core\"\n // would turn \"@idosgames/core/realtime\" into \".../src/index.ts/realtime\".\n \"@idosgames/core/realtime\": fromHere(\n \"../../packages/core/src/realtime/index.ts\",\n ),\n \"@idosgames/core\": fromHere(\"../../packages/core/src/index.ts\"),\n \"@idosgames/module-sdk\": fromHere(\"../../packages/module-sdk/src/index.ts\"),\n \"@idosgames/react/ui\": fromHere(\"../../packages/react/src/ui/index.ts\"),\n \"@idosgames/react\": fromHere(\"../../packages/react/src/index.ts\"),\n \"@idosgames/app-shell\": fromHere(\"../../packages/app-shell/src/index.ts\"),\n};\n\n// Env comes the standard Vite way: VITE_* variables of .env.local reach the code as\n// `import.meta.env.VITE_*` (src/env.ts, src/config.ts). VITE_IDOS_ENV (\"dev\" | \"prod\") picks which\n// copy of the title a LOCAL run talks to; hosted builds take it from their address instead.\nexport default defineConfig(() => {\n const alias = Object.fromEntries(\n Object.entries(sourceAliases).filter(([, path]) => existsSync(path)),\n );\n return {\n plugins: [react()],\n resolve: { alias },\n server: { port: 5180 },\n };\n});\n"
83
135
  }
84
136
  ]
85
137
  }