Newer
Older
rpg / v2 / apps / demo / tools / checks / dialogue.mjs
/**
 * Скриншот-гейт диалогов v2 (трек «диалоги + интерактивные точки»):
 * подсказка взаимодействия у точки, открытие диалога по «E» (мост interact),
 * выбор варианта с эффектами (флаги/переменные), закрытие, второй граф
 * (табличка). Ручной режим цикла — детерминизм. Скриншоты: /tmp/v2_dialog_*.png.
 *
 * Запуск: node apps/demo/tools/checks/dialogue.mjs [--port 5303]
 */
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { sleep, 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 ?? 5303);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));

const server = startDevServer({ port, cwd: DEMO_DIR });
const report = { name: 'dialogue', ok: false, checks: [] };
const check = (title, ok, info = null) => {
    report.checks.push({ title, ok, info });
    console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};

// Геометрия: страж у дома (42,47) радиус 4; табличка (30,71) радиус 3.
const GUARD = { x: 42, z: 47 };
const SIGN = { x: 30, z: 71 };

try {
    await server.waitReady;
    const browser = await launchBrowser();
    try {
        const client = await openGame(browser, { url: `http://localhost:${port}/` });

        const errs = client.errors();
        check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3));

        // Детерминизм + графы валидны (валидатор — часть ядра)
        await client.call('setManual', true);
        const boot = await client.call('dialogueInfo');
        check('диалоговые графы валидны', boot.graphErrors.length === 0, boot.graphErrors);

        // Далеко от точек: подсказки нет
        await client.call('heroTeleport', 40, 62);
        await client.call('stepTicks', 2);
        const far = await client.call('interactInfo');
        check('вдали от точек подсказки нет', far.near === null && far.hint === null, far);

        // Подошёл к стражу: ближайшая точка + подсказка «E — Поговорить»
        await client.call('heroTeleport', GUARD.x, GUARD.z + 4);
        await client.call('stepTicks', 2);
        const near = await client.call('interactInfo');
        check('у стража: подсказка взаимодействия',
            near.near === 'guard' && near.hint === 'E — Поговорить', near);

        // Скриншот с подсказкой — с той же позиции камеры, что и кадр диалога
        const hintShot = await client.screenshot('/tmp/v2_dialog_hint.png');
        const hintImg = decodePng(readFileSync(hintShot));

        // «E» у стража: диалог открыт, первый узел с тремя вариантами
        await client.call('interact');
        const d1 = await client.call('dialogueInfo');
        check('«E» открывает диалог: узел greet, выбор ждёт',
            d1.active === true && d1.nodeId === 'greet' && d1.waitingForChoice === true
            && d1.choices.length === 3, d1);
        const dialogShot = await client.screenshot('/tmp/v2_dialog_open.png');
        const dialogImg = decodePng(readFileSync(dialogShot));
        // Одна позиция камеры: подсказка живёт в верхних строках, панель — в нижних
        const topDiff = countDiff(cropRows(hintImg, 0, 60), cropRows(dialogImg, 0, 60));
        check('подсказка рисуется на кадре (верх экрана)', topDiff > 100, { topDiff });
        const panelDiff = countDiff(cropRows(dialogImg, 270, 540), cropRows(hintImg, 270, 540));
        check('панель диалога рисуется на кадре (низ экрана)', panelDiff > 5000, { panelDiff });

        // Выбор «Да, я копал» → флаг admitted, эффект toast, следующий узел
        await client.call('dialoguePick', 0);
        const d2 = await client.call('dialogueInfo');
        check('выбор применил эффекты: флаг admitted, тост цветка, узел dig',
            d2.nodeId === 'dig' && d2.flags.includes('admitted')
            && d2.toasts.includes('Получено: цветок пепла'), d2);

        // «Дальше» до конца: bye → закрытие, результат с путём
        await client.call('dialogueAdvance');
        await client.call('dialogueAdvance');
        const d3 = await client.call('dialogueInfo');
        check('диалог дошёл до bye и закрылся',
            d3.active === false && d3.result?.lastNodeId === 'bye'
            && JSON.stringify(d3.result?.path) === JSON.stringify(['greet', 'dig', 'bye']), d3);

        // Повторный заход: advance во время выбора игнорируется (узел ждёт игрока)
        await client.call('dialogueOpen', 'guard');
        await client.call('dialogueAdvance');
        const d4 = await client.call('dialogueInfo');
        check('advance при выборе варианта игнорируется', d4.nodeId === 'greet', d4);

        // Ветка «Кто ты такой?»: переменная asked открывает условный узел ask2
        await client.call('dialoguePick', 1);
        await client.call('dialogueAdvance');
        const d5 = await client.call('dialogueInfo');
        check('переменная asked открыла условный узел ask2',
            d5.nodeId === 'ask2' && d5.vars.asked === 1 && d5.choices.length === 2, d5);
        await client.call('dialoguePick', 0);
        await client.call('dialogueAdvance');
        await client.call('dialogueAdvance');
        const d6 = await client.call('dialogueInfo');
        check('ветка помощи записала оба флага и закрылась',
            d6.active === false && d6.flags.includes('helped') && d6.flags.includes('admitted'), d6);

        // Табличка: второй граф, один узел, свой тост
        await client.call('heroTeleport', SIGN.x, SIGN.z + 3);
        await client.call('stepTicks', 2);
        const nearSign = await client.call('interactInfo');
        check('у таблички: подсказка «E — Прочитать»',
            nearSign.near === 'sign' && nearSign.hint === 'E — Прочитать', nearSign);
        await client.call('interact');
        const d7 = await client.call('dialogueInfo');
        check('табличка открыла второй граф с тостом',
            d7.nodeId === 'read' && d7.toasts.includes('Табличка прочитана'), d7);
        await client.call('dialogueAdvance'); // end:true → закрытие
        const d8 = await client.call('dialogueInfo');
        const closeShot = await client.screenshot('/tmp/v2_dialog_closed.png');
        const closeImg = decodePng(readFileSync(closeShot));
        const closeDiff = countDiff(closeImg.rgba, dialogImg.rgba);
        check('диалог закрылся: панель скрыта, кадр вернулся',
            d8.active === false && closeDiff > 5000, { active: d8.active, closeDiff });

        report.ok = report.checks.every((c) => c.ok);
    } finally {
        await browser.close();
    }
} catch (e) {
    check('прогон без исключений', false, String(e));
} finally {
    server.stop();
}

console.log(JSON.stringify(report, null, 2));
process.exit(report.ok ? 0 : 1);

/** Строки y0..y1 кадра (RGBA) — сравнение зон HUD без шума сцены. */
function cropRows(img, y0, y1) {
    const stride = img.width * 4;
    return img.rgba.subarray(y0 * stride, y1 * stride);
}

/** Число различающихся RGBA-пикселей двух кадров одного размера. */
function countDiff(a, b) {
    let n = 0;
    for (let i = 0; i < a.length; i += 4) {
        if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++;
    }
    return n;
}