Newer
Older
rpg / apps / game / src / agent / GameAgent.ts
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;
}