Newer
Older
rpg / v2 / apps / game / tools / checks / act2.mjs
/**
 * Гейт акта 2 v2 (трек 19): Клинт появляется по act2_hook, квест «Голос для
 * резонатора» (тон-деревья → тоны 3/3 → сдача с катсценой настройки),
 * реакция резонатора до/после, эпилог machine_hook, усыпление Машины с
 * катсценой «правильного звона», пейзажная фауна (олени: бродят, подходят
 * к звону). Скриншоты: /tmp/v2_act2_*.png.
 *
 * Запуск: node apps/game/tools/checks/act2.mjs [--port 5324]
 */
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs';
import { decodePng } from '@rpg/engine/tools/png.mjs';

const args = parseArgs(process.argv.slice(2));
const port = Number(args.port ?? 5324);

const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'act2', ok: false, checks: [] };
const check = (title, ok, info = null) => {
    report.checks.push({ title, ok, info });
    console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};

/** Центр тайла (в тайлах v1) — как мост tileCenter. */
const center = (t) => ({ x: t.x * 10 + 5, z: t.y * 10 + 5 });
/** Тон-деревья рощи (data/quests). */
const TREES = [[32, 6], [12, 10], [20, 30]];

try {
    await server.waitReady;
    const browser = await launchBrowser();
    try {
        const client = await openGame(browser, { url: `http://localhost:${port}/` });
        await client.call('startGame', false); // страница стартует в меню (меню/автосейв)
        const errs = client.errors();
        check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3));

        await client.call('setManual', true);
        await client.call('setAsh', false); // кадр-равенства — мир без пепла
        await client.call('setClock', 720); // полдень: страница жила в реальном времени
        await client.call('stepTicks', 5);

        // ---- Роща до крючка: Клинта нет, тон-дерево молчит ----
        await client.call('gotoArea', 'rust_grove');
        await client.call('stepTicks', 5);
        let ni = await client.call('npcInfo');
        check('без act2_hook: Клинта нет в роще', ni.npcs.length === 0, ni.npcs);

        const t2 = center({ x: TREES[1][0], y: TREES[1][1] });
        await client.call('heroTeleport', t2.x + 10, t2.z);
        await client.call('stepTicks', 3);
        await client.call('interact');
        let di = await client.call('dialogueInfo');
        check('тон-дерево до квеста: «дерево молчит», тоны 0',
            di.toasts.some((t) => t.includes('молчит')) && !(di.vars.tones > 0),
            { toasts: di.toasts, tones: di.vars.tones });

        // ---- Клинт появляется, взятие квеста ----
        await client.call('setState', { flags: ['act2_hook'] });
        await client.call('gotoArea', 'rust_grove'); // перезаход — спавн по флагу
        await client.call('stepTicks', 5);
        ni = await client.call('npcInfo');
        check('act2_hook: Клинт стоит у резонатора (23,17)',
            ni.npcs.length === 1 && ni.npcs[0].id === 'clint'
            && ni.npcs[0].tile.x === 23 && ni.npcs[0].tile.y === 17, ni.npcs);

        const dC = await client.call('talkTo', 'clint');
        check('первый диалог Клинта — clint_first', dC.graphId === 'clint_first', dC.graphId);
        for (let i = 0; i < 6; i++) di = await client.call('dialogueAdvance');
        check('clint_first: met_clint + quest_resonator_taken',
            di.flags.includes('met_clint') && di.flags.includes('quest_resonator_taken'),
            { flags: di.flags.slice(-2) });

        // ---- Машина до настройки резонатора ----
        await client.call('gotoArea', 'machine_hall');
        await client.call('stepTicks', 5);
        const mach = center({ x: 13, y: 1 });
        await client.call('heroTeleport', mach.x, mach.z + 10);
        await client.call('stepTicks', 3);
        await client.call('interact');
        di = await client.call('dialogueInfo');
        check('Машина без резонатора: «не спеть»', di.toasts.some((t) => t.includes('не спеть')), di.toasts);

        // ---- Тон-деревья: три тона ----
        await client.call('gotoArea', 'rust_grove');
        await client.call('stepTicks', 5);
        for (const [i, [tx, ty]] of TREES.entries()) {
            const c = center({ x: tx, y: ty });
            await client.call('heroTeleport', c.x + 10, c.z);
            await client.call('stepTicks', 3);
            await client.call('interact');
            di = await client.call('dialogueInfo');
            check(`дерево ${i + 1}: тон взят (${i + 1}/3), флаг tone_tree_${i + 1}`,
                di.vars.tones === i + 1 && di.flags.includes(`tone_tree_${i + 1}`),
                { tones: di.vars.tones, flag: di.flags.includes(`tone_tree_${i + 1}`) });
        }
        const c1 = center({ x: TREES[0][0], y: TREES[0][1] }); // повтор дерева 1
        await client.call('heroTeleport', c1.x + 10, c1.z);
        await client.call('stepTicks', 3);
        await client.call('interact');
        di = await client.call('dialogueInfo');
        check('повтор дерева: «уже прозвенело», тоны не растут',
            di.toasts.some((t) => t.includes('уже прозвенело')) && di.vars.tones === 3,
            { toasts: di.toasts, tones: di.vars.tones });

        // ---- Резонатор до настройки: молчит ----
        const rez = center({ x: 24, y: 16 });
        await client.call('heroTeleport', rez.x + 10, rez.z);
        await client.call('stepTicks', 3);
        await client.call('interact');
        di = await client.call('dialogueInfo');
        check('резонатор до настройки: «ржавый, молчит»',
            di.toasts.some((t) => t.includes('молчит')), di.toasts);

        // ---- Сдача: clint_tune → катсцена настройки ----
        const dT = await client.call('talkTo', 'clint');
        check('тоны 3/3 — clint_tune', dT.graphId === 'clint_tune', dT.graphId);
        await client.call('dialogueAdvance'); // accept: флаг + custom tune_resonator
        di = await client.call('dialogueInfo');
        check('резонатор настроен (quest_resonator_done)', di.flags.includes('quest_resonator_done'), {});
        await client.call('dialogueAdvance'); // ring
        di = await client.call('dialogueAdvance'); // конец → катсцена
        await client.call('stepTicks', 100);
        let cs = await client.call('cutsceneInfo');
        check('катсцена настройки играет', cs.active && cs.step >= 1, cs);
        await client.call('stepTicks', 70); // шаг 3 — камера глайдит к дереву
        const hi = await client.call('heroInfo');
        cs = await client.call('cutsceneInfo');
        check('катсцена: герой стоит, камера едет к дереву рощи',
            cs.active && cs.step === 2 && hi.camTarget[0] > 125 && hi.camTarget[0] < 255
            && hi.tile.x === 25 && hi.tile.y === 16,
            { step: cs.step, cam: hi.camTarget, tile: hi.tile });
        await client.call('stepTicks', 300);
        di = await client.call('dialogueInfo');
        cs = await client.call('cutsceneInfo');
        const hiEnd = await client.call('heroInfo');
        check('конец катсцены: тост «Роща слышит», камера вернулась к герою',
            !cs.active && di.toasts.some((t) => t.includes('Роща слышит'))
            && Math.round(hiEnd.camTarget[0]) === Math.round(rez.x + 10), { toasts: di.toasts, cam: hiEnd.camTarget });

        // ---- После настройки: реакции в лад ----
        await client.call('interact'); // герой ещё у резонатора
        di = await client.call('dialogueInfo');
        check('резонатор после настройки: «держит тон ровно»',
            di.toasts.some((t) => t.includes('держит тон ровно')), di.toasts);
        const dR = await client.call('talkTo', 'clint'); // эпилог
        check('эпилог — clint_repeat', dR.graphId === 'clint_repeat', dR.graphId);
        for (let i = 0; i < 3; i++) di = await client.call('dialogueAdvance');
        check('clint_repeat: machine_hook', di.flags.includes('machine_hook'), {});

        // ---- Усыпление Машины: катсцена «правильного звона» ----
        await client.call('gotoArea', 'machine_hall');
        await client.call('stepTicks', 5);
        await client.call('heroTeleport', mach.x, mach.z + 10);
        await client.call('stepTicks', 3);
        await client.call('interact');
        let cs2 = await client.call('cutsceneInfo');
        di = await client.call('dialogueInfo');
        check('правильный звон: machine_lulled + катсцена усыпления',
            di.flags.includes('machine_lulled') && cs2.active, { cs: cs2 });
        await client.call('stepTicks', 300);
        di = await client.call('dialogueInfo');
        cs2 = await client.call('cutsceneInfo');
        check('конец: тост «Впервые за годы — тише»',
            !cs2.active && di.toasts.some((t) => t.includes('Впервые за годы — тише')), di.toasts.slice(-2));
        await client.call('interact');
        di = await client.call('dialogueInfo');
        check('повтор: «Машина спит»', di.toasts.some((t) => t.includes('Машина спит')), di.toasts.slice(-2));

        // ---- Фауна: олени лугов ----
        await client.call('gotoArea', 'meadows');
        await client.call('stepTicks', 2);
        let fi = await client.call('faunaInfo');
        check('луга: два оленя на спавнах (18,36) и (24,40)',
            fi.deer.length === 2
            && fi.deer[0].tile.x === 18 && fi.deer[0].tile.y === 36
            && fi.deer[1].tile.x === 24 && fi.deer[1].tile.y === 40, fi.deer);
        await client.call('stepTicks', 400); // брожение: 6.7 с — шагают
        const fi2 = await client.call('faunaInfo');
        check('олени бродят: тайлы сместились',
            fi2.deer.some((d, i) => d.tile.x !== fi.deer[i].tile.x || d.tile.y !== fi.deer[i].tile.y),
            { before: fi.deer.map((d) => d.tile), after: fi2.deer.map((d) => d.tile) });

        // Подход к звону: звон в 1 тайле от первого оленя
        const d0 = fi2.deer[0];
        await client.call('startleFauna', d0.x + 10, d0.z);
        fi = await client.call('faunaInfo');
        check('звон рядом: олень идёт к источнику (approach)',
            fi.deer[0].state === 'approach', fi.deer[0]);
        await client.call('stepTicks', 120); // 1 тайл ≈ 1.5 с на скорости подхода
        fi = await client.call('faunaInfo');
        check('олень дошёл до звона и стоит',
            fi.deer[0].state === 'idle' && Math.abs(fi.deer[0].x - (d0.x + 10)) < 0.1
            && Math.abs(fi.deer[0].z - d0.z) < 0.1, fi.deer[0]);

        // Кадр оленя у воды (визуальный артефакт)
        const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_act2_deer.png')));
        check('кадр с оленем не пустой', shot.rgba.length > 1000, {});
    } finally {
        await browser.close();
    }
} catch (e) {
    console.error(e);
    report.fatal = String(e);
}

report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));
process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно