# Агентный мост (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, speaker, text, mood, tags, choices, path,
waitingForChoice} | null` (`path` — показанные узлы по порядку, `mood`/`tags` —
презентационные метаданные узла), `cutscene`, `lastToast {text, tick}`
(единственный канал текста реакций — иначе агенту нужен OCR), `collision
{width, height, blocked (0/1 по тайлам, включает footprint пропов), props}` —
карта коллизий для проверки движения, `lighting {ambient, vignette, sources
[{id, x, y, color, intensity}]}` — освещение сцены: ambient — multiply-цвет
(0xffffff — не затемняет), `vignette` — целевая интенсивность затемнения краёв
0..1, источники — экранные px (импульсы видны как `pulse#N`), `intensity` — с
учётом мерцания (проверки сравнивают с допуском). Инвариант `lighting-bounded`:
источников ≤ 24, значения конечны, 0 ≤ intensity ≤ 2, 0 ≤ vignette ≤ 1.
Слой игры добавляет `flags`, `vars`, `inventory` и `time {minutes, label, night}`
— время суток (минуты 0..1439, «8:24», фактор ночи 0..1 по кривой суток);
инвариант `time-bounded` держит их в пределах. `MenuScene` отдаёт `{scene: 'menu'}`
и команду `menu:newGame`.

Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках):
`scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`,
`scene:setTime {hours}` (перемотка времени суток — пиновать перед каждым
ambient-ассертом: время идёт и после пина, точные равенства неустойчивы),
`scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A* без движения),
`scene:walk {x, y}` (пойти в тайл: A* + followPath, без инъекции клика; false —
пути нет), `scene:approach {x, y}` (подойти к краю (не)проходимого тайла — цель
может быть стеной/объектом/NPC; возврат — тайл прибытия, null — соседей нет),
`scene:pickChoice {index}`,
`scene:pickChoiceByText {text}` (выбор варианта диалога по тексту реплики —
порядок вариантов знать не надо; false — такого варианта нет),
`scene:skipCutscene`, `scene:noise {x, y, level}` (шум в тайле: 0.35 — бодрые
слышат в hearRadius, 0.7+ — будит спящих), `scene:damageEnemy {id, value}`
(урон сгустку — проверки отступления), `scene:damagePlayer {value}` (урон
герою полным боевым путём: вспышка, шейк, виньетка при низком hp),
`scene:walkable {x, y}` (проходим ли
тайл: стены, вода, footprint пропов), `scene:raycast {from, to}` (прямая
видимость между тайлами: false — высокий объект на отрезке),
`scene:synthesize {spec, key?, volume?}` — **звук по описанию**: агент без слуха
задаёт спек `SoundSpec` (движок детерминированно компилирует его в буфер —
см. `assets-audio-save.md`, «Спек-синтез»):

```json
{ "kind": "hit", "dur": 0.3, "low": 600, "high": 3000, "seed": 7 }
{ "kind": "chime", "dur": 0.6, "freq": 880 }
{ "kind": "scrape", "dur": 0.5, "low": 80, "high": 400, "tone": 60 }
{ "kind": "hum", "dur": 8, "tone": 55, "power": 2 }
{ "kind": "tone", "dur": 0.15, "freq": 700, "freqTo": 350, "power": 3 }
```

`kind` — форма звука (`hit` удар / `chime` звон / `scrape` скрип, дверь-куст /
`hum` гул-луп / `tone` чистый тон, со свипом `freqTo`), `dur` в секундах —
обязательны; остальное — параметры с дефолтами (`low`/`high` полоса шума,
`tone` низкая подложка, `freq` частота тона/звона, `power` крутизна спада,
`attack` нарастание, `fadeTo` линейный спад громкости, `wobbleHz`/`wobbleDepth`
тремоло, `layers` доп. голоса `[{freq|low/high, w, dur, delay}]`, `seed` —
детерминизм, `peak`, `loop`). Полный набор — `assets-audio-save.md`, «Спек-синтез». Ключ
голоса по умолчанию `agent/synth` — факт запуска ищется в DEV-логе
`window.__gameAudioLog`. Спек с неизвестным `kind` или без `dur` → команда
вернёт `null`. Команда вне 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;                    // движковый слой + сцена + флаги/вары/сумка
                                                 //   s.fps — реальный FPS отрисовки (FpsMeter, окно ~0,5 с)
    invariants(): Invariant[];                   // движковые + сценические + контентные
    step(n?, {render}?): {tick};                 // n фиксированных шагов (60 Гц), рендер опционален
    waitFor(pred: string, opts?): Promise<{ok, snapshot, ticks}>;
    tapVirtual(vx, vy); uiTap(vx, vy);
    press(action, holdTicks?); key(code);
    command(name, args?): JsonValue;
    walkTo(tx, ty, opts?): Promise<boolean>;     // scene:walk (A* без кликов) + ожидание тайла
    approach(tx, ty, opts?): Promise<{x,y}|null>; // scene:approach: к краю непроходимого
    runDialogue(timeoutTicks?): Promise<boolean>;// листает диалог до конца
    pickChoiceByText(text, 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` атомарен иначе: команда `scene:walk` стартует путь одним вызовом,
дальше мост только ждёт прибытия (тайл цели и `!s.hero.moving` — точный снап
прихода; возврат true — герой стоит на целевом тайле). Взаимодействие — не по
клику, а кнопкой действия: `approach(tx, ty)` ведёт к краю непроходимой цели и
возвращает тайл прибытия; после него `press('interact')` (в `apps/game/tools/lib.mjs`
это завёрнуто в `t.interactAt/t.talkTo/t.useInteractable/t.fightNearest`).

Семантика «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)`).

**Новый сценарий — через `withChecks`** (`apps/game/tools/lib.mjs`): он поднимает и
гасит dev-сервер, а сценарий оставляет только шаги. Второй аргумент — `async (t) => {...}`
с помощниками: `t.boot()` (новая игра + выход из fade), `t.c`/`t.ctx` (Checks и контекст),
`t.sleepEnemies()`, `t.goToArea(area, via)` (пешком к step-переходу), `t.readAudioLog()`/
`t.waitAudioLog(pred)`, `t.expectInvariantsClean(what)`, взаимодействие —
`t.interactAt(tx, ty)` (подойти к тайлу и нажать E; walkable → walkTo, иначе
approach), `t.talkTo(npcId)` / `t.useInteractable(id)` (координаты из снапшота),
`t.fightNearest()` (подход к ближайшему врагу + удар). Контекст — **только через
`t.ctx`** (деструктуризация в начале снимет геттер до `boot` и даст `null`).

**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` скриншотам: скриншот — для
визуальных вопросов (арт, компоновка), состояние игры читается снапшотом.