/**
* Библиотека для агентных проверок игры (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;
}