# Агентный мост (EngineAgent + window.__agent)

Агентный мост — набор инструментов, позволяющий ИИ-агенту (или тесту) управлять
игрой и читать её состояние **без скриншотов и «слепых» кликов по CSS-пикселям**.
Мост не заменяет рендер и реальный ввод, а идёт поверх них: инъекция ввода проходит
через те же структуры `InputManager`, шаг логики — через тот же `GameLoop`.

## Слои

```
packages/engine/src/agent/        — движковый каркас (жанронезависимый)
  types.ts        JsonValue, SnapshotLayer, Invariant, SceneAgent, AgentHost
  invariants.ts   чистые хелперы: checkFinite/checkRange/checkBounds/checkWalkable
  EngineAgent.ts  снапшот, инварианты, ручной шаг, инъекция ввода, waitFor

apps/game/src/agent/              — контентный слой игры
  snapshot.ts     чистые сборщики слоёв (hero/enemies/npcs/dialogue/game) — Vitest
  GameAgent.ts    регистрирует window.__agent (только DEV), high-level хелперы

apps/game/src/data/validate.ts    — runtime-валидация контента → Invariant[]
packages/engine/tools/agent-lib.mjs         — браузерный каркас (puppeteer, openGame, Checks)
apps/game/tools/lib.mjs                     — сценарный слой игры (маяк загрузки поверх каркаса)
apps/game/tools/agent.mjs                   — CLI: check / run / snapshot / screenshot / dev
```

Граница: движок не знает ничего об RPG-контенте. `EngineAgent` общается со сценой
через интерфейс `SceneAgent` (по образцу `StorageLike`), что позволяет тестировать
каркас в Vitest без Pixi и браузера.

## SceneAgent — что сцена отдаёт мосту

```ts
interface SceneAgent {
    agentSnapshot(): SnapshotLayer;              // слой сцены в снапшот
    agentInvariants(): Invariant[];              // инварианты сцены
    agentCommand?(name: string, args?: JsonValue): JsonValue;  // whitelist-команды
}
```

