/**
* Скриншот-гейт интерактива и квеста «Три цветка» 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*5 + 2.5). */
const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 });
/** Воксель цветка/мота тайла ([tx*5+2, GROUND_Y, ty*5+2]). */
const spotVoxel = (t) => [t.x * 5 + 2, 2, t.y * 5 + 2];
/** Прошагать с зажатым стиком и вернуть пройденную дистанцию по 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 - 5, 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 + 5, 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 + 5, 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 + 5, 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 + 5, 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 + 5, 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 + 5, hearthC.z + 2.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 + 5, 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 + 5);
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 + 5, hearthC.z + 2.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));