Агентный мост — набор инструментов, позволяющий ИИ-агенту (или тесту) управлять игрой и читать её состояние без скриншотов и «слепых» кликов по 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 и браузера.
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} — карта коллизий для проверки движения. 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:pickChoiceByText {text} (выбор варианта диалога по тексту реплики — порядок вариантов знать не надо; false — такого варианта нет), 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 — высокий объект на отрезке), scene:synthesize {spec, key?, volume?} — *звук по описанию: агент без слуха задаёт спек SoundSpec (движок детерминированно компилирует его в буфер — см. assets-audio-save.md, «Спек-синтез»):
{ "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).
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>;// листает диалог до конца
pickChoiceByText(text, timeoutTicks?): Promise<boolean>; // ждёт выбор, берёт по тексту
newGame(): Promise<void>;
currentArea(): string | null;
}
's.hero.hp < 3'): из браузера нельзя передать функцию. Компилируется new Function('s', ...) — источник доверенный (apps/game/tools/).{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).
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) не проверяются — проверяется только первый сегмент.
Runtime-валидаторы возвращают Invariant[] (severity: 'error' | 'warn', where — путь к данным). Вместо JSON Schema: истина одна — TS-типы, валидатор ловит то, что типы не выражают (ссылки, границы, стены). Проверяются: графы диалогов (start/next/choices существуют, сироты — warn), NPC/спавн/враги не в стенах, переходы ведут в существующие области и проходимые entry, интерактивные объекты в границах карты, hp/speed врагов.
Вызывается из GameAgent.invariants() (работает и в меню) и из юнит-теста data/__tests__/validate.test.ts — битый контент падает сразу в тестах.
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 скриншотам: скриншот — для визуальных вопросов (арт, компоновка), состояние игры читается снапшотом.