@idosgames/mcp 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -48,12 +48,12 @@ function moduleExportName(id) {
48
48
  var TOOLS = [
49
49
  {
50
50
  name: "list_modules",
51
- description: "List the composable iDosGames modules in the catalog. Each entry has id, name, type (template = a complete ready-to-run game to start FROM, e.g. voxelcraft; feature = a capability to add on top), engine, genre, a short summary, what it 'provides' (its features), tags, preview media, a live demo url, and its dependency count. Read 'provides' to reuse a ready module instead of building the feature from scratch, then get_module to pull its source.",
51
+ description: "List the composable iDosGames modules in the catalog. Each entry has id, name, type (template = a complete ready-to-run game to start FROM, e.g. voxelcraft; feature = a capability to add on top), engine, genre, a short summary, what it 'provides' (its features), tags, preview media, a live demo url, and its dependency count. Read 'provides' to reuse a ready module instead of building the feature from scratch, then get_module to pull its source. Entries may also carry 'sharedUi' ({provides, requires}: shared chrome roles currency-bar | wallet | status | account \u2014 a provider such as game-hud draws them once for every mode, and templates hide their own copies automatically; mixing two or more templates \u2192 install game-hud) and 'events' ({emits: [{topic, when, payload}], listens: [{topic, why}]}). To react to another module's event, copy its payload descriptor into your own defineTopic(topic, shape(payload)) \u2014 never import another module.",
52
52
  inputSchema: { type: "object", properties: {} }
53
53
  },
54
54
  {
55
55
  name: "get_module",
56
- description: "Get one module's full source (a map of files) plus install instructions. Write each file under src/modules/{id}/, install its dependencies, and register it in src/modules.ts. Requires a host scaffold in the project (see get_host_scaffold).",
56
+ description: "Get one module's full source (a map of files) plus install instructions. Write each file under src/modules/{id}/, install its dependencies, and register it in src/modules.ts. Requires a host scaffold in the project (see get_host_scaffold). Its module.ts declares 'sharedUi' (roles it draws for every mode or needs) and its module.meta.json declares 'events' (topics it emits with their payload shapes, and topics it listens to).",
57
57
  inputSchema: {
58
58
  type: "object",
59
59
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,9 +1,21 @@
1
1
  {
2
2
  "id": "host-starter",
3
3
  "files": [
4
+ {
5
+ "path": "AGENTS.md",
6
+ "content": "# AGENTS.md\n\nThis game's instructions for AI agents live in [IDOS.md](IDOS.md) — read it first. It covers what\nthe game is, how the project is laid out, where new code goes, and how the project keeps its history\n(`docs/feature-history/`).\n\nKeep this file a pointer: put guidance in IDOS.md, so every agent reads the same rules.\n"
7
+ },
8
+ {
9
+ "path": "CLAUDE.md",
10
+ "content": "# CLAUDE.md\n\nThis game's instructions live in IDOS.md — shared by every AI agent working on it. Keep this file a\npointer; put guidance in IDOS.md.\n\n@IDOS.md\n"
11
+ },
12
+ {
13
+ "path": "docs/feature-history/README.md",
14
+ "content": "# Feature history\n\nOne file per game system or feature: what was built, why, and the decisions the code alone does not\nshow. Not loaded automatically — open the entry you need before changing that system. A new system\ngets a new file here plus ONE line below; an existing one gets its file updated. Never grow IDOS.md\ninstead.\n\n<!-- One line per entry: - [Title](slug.md) — one-line gist -->\n"
15
+ },
4
16
  {
5
17
  "path": "IDOS.md",
6
- "content": "# IDOS.md — project memory\n\nThis file is loaded into the AI editor's context on every run. Keep it short and current: record\ndurable conventions and constraints here, delete anything that goes stale.\n\n## What this project is\n\nA **host shell + feature modules** app built on the iDosGames SDK.\n\n- `src/main.tsx` — the host: creates ONE `IDosGamesClient`, logs the player in, then calls\n `mountHost({ container, client, modules })`. Modules never create their own client or log in.\n- `src/modules.ts` — the composition list. Every module the project uses is imported and listed\n here; a fresh project starts empty.\n- `src/modules/<id>/` — one folder per feature module (its source, already copied in).\n- `src/idos.title.ts` — **generated, do not edit.** The project's identity: which Title's data this\n game reads and writes. Baked into the bundle so the build still knows its Title where there is no\n URL to read packaged as a mobile app, embedded in an iframe, or opened from a shared link.\n- `src/config.ts` / `src/env.ts` resolve the effective title (identity from `idos.title.ts`,\n environment on top) and the build key. Do not hardcode either elsewhere, and never read the title\n from the URL yourself: use `client.titleID`, or `ctx.titleId` inside a module's `setup()`.\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\nBecause of that:\n\n1. **Load the matching skill first.** Skills are the authoritative recipes for this SDK — there is\n one per service (store, currency, item, quest, leaderboard, auth, blockchain, …). Load the\n relevant one _before_ writing code that touches `@idosgames/*`.\n2. Then copy the patterns from the modules already in `src/modules/`.\n3. Never invent an SDK method, hook, or type name. An invented API looks plausible and fails the\n build.\n\nCommon entry points: `client.<service>.<action>()` returns `OperationResult<T>` (check `isOk` /\n`isFail`; it does not throw). In React, reach the client with `useIDosGamesClient()` and player\nstate with `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 by the publisher in the platform UI, per environment. There is **no config file in this\nproject** — do not create `config/public-configuration.json` or any other `config/*.json`; they are\nignored.\n\nWrite code against ids/keys that already exist in the title. If a feature needs an entity that is\nnot configured yet, say so explicitly and name exactly what has to be added, instead of inventing\nids. Code/configuration mismatch is the most common cause of a feature that builds but does nothing.\n\n## Constraints\n\n- No package installs, no native dependencies use what `package.json` already lists.\n- No terminal. The build runs on the server after each turn and its errors come back to you.\n- Client-side code shipped to players: never put secrets, keys or tokens in any file here.\n- Assets are not stored in the project — generated images/audio/3D return CDN URLs; reference those.\n\n## Project notes\n\n<!-- Record durable decisions and conventions below as the project grows. -->\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\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` / `src/env.ts` resolve the effective title and build key. Never read the title\n from the URL yourself: use `client.titleID`, or `ctx.titleId` inside a module's `setup()`.\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"
7
19
  },
8
20
  {
9
21
  "path": "index.html",
@@ -11,7 +23,7 @@
11
23
  },
12
24
  {
13
25
  "path": "package.json",
14
- "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.1.17\",\n \"@idosgames/core\": \"0.11.0\",\n \"@idosgames/module-sdk\": \"0.1.12\",\n \"@idosgames/react\": \"0.2.4\",\n \"@idosgames/wallet\": \"0.2.4\",\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/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.2.0\",\n \"@idosgames/core\": \"0.12.0\",\n \"@idosgames/module-sdk\": \"0.2.0\",\n \"@idosgames/react\": \"0.2.5\",\n \"@idosgames/wallet\": \"0.2.5\",\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"
15
27
  },
16
28
  {
17
29
  "path": "public/sw.js",
@@ -47,7 +59,7 @@
47
59
  },
48
60
  {
49
61
  "path": "src/previewProbe.ts",
50
- "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};\n\ntype AgentGlobal = {\n version?: number;\n modules?: Record<string, AgentModuleApi>;\n host?: AgentHostState;\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}\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 };\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"
62
+ "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"
51
63
  },
52
64
  {
53
65
  "path": "src/vite-env.d.ts",