Newer
Older
rpg / v2 / apps / game / tools / checks / interact.mjs
/**
 * Скриншот-гейт интерактива и квеста «Три цветка» v2 (трек 17): столбы у
 * троп, подборные моты (once → проп убран, кандидат исчез), верёвка
 * колокола (ветки notFlag), сундук/записка/очаг дома, Старая Машина
 * (заперто → усыплена), зоны наката (без полотна — тост и ползём, с
 * полотном — полный ход), сбор колокольчиков у прудов, катсцена сдачи
 * (посадка поляны у тропы, камера к башне). Скриншоты: /tmp/v2_interact_*.png.
 *
 * Запуск: node apps/game/tools/checks/interact.mjs [--port 5316]
 */
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 ?? 5316);

const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'interact', 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;
}

/** Центр тайла v1 в вокселях (tile*3 + 1.5). */
const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 });
/** Воксель цветка/мота тайла ([tx*3+1, GROUND_Y, ty*3+1]). */
const spotVoxel = (t) => [t.x * 3 + 1, 2, t.y * 3 + 1];

/** Прошагать с зажатым стиком и вернуть пройденную дистанцию по x. */
async function walkX(client, sx, sy, ticks) {
    const a = await client.call('heroInfo');
    await client.call('setInput', sx, sy);
    await client.call('stepTicks', ticks);
    await client.call('setInput', null, null);
    const b = await client.call('heroInfo');
    return b.pos[0] - a.pos[0];
}

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);

        // ---- Луга: столб у тропы (подсказка + реакция-тост) ----
        const signC = center({ x: 48, y: 27 });
        await client.call('heroTeleport', signC.x - 3, signC.z);
        await client.call('stepTicks', 3);
        const ii1 = await client.call('interactInfo');
        check('столб у тропы: кандидат «interact», подсказка с подписью',
            ii1.kind === 'interact' && ii1.near === 'sign_meadows_east'
            && ii1.target.kind === 'signpost' && ii1.hint === 'E — Столб у тропы',
            { kind: ii1.kind, near: ii1.near, hint: ii1.hint });
        await client.call('interact');
        const st1 = await client.call('dialogueInfo');
        check('столб: тост-реакция («иди на звон»)',
            st1.toasts.some((t) => t.includes('Звенец') && t.includes('звон')), { toasts: st1.toasts });

        // ---- Луга: мот (pickup, once) ----
        const moteTile = { x: 13, y: 28 };
        const moteC = center(moteTile);
        const mv = spotVoxel(moteTile);
        const motBefore = await client.call('voxAt', mv[0], mv[1], mv[2]);
        await client.call('heroTeleport', moteC.x + 3, moteC.z);
        await client.call('stepTicks', 3);
        const mv2 = await client.call('dialogueInfo');
        await client.call('interact');
        const motAfter = await client.call('dialogueInfo');
        const mvVox = await client.call('voxAt', mv[0], mv[1], mv[2]);
        check('мот: искра на поле, подбор дал предмет и мот',
            motBefore === 9 && !(mv2.vars.motes > 0) && motAfter.vars.motes === 1
            && motAfter.inv.some((s) => s.id === 'mote') && mvVox === 0
            && motAfter.toasts.some((t) => t.includes('Мот') || t.includes('мот')),
            { before: motBefore, after: mvVox, motes: motAfter.vars.motes });
        await client.call('stepTicks', 3);
        const ii2 = await client.call('interactInfo');
        check('мот поднят: кандидат исчез (once-used вне радиуса-списка)',
            ii2.kind === null && ii2.flower === null, ii2.kind);

        // ---- Пруды: столб, цветок, накат ----
        await client.call('gotoArea', 'ponds');
        await client.call('stepTicks', 5);
        const pSignC = center({ x: 5, y: 3 });
        await client.call('heroTeleport', pSignC.x + 3, pSignC.z);
        await client.call('stepTicks', 3);
        await client.call('interact');
        const st2 = await client.call('dialogueInfo');
        check('столб у берега: тост с припиской', st2.toasts.some((t) => t.includes('голос дороже соли')), {});

        // цветок: сборный тайл (12,34) — «E — Сорвать», воксель снят, вар +1
        const flTile = { x: 12, y: 34 };
        const flC = center(flTile);
        const fv = spotVoxel(flTile);
        const flBefore = await client.call('voxAt', fv[0], fv[1], fv[2]);
        await client.call('heroTeleport', flC.x + 3, flC.z);
        await client.call('stepTicks', 3);
        const ii3 = await client.call('interactInfo');
        check('цветок: кандидат «flower» с тайлом и подсказкой',
            ii3.kind === 'flower' && ii3.flower.x === 12 && ii3.flower.y === 34
            && ii3.hint.includes('Сорвать'),
            { kind: ii3.kind, flower: ii3.flower, hint: ii3.hint });
        await client.call('interact');
        const flAfter = await client.call('dialogueInfo');
        const flVox = await client.call('voxAt', fv[0], fv[1], fv[2]);
        const flTiles = await client.call('flowerTiles');
        check('сбор: вар 1/3, воксель снят, тайл больше не цветок',
            flBefore === 9 && flAfter.vars.flowers === 1 && flVox === 0
            && !flTiles.some((t) => t.x === 12 && t.y === 34)
            && flAfter.toasts.some((t) => t.includes('Лунный колокольчик (1/3)')),
            { before: flBefore, after: flVox, flowers: flAfter.vars.flowers });

        // накат без полотна: тост при входе + ползём вдвое медленнее
        const hzC = center({ x: 10, y: 32 });
        await client.call('heroTeleport', hzC.x, hzC.z);
        await client.call('stepTicks', 3);
        const hz1 = await client.call('dialogueInfo');
        check('вход в накат: тост-предупреждение (без полотна)',
            hz1.toasts.some((t) => t.includes('Свежий накат') && t.includes('потерять голос')),
            { toasts: hz1.toasts });
        const dxSlow = Math.abs(await walkX(client, -1, -1, 30));

        // ---- Дом Ирвина: сундук (полотно), записка, очаг до сдачи ----
        await client.call('gotoArea', 'house_elder');
        await client.call('stepTicks', 5);
        const chestC = center({ x: 3, y: 3 });
        await client.call('heroTeleport', chestC.x + 3, chestC.z);
        await client.call('stepTicks', 3);
        await client.call('interact');
        const chest = await client.call('dialogueInfo');
        check('сундук: полотно в сумке, проп убран',
            chest.inv.some((s) => s.id === 'cloth')
            && (await client.call('voxAt', chestC.x - 0.5, 2, chestC.z - 0.5)) === 0,
            { inv: chest.inv.map((s) => s.id) });
        const noteC = center({ x: 10, y: 4 });
        await client.call('heroTeleport', noteC.x + 3, noteC.z);
        await client.call('stepTicks', 3);
        await client.call('interact');
        const note = await client.call('dialogueInfo');
        check('записка: read_note + тост «не буди Молчание»',
            note.flags.includes('read_note')
            && note.toasts.some((t) => t.includes('Молчание')), {});
        const hearthC = center({ x: 8, y: 0 });
        await client.call('heroTeleport', hearthC.x + 3, hearthC.z + 1.5);
        await client.call('stepTicks', 3);
        await client.call('interact');
        const hearth = await client.call('dialogueInfo');
        check('очаг до сдачи: «тлеют щепки» (ветка без флага)',
            hearth.toasts.some((t) => t.includes('тлеют щепки')), { toasts: hearth.toasts });

        // ---- Пруды с полотном: маскированный тост + полный ход ----
        await client.call('gotoArea', 'ponds');
        await client.call('stepTicks', 5);
        await client.call('heroTeleport', hzC.x, hzC.z);
        await client.call('stepTicks', 3);
        const hz2 = await client.call('dialogueInfo');
        const dxFast = Math.abs(await walkX(client, -1, -1, 30));
        check('накат с полотном: тост «полотно держит», ход в ~2 раза быстрее',
            hz2.toasts.some((t) => t.includes('полотно держит')) && dxFast > dxSlow * 1.7,
            { dxSlow: Math.round(dxSlow * 100) / 100, dxFast: Math.round(dxFast * 100) / 100 });

        // ---- Звенец: верёвка колокола (две ветки) ----
        await client.call('gotoArea', 'zvenets');
        await client.call('stepTicks', 5);
        const ropeC = center({ x: 27, y: 13 });
        await client.call('heroTeleport', ropeC.x + 3, ropeC.z);
        await client.call('stepTicks', 3);
        await client.call('interact');
        const rope1 = await client.call('dialogueInfo');
        check('верёвка: первый звон ставит rang_tower',
            rope1.flags.includes('rang_tower')
            && rope1.toasts.some((t) => t.includes('ушёл в землю')), {});
        await client.call('interact');
        const rope2 = await client.call('dialogueInfo');
        check('верёвка: повторный звон — ветка «пол-тона ниже»',
            rope2.toasts.some((t) => t.includes('пол-тона ниже')), { toasts: rope2.toasts });

        // ---- Сдача цветов: катсцена (посадка, камера к башне, зелень) ----
        const plantTile = { x: 24, y: 22 };
        const plantC = center(plantTile);
        await client.call('heroTeleport', plantC.x, plantC.z);
        await client.call('stepTicks', 3);
        const shotBefore = await client.screenshot('/tmp/v2_interact_before.png');
        // квест ещё не взят (знакомств не было) — первый диалог Ирвина
        const dQ = await client.call('talkTo', 'elder');
        check('до знакомства — elder_first (окно сдачи закрыто)',
            dQ.graphId === 'elder_first', dQ.graphId);
        await client.call('dialogueAdvance'); // ask
        await client.call('dialogueAdvance'); // task (выборы)
        await client.call('dialoguePick', 0); // → give (флаги, часы/карта)
        await client.call('dialogueAdvance'); // ring
        const dQEnd = await client.call('dialogueAdvance'); // конец
        check('квест взят (elder_first пройден до конца)',
            !dQEnd.active && dQEnd.path.length === 5, { path: dQEnd.path });
        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 → pendingHandIn)
        await client.call('dialogueAdvance'); // ring
        await client.call('dialogueAdvance'); // конец → onFinish → катсцена
        await client.call('stepTicks', 84); // 0.8 с посадка + половина глайда
        const camMid = await client.call('heroInfo');
        const heroMid = camMid.tile;
        check('катсцена: герой стоит, камера глайдит к башне',
            heroMid.x === 24 && heroMid.y === 22 && camMid.camTarget[0] > 78
            && camMid.camTarget[2] < 58,
            { tile: heroMid, cam: camMid.camTarget });
        await client.call('stepTicks', 260); // конец катсцены + возврат камеры
        const fin = await client.call('dialogueInfo');
        const finTiles = await client.call('flowerTiles');
        const pv = spotVoxel(plantTile);
        const plantVox = await client.call('voxAt', pv[0], pv[1], pv[2]);
        check('сдача: цветы посажены (вар 0, тайлы цветут, воксель на месте)',
            fin.vars.flowers === 0
            && [[24, 22], [25, 23], [24, 24]].every(([x, y]) => finTiles.some((t) => t.x === x && t.y === y))
            && plantVox === 9,
            { flowers: fin.vars.flowers, planted: finTiles.length, vox: plantVox });
        check('финал акта 1 в тостах',
            fin.toasts.some((t) => t.includes('Цветы в земле'))
            && fin.toasts.some((t) => t.includes('Пепел отступил у тропы')),
            { toasts: fin.toasts });
        const shotAfter = decodePng(readFileSync(await client.screenshot('/tmp/v2_interact_after.png'))).rgba;
        const plantDiff = countDiff(decodePng(readFileSync(shotBefore)).rgba, shotAfter);
        check('поляна рисуется (кадр изменился)', plantDiff > 100, { plantDiff });

        // ---- Зал Машины: заперто → настроен → усыплена ----
        await client.call('gotoArea', 'machine_hall');
        await client.call('stepTicks', 5);
        const machC = center({ x: 13, y: 1 });
        await client.call('heroTeleport', machC.x, machC.z + 3);
        await client.call('stepTicks', 3);
        await client.call('interact');
        const mach1 = await client.call('dialogueInfo');
        check('Машина без резонатора: «не спеть»',
            mach1.toasts.some((t) => t.includes('не спеть')), { toasts: mach1.toasts });
        await client.call('setState', { flags: ['quest_resonator_done'] });
        await client.call('interact');
        await client.call('stepTicks', 300); // катсцена усыпления — тост в её финале
        const mach2 = await client.call('dialogueInfo');
        check('правильный звон: machine_lulled + тост-финал акта 2',
            mach2.flags.includes('machine_lulled')
            && mach2.toasts.some((t) => t.includes('Впервые за годы — тише')), {});
        await client.call('interact');
        const mach3 = await client.call('dialogueInfo');
        check('Машина спит (ветка machine_lulled)',
            mach3.toasts.some((t) => t.includes('Машина спит')), {});

        // ---- Очаг после сдачи: ветка с флагом ----
        await client.call('gotoArea', 'house_elder');
        await client.call('stepTicks', 5);
        await client.call('heroTeleport', hearthC.x + 3, hearthC.z + 1.5);
        await client.call('stepTicks', 3);
        await client.call('interact');
        const hearth2 = await client.call('dialogueInfo');
        check('очаг после сдачи: «тепло возвращается»',
            hearth2.toasts.some((t) => t.includes('тепло возвращается')), {});
    } 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));