Newer
Older
rpg / v2 / apps / demo / tools / checks / inventory.mjs
/**
 * Скриншот-гейт инвентаря v2 (трек «инвентарь»): эффекты диалогов
 * giveItem/takeItem наполняют сумку героя, HUD сумки рисуется в левом
 * верхнем углу, выбор «Отдать цветок» виден только с предметом
 * (DialogueWorld.hasItem). Ручной режим цикла — детерминизм.
 * Скриншоты: /tmp/v2_inv_*.png.
 *
 * Запуск: node apps/demo/tools/checks/inventory.mjs [--port 5306]
 */
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 ?? 5306);
const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url));

const server = startDevServer({ port, cwd: DEMO_DIR });
const report = { name: 'inventory', 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); подходим с юга, камера одна на все кадры HUD.
const GUARD = { x: 42, z: 51 };

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);
        await client.call('heroTeleport', GUARD.x, GUARD.z);
        await client.call('stepTicks', 2);

        // Старт: сумка пуста, HUD скрыт, выбор «Отдать цветок» не показан
        const empty = await client.call('invInfo');
        check('сумка пуста на старте', empty.empty === true && empty.size === 0, empty);
        const hudEmptyShot = await client.screenshot('/tmp/v2_inv_empty.png');
        await client.call('interact');
        const noFlower = await client.call('dialogueInfo');
        check('без цветка выбор «Отдать цветок» скрыт (hasItem)',
            noFlower.choices.length === 3
            && !noFlower.choices.some((c) => c.text === 'Отдать цветок'), noFlower.choices);

        // «Да, я копал» → dig даёт предмет (giveItem) → сумка и HUD
        await client.call('dialoguePick', 0);
        await client.call('dialogueAdvance');
        await client.call('dialogueAdvance');
        const got = await client.call('dialogueInfo');
        const inv1 = await client.call('invInfo');
        check('giveItem диалога наполнил сумку: цветок ×1',
            got.active === false && inv1.all.length === 1
            && inv1.all[0].id === 'flower' && inv1.all[0].count === 1, inv1);

        // Кадр с предметом: HUD в левом верхнем углу, позиция камеры та же
        const hudItemShot = await client.screenshot('/tmp/v2_inv_item.png');
        const hudEmpty = decodePng(readFileSync(hudEmptyShot));
        const hudItem = decodePng(readFileSync(hudItemShot));
        const hudDiff = countDiff(cropRows(hudItem, 0, 80, 0, 220), cropRows(hudEmpty, 0, 80, 0, 220));
        check('HUD сумки рисуется в углу кадра', hudDiff > 300, { hudDiff });

        // Повторный диалог: выбор «Отдать цветок» появился (index 3)
        await client.call('interact');
        const withFlower = await client.call('dialogueInfo');
        check('с цветком выбор «Отдать цветок» виден',
            withFlower.choices.length === 4
            && withFlower.choices[3].text === 'Отдать цветок', withFlower.choices);
        await client.call('dialoguePick', 3); // away: takeItem
        await client.call('dialogueAdvance');
        await client.call('dialogueAdvance');
        const gave = await client.call('dialogueInfo');
        const inv2 = await client.call('invInfo');
        check('takeItem забрал цветок: сумка снова пуста',
            gave.active === false && gave.flags.includes('flowerGivenAway')
            && inv2.empty === true, { flags: gave.flags, inv: inv2 });
        const hudAfterShot = await client.screenshot('/tmp/v2_inv_after.png');
        const hudAfter = decodePng(readFileSync(hudAfterShot));
        const backDiff = countDiff(cropRows(hudAfter, 0, 80, 0, 220), cropRows(hudEmpty, 0, 80, 0, 220));
        check('HUD скрылся после отдачи: кадр вернулся', backDiff === 0, { backDiff });

        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, колонки x0..x1 кадра (RGBA) — зона HUD без шума сцены. */
function cropRows(img, y0, y1, x0, x1) {
    const stride = img.width * 4;
    const out = [];
    for (let y = y0; y < y1; y++) {
        out.push(...img.rgba.subarray(y * stride + x0 * 4, y * stride + x1 * 4));
    }
    return out;
}

/** Число различающихся RGBA-пикселей двух зон одного размера. */
function countDiff(a, b) {
    let n = 0;
    for (let i = 0; i < Math.min(a.length, b.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;
}