/**
* 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') },
{ name: 'interact', run: () => runScenario('interact', 'tools/checks/interact.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());