/**
* Скриншот-гейт инвентаря 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);
// Демка стартует в меню (стек сцен) — входим в игру и проходим переход (0.4 с)
await client.call('startGame', false);
await client.call('stepTicks', 60);
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;
}