Newer
Older
rpg / v2 / apps / game / tools / checks / npc.mjs
/**
 * Скриншот-гейт 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*5 + 2.5). */
const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 });

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('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 - 5, 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 - 5, 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 - 5, 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));