Newer
Older
rpg / tools / agent-lib.mjs
/**
 * Библиотека для агентных проверок игры (puppeteer-core + системный Chromium).
 * Единственное место с браузерным бойлерплейтом: смоуки и сценарии
 * tools/checks/*.mjs строятся поверх openGame()/AgentClient.
 */
import puppeteer from 'puppeteer-core';
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';

export const CHROMIUM = '/usr/bin/chromium';
export const BEACON = '[boot] ассеты загружены';

/** Запустить браузер с нужными headless-флагами (см. грабли autoplay-policy). */
export async function launchBrowser() {
    return puppeteer.launch({
        executablePath: CHROMIUM,
        headless: true,
        args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader',
               '--autoplay-policy=no-user-gesture-required', '--window-size=960,540']
    });
}

/** Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}. */
export async function startDevServer({ port = 5199 } = {}) {
    const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], {
        cwd: new URL('..', import.meta.url).pathname,
        stdio: ['ignore', 'pipe', 'pipe']
    });
    const url = `http://localhost:${port}/`;
    const deadline = Date.now() + 30000;
    while (Date.now() < deadline) {
        try {
            const res = await fetch(url);
            if (res.ok) return { url, stop: () => proc.kill('SIGTERM') };
        } catch { /* ещё не поднялся */ }
        await sleep(250);
    }
    proc.kill('SIGTERM');
    throw new Error(`dev-сервер на :${port} не поднялся за 30 с`);
}

/**
 * Открыть игру и дождаться готовности агента.
 * newGame=true — кликнуть «Новая игра» через мост.
 * Возвращает {browser, page, agent}.
 */
export async function openGame({ url = 'http://localhost:5199/', newGame = true } = {}) {
    const browser = await launchBrowser();
    const page = await browser.newPage();
    await page.setViewport({ width: 960, height: 540 });
    const consoleLogs = [];
    page.on('console', (m) => consoleLogs.push(m.text()));
    page.on('pageerror', (e) => consoleLogs.push(`[pageerror] ${e.message}`));

    const booted = new Promise((resolve, reject) => {
        const t = setTimeout(() => reject(new Error(`маяк «${BEACON}» не пришёл за 30 с`)), 30000);
        page.on('console', (m) => { if (m.text().includes(BEACON)) { clearTimeout(t); resolve(); } });
    });
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
    await booted;
    // Дождаться конца fade-перехода BootScene->Menu: begin() молча отбрасывает
    // команды, пришедшие во время перехода.
    await sleep(1500);

    const agent = new AgentClient(page);
    if (newGame) {
        await agent.newGame();
        // fade-переход в локацию ещё идёт: сцена игнорирует клики до его конца.
        await agent.waitFor('!s.transitioning', { timeoutTicks: 300 });
    }
    return { browser, page, agent, consoleLogs };
}

/**
 * Тонкий клиент поверх window.__agent: каждое поле — прокси-вызов page.evaluate.
 * snapshot()/invariants() возвращают готовые объекты; waitFor — строка-pred.
 */
export class AgentClient {
    constructor(page) { this.page = page; }

    async call(method, ...args) {
        return this.page.evaluate((m, a) => {
            const api = window.__agent;
            if (!api) return { __agentError: 'window.__agent не зарегистрирован (DEV-сборка?)' };
            return api[m](...a);
        }, method, args);
    }

    snapshot() { return this.call('snapshot'); }
    invariants() { return this.call('invariants'); }
    step(n = 1, opts) { return this.call('step', n, opts); }
    waitFor(pred, opts) { return this.call('waitFor', pred, opts); }
    tapTile(x, y) { return this.call('tapTile', x, y); }
    tapVirtual(x, y) { return this.call('tapVirtual', x, y); }
    press(action, holdTicks) { return this.call('press', action, holdTicks); }
    key(code) { return this.call('key', code); }
    command(name, args) { return this.call('command', name, args); }
    walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); }
    runDialogue() { return this.call('runDialogue'); }
    newGame() { return this.call('newGame'); }
    currentArea() { return this.call('currentArea'); }

    async screenshot(path) { return this.page.screenshot({ path }); }
}

/**
 * Каркас сценариев: run() ловит исключения, finish() печатает JSON и ставит код.
 * Использование: const c = new Checks('имя'); await c.run('шаг', async () => ...); c.finish();
 */
export class Checks {
    constructor(name) {
        this.name = name;
        this.results = [];
        this.started = Date.now();
    }
    /** Добавить проверку; исключение/ложь → неуспех, прогон продолжается. */
    async run(checkName, fn) {
        const t0 = Date.now();
        try {
            const details = await fn();
            this.results.push({ name: checkName, ok: details !== false, ms: Date.now() - t0,
                                details: details === true ? null : details ?? null });
        } catch (err) {
            this.results.push({ name: checkName, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) });
        }
        return this;
    }
    /** Ассерт-хелпер: неистинное условие кидает исключение с сообщением. */
    expect(cond, msg, details) {
        if (!cond) throw new Error(msg + (details !== undefined ? `: ${JSON.stringify(details)}` : ''));
        return details;
    }
    /** Напечатать JSON-отчёт и вернуть его (код выхода — через report.ok). */
    finish({ pretty = false } = {}) {
        const report = {
            name: this.name,
            ok: this.results.every((r) => r.ok),
            ms: Date.now() - this.started,
            results: this.results
        };
        console.log(pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report));
        return report;
    }
}

/** Разбор общих флагов CLI: --pretty/--json/--port/--only/--skip. */
export function parseArgs(argv = process.argv.slice(2)) {
    const out = { _: [] };
    for (let i = 0; i < argv.length; i++) {
        const a = argv[i];
        if (a === '--pretty' || a === '--json') out.pretty = a === '--pretty';
        else if (a === '--port') out.port = Number(argv[++i]);
        else if (a === '--only') out.only = (argv[++i] ?? '').split(',').filter(Boolean);
        else if (a === '--skip') out.skip = (argv[++i] ?? '').split(',').filter(Boolean);
        else if (a === '--new-game') out.newGame = true;
        else if (a === '--out') out.out = argv[++i];
        else if (a === '--steps') out.steps = Number(argv[++i]);
        else out._.push(a);
    }
    return out;
}