Агентный мост — набор инструментов, позволяющий ИИ-агенту (или тесту) управлять игрой и читать её состояние без скриншотов и «слепых» кликов по 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 и браузера.
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. Команда вне whitelist возвращает null — расширять осознанно.
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;
}
's.hero.hp < 3'): из браузера нельзя передать функцию. Компилируется new Function('s', ...) — источник доверенный (tools/).{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).
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.
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 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 скриншотам: скриншот — для визуальных вопросов (арт, компоновка), состояние игры читается снапшотом.