/**
* Скриншот-гейт NPC и квестового каркаса v2 (трек 16): NPC стоят по
* раскладке v1 (Ирвин/Мила в Звенце, Клинт — по флагу act2_hook),
* выбор графа по квест-стадиям (первый → repeat → сдача), эффекты графов
* (giveItem → сумка, setFlags → состояние), журнал/трекер квестов, мир
* замер в диалоге, панель диалога рисуется. Скриншоты: /tmp/v2_npc_*.png.
*
* Запуск: node apps/game/tools/checks/npc.mjs [--port 5314]
*/
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 ?? 5314);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'npc', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
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;
}
function colorCount(rgba) {
const seen = new Set();
for (let i = 0; i < rgba.length; i += 4) {
seen.add((rgba[i] >> 3) + (rgba[i + 1] >> 3) * 32 + (rgba[i + 2] >> 3) * 1024);
}
return seen.size;
}
/** Воксельный центр тайла v1 (как tileCenter: tile*3 + 1.5). */
const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 });
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('stepTicks', 10);
// NPC Звенца по раскладке v1; Клинта в роще ещё нет (act2_hook не поднят)
const zn = await client.call('gotoArea', 'zvenets');
await client.call('stepTicks', 5);
const info = await client.call('npcInfo');
const byId = Object.fromEntries(info.npcs.map((n) => [n.id, n]));
check('Звенец: стоят Ирвин (12,15) и Мила (40,20), Клинта нет',
info.area === 'zvenets' && info.npcs.length === 2
&& byId.elder && byId.elder.tile.x === 12 && byId.elder.tile.y === 15
&& byId.trader && byId.trader.tile.x === 40 && byId.trader.tile.y === 20,
{ npcs: info.npcs.map((n) => n.id) });
check('графы по стадиям: первый диалог у обоих, Клинт — clint_first',
byId.elder.dialogue === 'elder_first' && byId.trader.dialogue === 'trader_first',
{ elder: byId.elder.dialogue, trader: byId.trader.dialogue });
const grove0 = await client.call('gotoArea', 'rust_grove');
await client.call('stepTicks', 5);
const groveInfo = await client.call('npcInfo');
check('роща без act2_hook: Клинта нет', groveInfo.area === 'rust_grove' && groveInfo.npcs.length === 0,
{ npcs: groveInfo.npcs.map((n) => n.id) });
await client.call('gotoArea', 'zvenets');
await client.call('stepTicks', 5);
// Подход к Ирвину: радиус 5 вокселей → near + подсказка «E — Поговорить»
const elderC = center({ x: 12, y: 15 });
await client.call('heroTeleport', elderC.x - 3, elderC.z);
await client.call('stepTicks', 3);
const near1 = await client.call('npcInfo');
const ii1 = await client.call('interactInfo');
check('у Ирвина: near = elder, подсказка «E — Поговорить»',
near1.near === 'elder' && ii1.hint.includes('E') && ii1.hint.includes('оговорить'),
{ near: near1.near, hint: ii1.hint });
// Мир замер в диалоге: стик не двигает героя
const hiBefore = await client.call('heroInfo');
await client.call('interact');
await client.call('setInput', 1, 1);
await client.call('stepTicks', 10);
await client.call('setInput', null, null);
const hiLocked = await client.call('heroInfo');
const dlg = await client.call('dialogueInfo');
const moved = Math.hypot(hiLocked.pos[0] - hiBefore.pos[0], hiLocked.pos[2] - hiBefore.pos[2]);
check('диалог открыт (greet), герой стоит на месте',
dlg.active && dlg.nodeId === 'greet' && moved < 0.01,
{ nodeId: dlg.nodeId, moved: Math.round(moved * 100) / 100 });
// Панель диалога рисуется: кадр с репликой отличается от пустого
const withPanel = await client.screenshot('/tmp/v2_npc_panel.png');
await client.call('dialogueAbort');
await client.call('stepTicks', 3);
const withoutPanel = await client.screenshot('/tmp/v2_npc_nopanel.png');
const panelDiff = countDiff(decodePng(readFileSync(withPanel)).rgba,
decodePng(readFileSync(withoutPanel)).rgba);
check('панель диалога видна (кадры различаются)', panelDiff > 3000, { panelDiff });
// elder_first: выбор на «task» → give (флаги + часы и карта) → ring
const d1 = await client.call('talkTo', 'elder');
check('взятие квеста: greet, выбор варианта', d1.nodeId === 'greet' && d1.waitingForChoice === false, d1.nodeId);
await client.call('dialogueAdvance'); // ask
await client.call('dialogueAdvance'); // task (выборы)
const dTask = await client.call('dialogueInfo');
check('на «task» показан выбор', dTask.nodeId === 'task' && dTask.waitingForChoice && dTask.choices.length === 1,
{ choices: dTask.choices.map((c) => c.text) });
const dGive = await client.call('dialoguePick', 0); // → give (флаги, предметы)
const invAfter = dGive.inv.map((s) => `${s.id}:${s.count}`).join(',');
check('give: флаги met_elder+quest_bells_taken, часы и карта в сумке',
dGive.flags.includes('met_elder') && dGive.flags.includes('quest_bells_taken')
&& invAfter === 'clock:1,map_scroll:1', { inv: invAfter });
check('трекер: стадия сбора (0/3)',
dGive.questTracker !== null && dGive.questTracker.includes('колокольчик') && dGive.questTracker.includes('0/3'),
{ tracker: dGive.questTracker });
await client.call('dialogueAdvance'); // ring
const dEnd = await client.call('dialogueAdvance');
check('диалог завершился', !dEnd.active && dEnd.result !== null && dEnd.path.length === 5,
{ path: dEnd.path });
// Повторный разговор: квест-стадия 2 не готова (цветов 0) → repeat
const dRep = await client.call('talkTo', 'elder');
check('не готово — repeat-диалог Ирвина', dRep.graphId === 'elder_repeat', dRep.graphId);
await client.call('dialogueAdvance');
// Мила: знакомство trader_first (полотно) — до сдачи цветов: после
// сдачи активная стадия «bells» переключит Милу на trader_after
const traderC = center({ x: 40, y: 20 });
await client.call('heroTeleport', traderC.x - 3, traderC.z);
await client.call('stepTicks', 3);
const dMila = await client.call('talkTo', 'trader');
check('Мила: знакомство trader_first', dMila.graphId === 'trader_first', dMila.graphId);
for (let i = 0; i < 4; i++) await client.call('dialogueAdvance'); // stop→warn→gift→(give_cloth)→reply
const dMilaEnd = await client.call('dialogueAdvance');
check('Мила: met_mila+got_cloth, полотно в сумке',
dMilaEnd.flags.includes('met_mila') && dMilaEnd.flags.includes('got_cloth')
&& dMilaEnd.inv.some((s) => s.id === 'cloth'),
{ flags: dMilaEnd.flags.length, inv: dMilaEnd.inv.map((s) => s.id) });
// Сдача: цветы 3/3 → elder_hand_in (custom plant_flowers) → квест закрыт
await client.call('setState', { vars: { flowers: 3 } });
const dHand = await client.call('talkTo', 'elder');
check('3/3 — открывается сдача elder_hand_in', dHand.graphId === 'elder_hand_in', dHand.graphId);
await client.call('dialogueAdvance'); // accept (флаг + custom)
const dHand2 = await client.call('dialogueInfo');
check('сдача: quest_bells_done, стадия 3 — «поговорить с Милой»',
dHand2.flags.includes('quest_bells_done') && dHand2.questTracker !== null
&& dHand2.questTracker.includes('тропы гудит'), { tracker: dHand2.questTracker });
await client.call('dialogueAdvance'); // ring
await client.call('dialogueAdvance'); // конец
// trader_after (эпилог «bells») → trader_repeat: motes_note по вару
const dAfter = await client.call('talkTo', 'trader');
check('стадия 3 «bells» — trader_after', dAfter.graphId === 'trader_after', dAfter.graphId);
await client.call('dialogueAdvance');
const dHook = await client.call('dialogueAdvance');
check('trader_after: act2_hook (крючок акта 2)', dHook.flags.includes('act2_hook'), {});
const dRep2 = await client.call('talkTo', 'trader');
check('без мотов motes_note скрыт (диалог короткий)',
dRep2.graphId === 'trader_repeat' && !dRep2.path.includes('motes_note'), { path: dRep2.path });
await client.call('dialogueAbort');
await client.call('setState', { vars: { motes: 2 } });
await client.call('talkTo', 'trader');
const dRep3 = await client.call('dialogueAdvance'); // quiet → motes_note
check('моты есть — motes_note виден', dRep3.path.includes('motes_note'), { path: dRep3.path });
await client.call('dialogueAbort');
// Клинт появился в роще (act2_hook), квест резонатора: взятие → сдача
await client.call('gotoArea', 'rust_grove');
await client.call('stepTicks', 5);
const clintInfo = await client.call('npcInfo');
check('act2_hook: Клинт стоит в роще (23,17)',
clintInfo.npcs.length === 1 && clintInfo.npcs[0].id === 'clint'
&& clintInfo.npcs[0].tile.x === 23 && clintInfo.npcs[0].tile.y === 17,
{ npcs: clintInfo.npcs.map((n) => n.id) });
const clintC = center({ x: 23, y: 17 });
await client.call('heroTeleport', clintC.x - 3, clintC.z);
await client.call('stepTicks', 3);
const dClint = await client.call('talkTo', 'clint');
for (let i = 0; i < 5; i++) await client.call('dialogueAdvance'); // grunt→…→reply
const dClintEnd = await client.call('dialogueAdvance');
check('Клинт: met_clint+quest_resonator_taken',
dClintEnd.flags.includes('met_clint') && dClintEnd.flags.includes('quest_resonator_taken'), {});
await client.call('setState', { vars: { tones: 3 } });
const dTune = await client.call('talkTo', 'clint');
check('тоны 3/3 — clint_tune', dTune.graphId === 'clint_tune', dTune.graphId);
await client.call('dialogueAdvance'); // accept (флаг + custom)
const dTune2 = await client.call('dialogueInfo');
check('резонатор настроен (quest_resonator_done)', dTune2.flags.includes('quest_resonator_done'), {});
await client.call('dialogueAdvance'); // ring
await client.call('dialogueAdvance'); // конец
const dClintRep = await client.call('talkTo', 'clint');
for (let i = 0; i < 2; i++) await client.call('dialogueAdvance'); // grumble→hint→half_tone
const dClintRepEnd = await client.call('dialogueAdvance');
check('эпилог: machine_hook, все квесты закрыты (трекер пуст)',
dClintRepEnd.flags.includes('machine_hook') && dClintRepEnd.questTracker === null, {});
// Кадр рощи с Клинтом рисуется (катсцена настройки доиграть — кадр чистый)
await client.call('stepTicks', 500);
const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_npc_grove.png')));
check('кадр рощи с Клинтом не пустой', colorCount(shot.rgba) >= 8, {});
} finally {
await browser.close();
}
} catch (e) {
report.error = String(e && e.stack ? e.stack : e);
console.error(report.error);
} finally {
server.stop();
}
report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));