`LocationScene` отдаёт: `scene/area/areaName`, `hero {tile, pos, hp, facing, moving,
invuln, inHazard}`, `enemies [{kind, state, hp, pos, asleep, dead}]`, `npcs`,
`transitions`, `dialogue {id, nodeId, text, choices, waitingForChoice} | null`,
`cutscene`, `lastToast {text, tick}` (единственный канал текста реакций — иначе
агенту нужен OCR), `collision {width, height, blocked (0/1 по тайлам, включает
footprint пропов), props}` — карта коллизий для проверки движения. `MenuScene`
отдаёт `{scene: 'menu'}` и команду `menu:newGame`.

Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках):
`scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`,
`scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A*), `scene:pickChoice {index}`,
`scene:skipCutscene`, `scene:noise {x, y, level}` (шум в тайле: 0.35 — бодрые
слышат в hearRadius, 0.7+ — будит спящих), `scene:damageEnemy {id, value}`
(урон сгустку — проверки отступления), `scene:walkable {x, y}` (проходим ли
тайл: стены, вода, footprint пропов), `scene:raycast {from, to}` (прямая
видимость между тайлами: false — высокий объект на отрезке). Команда вне
whitelist возвращает `null` — расширять осознанно.

Состояние врага читается из снапшота: `s.enemies[i].{kind, state, hp, pos,
asleep, dead}` — состояния `dormant/rise/patrol/wary/chase/windup/attack/
recover/hurt/flee/return/sleeping/dead` (см. `systems/combat/EnemyBrain.ts`).

## window.__agent (только DEV)

```ts
interface AgentApi {
    version: 1;
    snapshot(): GameSnapshot;                    // движковый слой + сцена + флаги/вары/сумка
    invariants(): Invariant[];                   // движковые + сценические + контентные
    step(n?, {render}?): {tick};                 // n фиксированных шагов (60 Гц), рендер опционален
    waitFor(pred: string, opts?): Promise<{ok, snapshot, ticks}>;
    tapTile(tx, ty); tapVirtual(vx, vy); uiTap(vx, vy);
    press(action, holdTicks?); key(code);
    command(name, args?): JsonValue;
    walkTo(tx, ty, opts?): Promise<boolean>;     // маршрут A* + клики по узлам
    runDialogue(timeoutTicks?): Promise<boolean>;// листает диалог до конца
    newGame(): Promise<void>;
    currentArea(): string | null;
}
```

- **pred — строка-выражение** над снапшотом (`'s.hero.hp < 3'`): из браузера нельзя
  передать функцию. Компилируется `new Function('s', ...)` — источник доверенный (apps/game/tools/).
- **waitFor крутит шаги сам** и вызывает pred после каждого шага; сцены могут
  меняться в процессе — обращайся к полям через optional chaining.
- Мост **никогда не бросает исключений**: ошибка внутри превращается в слой
  `{error: string}` снапшота (проверяй `s.error`).

## Ручной шаг и атомарность инъекции

`Engine.stepTick()` — один шаг логики (60 Гц) + `events.emit('engine:tick')`;
`stepTicks(n, render)` — серия с `GameLoop.setManual(true)` и `resetTiming()` после
(в `finally` — исключение в шаге не оставляет цикл в ручном режиме навсегда),
чтобы rAF не «догонял» пропущенное. Реальный rAF-цикл при этом живёт: между
вызовами из страницы кадры продолжают тикать.

Мост **глотает исключения шага** (`step` возвращает последний тик) — если игра
«замерла» без ошибок в консоли, скорее всего тик кидает каждый кадр. Диагностика:
обернуть `engine.fx/tweens/scenes.update` в странице через `__game` и печатать
stack (`console.error`) — см. practices.md.

Отсюда **главное правило**: инъекция ввода и шаг, который её видит, должны быть
атомарны (один JS-стек). `tapVirtual/press/key` внутри делают инъекцию + один
`stepTick` — между `evaluate`-вызовами rAF успел бы очистить «just pressed».
`walkTo` тоже атомарен по узлам: клик + ожидание тайла. Если клик по узлу
маршрута сработал как взаимодействие (мот/цветок/NPC на пути) — герой
останавливается рядом, и такой узел **пропускается**: идём дальше со
следующего. Поэтому `walkTo` возвращает true, только если герой стоит на
целевом тайле; узлы-объекты на пути не ломают маршрут.

Семантика «just pressed» не меняется: инъекция между тиками видна сцене ровно
один тик (очистка в `endTick`).

## waitFor и переходы сцен

`SceneManager.begin()` молча отбрасывает переход, если уже идёт другой, а сцена
игнорирует клики, пока `transitioning`. Поэтому:

- перед действием из меню: `await agent.waitFor('!s.transitioning')`;
- после входа в локацию: тоже дождаться `!s.transitioning` — fade длится
  2 × duration, снапшот `scene: 'location'` появляется уже на swap.

`GameAgent.waitFor` ждёт по **полному** снапшоту игры: в предикатах доступны
не только сценические поля (`s.area`, `s.hero`, `s.interactables`,
`s.lastToast`), но и слой игры — `s.flags`, `s.vars`, `s.inventory`
(массив `{id, count}`). `EngineAgent.waitFor` (движковый) видит только
движковый + сценический слои — для предикатов над прогрессом используй
`agent.waitFor`, а не `engine.waitFor`.

**Ключи предиката проверяются до исполнения**: top-level обращения `s.<ключ>`
сверяются со списком `AGENT_SNAPSHOT_KEYS` (`agent/snapshot.ts`; движковые —
`ENGINE_SNAPSHOT_KEYS` из `@rpg/engine`). Неизвестный ключ (обычно опечатка —
`s.heroo`) отклоняет ожидание сразу: `{ ok: false, error: 'неизвестный ключ…',
ticks: 0 }` — вместо тихого `false` на весь таймаут. Новый top-level ключ
снапшота добавляй и в список (анти-дрейф тест — `snapshot.test.ts`); вложенные
пути (`s.hero.tile.x`) не проверяются — проверяется только первый сегмент.

## Контентная валидация (data/validate.ts)

Runtime-валидаторы возвращают `Invariant[]` (`severity: 'error' | 'warn'`,
`where` — путь к данным). Вместо JSON Schema: истина одна — TS-типы, валидатор
ловит то, что типы не выражают (ссылки, границы, стены). Проверяются: графы
диалогов (start/next/choices существуют, сироты — warn), NPC/спавн/враги не в
стенах, переходы ведут в существующие области и проходимые entry, интерактивные
объекты в границах карты, hp/speed врагов.

Вызывается из `GameAgent.invariants()` (работает и в меню) и из юнит-теста
`data/__tests__/validate.test.ts` — битый контент падает сразу в тестах.

## apps/game/tools/agent.mjs (CLI, JSON по умолчанию)

```bash
node apps/game/tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty]  # полный прогон
node apps/game/tools/agent.mjs run apps/game/tools/checks/xxx.mjs                    # один сценарий
node apps/game/tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty]
node apps/game/tools/agent.mjs screenshot [--out] [--steps N]              # скриншот + хвост консоли
node apps/game/tools/agent.mjs dev [--port 5199]                           # dev-сервер для ручной работы
```

`check` поднимает dev-сервер сам (и гасит в finally): `typecheck`, `tests`, `maps`,
`maps:fresh` (файлы карт равны генераторам), `agent:invariants` (новая игра → 300
шагов → инварианты чисты). Сценарии геймплея — в `apps/game/tools/checks/*.mjs`, каркас —
`Checks` из `packages/engine/tools/agent-lib.mjs` — импортируйте через сценарный слой
`apps/game/tools/lib.mjs` (там маяк загрузки игры) (проверка возвращает `false`/кидает исключение
или `c.expect(cond, msg, details)`).

**startDevServer и cwd**: `startDevServer({ port, cwd })` поднимает vite из каталога
`cwd` (по умолчанию — корень монорепо); приложение обязано передать свой каталог
(index.html + vite-конфиг), иначе vite от корня отдаёт 404 на всё. Игровой
`lib.mjs` уже заворачивает это с `cwd: apps/game` — импортируйте `startDevServer`
оттуда. Грабли: (а) `--strictPort` при занятом порте гасит наш vite, но fetch
продолжил бы отвечать чужим сервером — каркас отслеживает выход процесса и падает;
(б) npx-обёртка при смерти оставляет зомби-vite, держащий порт — spawn идёт с
`detached: true`, `stop()` убивает всю группу (`process.kill(-pid)`).

**Правило**: каждая геймплейная система обязана иметь сценарий проверки в
`apps/game/tools/checks/`. Предпочитай `snapshot`/`check` скриншотам: скриншот — для
визуальных вопросов (арт, компоновка), состояние игры читается снапшотом.