Newer
Older
rpg / docs / engine / agent.md

Агентный мост (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 — что сцена отдаёт мосту

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)

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 по умолчанию)

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