Newer
Older
rpg / apps / game / tools / lib.mjs
/**
 * Сценарный слой тулз «Пепельных лугов» поверх жанронезависимого каркаса
 * @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;
}