/**
* Сценарный слой тулз «Пепельных лугов» поверх жанронезависимого каркаса
* @rpg/engine/tools/agent-lib.mjs: маяк загрузки игры и re-export общих
* примитивов. Смоуки и checks/*.mjs импортируют отсюда.
*/
import { fileURLToPath } from 'node:url';
import {
Checks,
openGame as engineOpenGame,
startDevServer as engineStartDevServer
} from '@rpg/engine/tools/agent-lib.mjs';
export { CHROMIUM, launchBrowser, AgentClient, Checks, parseArgs } from '@rpg/engine/tools/agent-lib.mjs';
/** Каталог игры (index.html + vite-конфиг) — дефолтный cwd dev-сервера. */
const GAME_ROOT = fileURLToPath(new URL('..', import.meta.url));
/** startDevServer по умолчанию поднимает vite из каталога игры, а не из корня монорепо. */
export const startDevServer = (opts = {}) => engineStartDevServer({ cwd: GAME_ROOT, ...opts });
/** Консольный маяк BootScene: ассеты загружены, мост готов. */
export const BEACON = '[boot] ассеты загружены';
/** openGame с игровым маяком загрузки. */
export const openGame = (opts = {}) => engineOpenGame({ beacon: BEACON, ...opts });
/**
* Удар по ближайшему живому врагу через агентный мост. Возврат — урон прошёл.
* Подход задаёт facing только последним отрезком пути — он может прийти
* перпендикулярно врагу, поэтому после подхода герой делает короткий шаг в
* его сторону (клавиши движения — экранный вектор: facing совпадёт с
* направлением на врага) и бьёт конусом. Враг подвижен — до 3 попыток со
* свежей позицией.
*/
export const fightNearest = async (agent) => {
const snap = await agent.snapshot();
const hero = snap.hero;
const foes = (snap.enemies ?? []).filter((e) => !e.dead && e.hp > 0);
if (foes.length === 0) return true; // бить некого — не провал
const d2 = (e) => (e.pos.x - hero.pos.x) ** 2 + (e.pos.y - hero.pos.y) ** 2;
const foe = foes.reduce((b, e) => (d2(e) < d2(b) ? e : b));
const hurt =
`(s.enemies.filter((e) => !e.dead).length) < ${foes.length} || ` +
`s.enemies.some((e) => !e.dead && e.hp < ${Math.min(...foes.map((e) => e.hp))})`;
for (let i = 0; i < 3; i++) {
const cur = await agent.snapshot();
// Свежая позиция цели: враг сам идёт на героя, пока мы подходили.
const live = (cur.enemies ?? []).find(
(e) => !e.dead && e.hp > 0 && Math.hypot(e.pos.x - foe.pos.x, e.pos.y - foe.pos.y) < 3
);
if (!live) return true; // добита (нашим прошлым тапом или сплэшем)
const dest = await agent.approach(Math.floor(live.pos.x), Math.floor(live.pos.y));
if (dest) {
const dx = live.pos.x - cur.hero.pos.x;
const dy = live.pos.y - cur.hero.pos.y;
// Экранные оси — мировые диагонали: sx = dx − dy, sy = dx + dy.
const sx = dx - dy;
const sy = dx + dy;
const dir = Math.abs(sx) >= Math.abs(sy) ? (sx >= 0 ? 'right' : 'left') : sy >= 0 ? 'down' : 'up';
agent.press(dir, 5); // шаг к врагу задаёт facing
agent.press('attack');
}
const w = await agent.waitFor(hurt, { timeoutTicks: 120 });
if (w.ok) return true;
}
return false;
};
/**
* Каркас чек-сценария: поднимает dev-сервер, даёт помощники (boot, goToArea,
* sleepEnemies, readAudioLog, waitAudioLog, expectInvariantsClean) и в любом
* исходе гасит браузер и сервер. Сценарий оставляет только шаги проверки.
*
* export default async function ({ pretty }) {
* return withChecks('имя', async (t) => { ... }, { pretty });
* }
*/
export async function withChecks(name, fn, { pretty } = {}) {
const c = new Checks(name);
const server = await startDevServer();
const h = { ctx: null };
const t = {
c,
/** Контекст игры (заполняется boot). */
get ctx() {
return h.ctx;
},
/** Новая игра + выход из стартового fade-перехода. */
boot: async () => {
h.ctx = await openGame({ url: server.url, newGame: true });
await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 });
return h.ctx;
},
/**
* Открыть игру с сырыми опциями openGame (boot = open({newGame: true}) +
* ожидание fade). Для сценариев, которым нужен свой тайминг старта.
*/
open: async (opts = {}) => {
h.ctx = await openGame({ url: server.url, ...opts });
return h.ctx;
},
/** Спим всех врагов (безопасная навигация по миру). */
sleepEnemies: () => h.ctx.agent.command('scene:sleepAll'),
/**
* Пешком к тайлу-триггеру step-перехода (via) и дождаться области
* (двери-переходы — явным interactAt в сценарии).
*/
goToArea: async (area, via) => {
const walked = await h.ctx.agent.walkTo(via.x, via.y, { timeoutTicks: 3000 });
c.expect(walked, `walkTo(${via.x},${via.y}) не дошёл до перехода`);
const w = await h.ctx.agent.waitFor(`s.area === "${area}"`, { timeoutTicks: 1200 });
c.expect(w.ok, `после перехода не в «${area}»`, w.snapshot?.area);
await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 });
return w;
},
/**
* Подойти к тайлу и нажать действие (E): walkTo на проходимый (цветок,
* мот), approach на непроходимый (стены, объекты, NPC-тайлы — путь в
* занятый тайл не строится). Возврат — дошли ли.
*/
interactAt: async (tx, ty) => {
const a = h.ctx.agent;
const ok =
(await a.walkTo(tx, ty)) || (await a.approach(tx, ty)) !== null;
if (ok) a.press('interact');
return ok;
},
/** Подойти к NPC по id (координаты — из снапшота) и заговорить. */
talkTo: async (npcId) => {
const snap = await h.ctx.agent.snapshot();
const npc = (snap.npcs ?? []).find((n) => n.id === npcId);
c.expect(npc, `NPC «${npcId}» не найден в снапшоте`, null);
if (!(await t.interactAt(npc.tile.x, npc.tile.y))) return false;
const w = await h.ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 300 });
return w.ok;
},
/** Подойти к интерактивному объекту по id и нажать действие. */
useInteractable: async (id) => {
const snap = await h.ctx.agent.snapshot();
const it = (snap.interactables ?? []).find((o) => o.id === id);
c.expect(it, `интерактив «${id}» не найден в снапшоте`, null);
return t.interactAt(it.tile.x, it.tile.y);
},
/** Удар по ближайшему живому врагу: см. fightNearest выше. */
fightNearest: async () => {
const ok = await fightNearest(h.ctx.agent);
c.expect(ok, 'враг не получил урон', null);
return ok;
},
/** DEV-лог аудио страницы (window.__gameAudioLog) или null вне DEV. */
readAudioLog: () => h.ctx.page.evaluate(() => window.__gameAudioLog ?? null),
/**
* Ждать условие на аудио-логе: декод WAV и компиляция спеков в headless
* занимают заметное время — шагаем и опрашиваем, а не читаем сразу.
*/
waitAudioLog: async (pred, tries = 12) => {
let log = await t.readAudioLog();
for (let i = 0; i < tries && !pred(log); i++) {
await h.ctx.agent.step(30);
log = await t.readAudioLog();
}
return log;
},
/** Ошибки инвариантов моста: пусто — чисто (warn допустим). */
expectInvariantsClean: async (what) => {
const inv = await h.ctx.agent.invariants();
const errs = (inv ?? []).filter((i) => i.severity === 'error');
c.expect(errs.length === 0, `инварианты нарушены${what ? `: ${what}` : ''}`, errs);
return errs;
}
};
try {
await fn(t);
} finally {
try {
await h.ctx?.browser?.close();
} catch {
// браузер мог умереть раньше — сервер всё равно гасим
}
server.stop();
}
return c.finish({ pretty }).ok ? 0 : 1;
}