Newer
Older
rpg / tools / agent.mjs
/**
 * CLI-диспетчер агентных инструментов. Вывод по умолчанию — JSON
 * (машинночитаемый), --pretty — для человека.
 *
 *   node tools/agent.mjs check [--only a,b] [--skip a,b] [--pretty]
 *   node tools/agent.mjs run <tools/checks/xxx.mjs> [--pretty]
 *   node tools/agent.mjs snapshot [--new-game] [--out файл] [--pretty]
 *   node tools/agent.mjs screenshot [--out файл] [--steps N] [--pretty]
 *   node tools/agent.mjs dev [--port N]
 */
import { spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { parseArgs, startDevServer, openGame, Checks } from './agent-lib.mjs';

const args = parseArgs();
const cmd = args._[0] ?? 'check';

/** Один «серверный» шаг проверки: результат + ms + детали (fn может быть async). */
async function stepResult(name, fn) {
    const t0 = Date.now();
    try {
        const details = await fn();
        return { name, ok: true, ms: Date.now() - t0, details: details ?? null };
    } catch (err) {
        return { name, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) };
    }
}

/** Существующие .map равны генераторам (gen.test.ts сравнивает сиды с файлами). */
const mapsFresh = () => stepResult('maps:fresh', () => {
    const gen = spawnSync('npx', ['vitest', 'run', 'tools/maps/gen.test.ts'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname });
    if (gen.status !== 0) throw new Error(gen.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'gen.test.ts не зелёный');
    return 'файлы карт равны генераторам';
});

/** Браузерная проверка: новая игра -> N шагов -> инварианты пусты (error). */
async function agentInvariants() {
    const c = new Checks('agent:invariants');
    const server = await startDevServer({ port: args.port ?? 5199 });
    let ctx;
    try {
        await c.run('новая игра + 300 шагов', async () => {
            ctx = await openGame({ url: server.url, newGame: true });
            await ctx.agent.step(300);
            const inv = await ctx.agent.invariants();
            const errs = inv.filter((i) => i.severity === 'error');
            if (errs.length) return errs;
            return 'инварианты чисты';
        });
        await c.run('снапшот валиден', async () => {
            const s = await ctx.agent.snapshot();
            if (!s.hero || !Array.isArray(s.flags)) return 'снапшот без hero/flags';
            return null;
        });
    } finally {
        await ctx?.browser?.close();
        server.stop();
    }
    return c.finish({ pretty: args.pretty });
}

/** Сценарий из tools/checks/ как шаг check: exit code -> результат. */
async function runScenario(name, file) {
    return stepResult(name, async () => {
        const mod = await import(pathToFileURL(resolve(new URL('..', import.meta.url).pathname, file)));
        const code = await mod.default({ args, pretty: args.pretty });
        if (code !== 0) throw new Error(`сценарий ${file} не зелёный (exit ${code})`);
        return null;
    });
}

const commands = {
    /** Полный прогон проверок. */
    async check() {
        const all = [
            { name: 'typecheck', run: () => stepResult('typecheck', () => {
                const r = spawnSync('npm', ['run', 'typecheck'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname });
                if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('error TS')).join('\n'));
                return null;
            }) },
            { name: 'tests', run: () => stepResult('tests', () => {
                const r = spawnSync('npm', ['test'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname });
                if (r.status !== 0) throw new Error(r.stdout.split('\n').filter((l) => l.includes('FAIL')).join('; ') || 'тесты не зелёные');
                return null;
            }) },
            { name: 'maps', run: () => stepResult('maps', () => {
                const r = spawnSync('npm', ['run', 'maps'], { encoding: 'utf8', cwd: new URL('..', import.meta.url).pathname });
                if (r.status !== 0) throw new Error('npm run maps не зелёный');
                return null;
            }) },
            { name: 'maps:fresh', run: mapsFresh },
            { name: 'agent:invariants', run: agentInvariants },
            { name: 'transitions', run: () => runScenario('transitions', 'tools/checks/transitions.mjs') }
        ];
        const only = args.only ?? all.map((p) => p.name);
        const skip = args.skip ?? [];
        const report = { name: 'check', ok: true, ms: 0, results: [] };
        const t0 = Date.now();
        for (const probe of all) {
            if (!only.includes(probe.name) || skip.includes(probe.name)) continue;
            let res;
            try {
                res = await probe.run();
            } catch (err) {
                res = { name: probe.name, ok: false, ms: 0, details: String(err?.message ?? err) };
            }
            report.results.push(res);
            report.ok = report.ok && res.ok;
        }
        report.ms = Date.now() - t0;
        console.log(args.pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report));
        return report.ok ? 0 : 1;
    },

    /** Один сценарий из tools/checks/. */
    async run() {
        const file = args._[1];
        if (!file) throw new Error('укажи путь к сценарию: node tools/agent.mjs run tools/checks/xxx.mjs');
        const mod = await import(pathToFileURL(file).href);
        if (typeof mod.default !== 'function') throw new Error(`${file}: нет export default (async ({args}) => exitCode)`);
        return mod.default({ args, pretty: args.pretty });
    },

    /** Снимок снапшота в файл/stdout. */
    async snapshot() {
        const server = await startDevServer({ port: args.port ?? 5199 });
        try {
            const ctx = await openGame({ url: server.url, newGame: args.newGame ?? false });
            const snap = await ctx.agent.snapshot();
            if (args.steps) await ctx.agent.step(args.steps);
            if (args.out) writeFileSync(args.out, JSON.stringify(snap, null, 2));
            console.log(args.out ? `снапшот: ${args.out}` : JSON.stringify(snap, null, 2));
            await ctx.browser.close();
            return 0;
        } finally { server.stop(); }
    },

    /** Скриншот + консоль (для визуальных проверок, когда снапшота мало). */
    async screenshot() {
        const server = await startDevServer({ port: args.port ?? 5199 });
        try {
            const ctx = await openGame({ url: server.url, newGame: args.newGame ?? true });
            if (args.steps) await ctx.agent.step(args.steps);
            const out = args.out ?? '/tmp/rpg_agent.png';
            await ctx.agent.screenshot(out);
            console.log(JSON.stringify({ screenshot: out, logs: ctx.consoleLogs.slice(-10) }));
            await ctx.browser.close();
            return 0;
        } finally { server.stop(); }
    },

    /** Dev-сервер + URL для ручной работы (не гасит сервер до Ctrl+C). */
    async dev() {
        const server = await startDevServer({ port: args.port ?? 5199 });
        console.log(`dev: ${server.url}`);
        process.on('SIGINT', () => { server.stop(); process.exit(0); });
        await new Promise(() => {});
    }
};

const fn = commands[cmd];
if (!fn) {
    console.error(`Неизвестная команда «${cmd}». Доступны: ${Object.keys(commands).join(', ')}`);
    process.exit(2);
}
process.exit(await fn());