import {
EngineAgent,
worldToScreen,
type AgentHost,
type Invariant,
type JsonValue,
type SceneAgent,
type SnapshotLayer
} from '@rpg/engine';
import type { Game } from '../Game';
import { gameLayer, type DialogueSnapshot, type GameSnapshot } from './snapshot';
import { validateContent } from '../data/validate';
/**
* Агентный мост игры: поверх движкового каркаса (EngineAgent) добавляет
* контентные слои (флаги/вары/инвентарь/сцена) и помощники высокого уровня
* (ходьба по маршруту, диалоги, «новая игра»). Регистрируется как
* `window.__agent` (только DEV) — инструменты в tools/ ходят через него.
*/
export interface AgentApi {
version: 1;
snapshot(): GameSnapshot;
invariants(): Invariant[];
/** n фиксированных шагов (детерминированно, без реального ожидания). */
step(n?: number, opts?: { render?: boolean }): { tick: number };
/** pred — строка-выражение над снапшотом (из браузера функцию не передать). */
waitFor(
pred: string,
opts?: { timeoutTicks?: number; render?: boolean }
): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }>;
/** Клик по тайлу (в юнитах; не зависит от видимости тайла на канвасе). */
tapTile(tx: number, ty: number): void;
/** Клик в виртуальных пикселях (480×270). */
tapVirtual(vx: number, vy: number): void;
/** Реальный DOM-клик (Pixi-кнопки меню). */
uiTap(vx: number, vy: number): void;
/** Действие по маппингу (advance/attack/...). */
press(action: string, holdTicks?: number): void;
/** Сырой код клавиши (e.code). */
key(code: string): void;
/** Whitelist-команда сцены (scene:sleepAll, scene:teleport, ...). */
command(name: string, args?: JsonValue): JsonValue;
/** Пойти в тайл по маршруту A* (в сцене). false — путь не найден. */
walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise<boolean>;
/** Дождаться и долистать активный диалог. false — диалог не открылся. */
runDialogue(timeoutTicks?: number): Promise<boolean>;
/** «Новая игра» из меню. */
newGame(): Promise<void>;
/** id текущей локации (null — сцена не локация). */
currentArea(): string | null;
}
/** Действие «листать диалог» (маппится и на Space, и на Enter). */
const ADVANCE = 'advance';
export class GameAgent {
private engineAgent: EngineAgent;
constructor(private game: Game) {
this.engineAgent = new EngineAgent(
game.engine as unknown as AgentHost,
() => (game.scenes.current as unknown as SceneAgent | undefined) ?? null
);
}
snapshot(): GameSnapshot {
const s = this.engineAgent.snapshot() as GameSnapshot;
// Слой игры поверх движкового и сценического.
Object.assign(s, gameLayer({
flags: this.game.state.allFlags,
vars: { ...this.game.state.serialize().vars },
inventory: this.game.inventory.all
}));
return s;
}
invariants(): Invariant[] {
// Контентная валидация живёт здесь (не в сцене), чтобы работать и в меню.
return [...this.engineAgent.invariants(), ...validateContent(this.game.mapFiles)];
}
step(n = 1, opts?: { render?: boolean }): { tick: number } {
return this.engineAgent.step(n, opts);
}
async waitFor(
pred: string,
opts?: { timeoutTicks?: number; render?: boolean }
): Promise<{ ok: boolean; snapshot: GameSnapshot; ticks: number }> {
// Строка-выражение приходит из tools/ (доверенная среда репозитория).
const fn = new Function('s', `"use strict"; return (${pred});`) as (s: SnapshotLayer) => boolean;
const limit = opts?.timeoutTicks ?? 600;
const render = opts?.render ?? false;
// Ждём по ПОЛНОМУ снапшоту игры (flags/vars/inventory поверх сцены):
// engineAgent.waitFor видит только движковый + сценический слои.
for (let i = 0; i < limit; i++) {
this.step(1, { render });
const s = this.snapshot();
let ok = false;
try {
ok = fn(s as unknown as SnapshotLayer);
} catch {
ok = false;
}
if (ok) return { ok: true, snapshot: s, ticks: i + 1 };
}
return { ok: false, snapshot: this.snapshot(), ticks: limit };
}
/** Клик по тайлу: юниты -> экран (виртуальные px) -> инъекция указателя. */
tapTile(tx: number, ty: number): void {
const w = worldToScreen(tx + 0.5, ty + 0.5);
const root = this.game.worldRoot.position;
this.tapVirtual(w.x + root.x, w.y + root.y);
}
tapVirtual(vx: number, vy: number): void {
this.engineAgent.tapVirtual(vx, vy);
}
uiTap(vx: number, vy: number): void {
this.engineAgent.uiTap(vx, vy);
}
press(action: string, holdTicks = 1): void {
this.engineAgent.press(action, holdTicks);
}
key(code: string): void {
this.engineAgent.key(code);
}
command(name: string, args?: JsonValue): JsonValue {
return this.engineAgent.command(name, args) as JsonValue;
}
/** Идти в тайл: маршрут просит сцену (scene:route), кликает по узлам по очереди. */
async walkTo(tx: number, ty: number, opts?: { timeoutTicks?: number }): Promise<boolean> {
const route = this.command('scene:route', { x: tx, y: ty });
if (!Array.isArray(route) || route.length === 0) return false;
for (const node of route as { x: number; y: number }[]) {
this.tapTile(node.x, node.y);
const wait = await this.waitFor(
`s.hero && s.hero.tile && s.hero.tile.x === ${node.x} && s.hero.tile.y === ${node.y}`,
{ timeoutTicks: opts?.timeoutTicks ?? 240 }
);
if (!wait.ok) return false;
}
return true;
}
/** Долистать активный диалог: жать advance, пока он открыт. */
async runDialogue(timeoutTicks = 600): Promise<boolean> {
const opened = await this.waitFor('s.dialogue != null', { timeoutTicks });
if (!opened.ok) return false;
for (let i = 0; i < 50; i++) {
const d = this.snapshot().dialogue as DialogueSnapshot | null;
if (!d) return true; // диалог закрылся — завершён
if (d.waitingForChoice) {
this.command('scene:pickChoice', { index: 0 });
} else {
this.press(ADVANCE);
}
this.step(2);
}
return !this.snapshot().dialogue;
}
/** «Новая игра» из меню (команда сцены — без координат кнопок). */
async newGame(): Promise<void> {
// begin() молча отбрасывает команды во время fade-перехода — ждём его конца.
await this.waitFor('!s.transitioning', { timeoutTicks: 1200 });
this.command('menu:newGame');
await this.waitFor('s.scene === "location"', { timeoutTicks: 1200 });
}
currentArea(): string | null {
const s = this.snapshot();
return typeof s.area === 'string' ? s.area : null;
}
}
/** Зарегистрировать window.__agent (только DEV). */
export function registerAgent(game: Game): AgentApi {
const agent = new GameAgent(game);
const api: AgentApi = {
version: 1,
snapshot: () => agent.snapshot(),
invariants: () => agent.invariants(),
step: (n, opts) => agent.step(n, opts),
waitFor: (pred, opts) => agent.waitFor(pred, opts),
tapTile: (tx, ty) => agent.tapTile(tx, ty),
tapVirtual: (vx, vy) => agent.tapVirtual(vx, vy),
uiTap: (vx, vy) => agent.uiTap(vx, vy),
press: (action, holdTicks) => agent.press(action, holdTicks),
key: (code) => agent.key(code),
command: (name, args) => agent.command(name, args),
walkTo: (tx, ty, opts) => agent.walkTo(tx, ty, opts),
runDialogue: (timeoutTicks) => agent.runDialogue(timeoutTicks),
newGame: () => agent.newGame(),
currentArea: () => agent.currentArea()
};
(window as unknown as { __agent?: AgentApi }).__agent = api;
return api;
}