# Агентный мост (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[]
tools/agent-lib.mjs               — браузерный клиент (puppeteer, openGame, Checks)
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). `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}`
(урон сгустку — проверки отступления). Команда вне 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', ...)` — источник доверенный (tools/).
- **waitFor крутит шаги сам** и вызывает pred после каждого шага; сцены могут
  меняться в процессе — обращайся к полям через optional chaining.
- Мост **никогда не бросает исключений**: ошибка внутри превращается в слой
  `{error: string}` снапшота (проверяй `s.error`).

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

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

Отсюда **главное правило**: инъекция ввода и шаг, который её видит, должны быть
атомарны (один 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`.

## Контентная валидация (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` — битый контент падает сразу в тестах.

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

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

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

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