/**
* Гейт акта 2 v2 (трек 19): Клинт появляется по act2_hook, квест «Голос для
* резонатора» (тон-деревья → тоны 3/3 → сдача с катсценой настройки),
* реакция резонатора до/после, эпилог machine_hook, усыпление Машины с
* катсценой «правильного звона», пейзажная фауна (олени: бродят, подходят
* к звону). Скриншоты: /tmp/v2_act2_*.png.
*
* Запуск: node apps/game/tools/checks/act2.mjs [--port 5324]
*/
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 ?? 5324);
const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) });
const report = { name: 'act2', ok: false, checks: [] };
const check = (title, ok, info = null) => {
report.checks.push({ title, ok, info });
console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
};
/** Центр тайла (в тайлах v1) — как мост tileCenter. */
const center = (t) => ({ x: t.x * 10 + 5, z: t.y * 10 + 5 });
/** Тон-деревья рощи (data/quests). */
const TREES = [[32, 6], [12, 10], [20, 30]];
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('setAsh', false); // кадр-равенства — мир без пепла
await client.call('setClock', 720); // полдень: страница жила в реальном времени
await client.call('stepTicks', 5);
// ---- Роща до крючка: Клинта нет, тон-дерево молчит ----
await client.call('gotoArea', 'rust_grove');
await client.call('stepTicks', 5);
let ni = await client.call('npcInfo');
check('без act2_hook: Клинта нет в роще', ni.npcs.length === 0, ni.npcs);
const t2 = center({ x: TREES[1][0], y: TREES[1][1] });
await client.call('heroTeleport', t2.x + 10, t2.z);
await client.call('stepTicks', 3);
await client.call('interact');
let di = await client.call('dialogueInfo');
check('тон-дерево до квеста: «дерево молчит», тоны 0',
di.toasts.some((t) => t.includes('молчит')) && !(di.vars.tones > 0),
{ toasts: di.toasts, tones: di.vars.tones });
// ---- Клинт появляется, взятие квеста ----
await client.call('setState', { flags: ['act2_hook'] });
await client.call('gotoArea', 'rust_grove'); // перезаход — спавн по флагу
await client.call('stepTicks', 5);
ni = await client.call('npcInfo');
check('act2_hook: Клинт стоит у резонатора (23,17)',
ni.npcs.length === 1 && ni.npcs[0].id === 'clint'
&& ni.npcs[0].tile.x === 23 && ni.npcs[0].tile.y === 17, ni.npcs);
const dC = await client.call('talkTo', 'clint');
check('первый диалог Клинта — clint_first', dC.graphId === 'clint_first', dC.graphId);
for (let i = 0; i < 6; i++) di = await client.call('dialogueAdvance');
check('clint_first: met_clint + quest_resonator_taken',
di.flags.includes('met_clint') && di.flags.includes('quest_resonator_taken'),
{ flags: di.flags.slice(-2) });
// ---- Машина до настройки резонатора ----
await client.call('gotoArea', 'machine_hall');
await client.call('stepTicks', 5);
const mach = center({ x: 13, y: 1 });
await client.call('heroTeleport', mach.x, mach.z + 10);
await client.call('stepTicks', 3);
await client.call('interact');
di = await client.call('dialogueInfo');
check('Машина без резонатора: «не спеть»', di.toasts.some((t) => t.includes('не спеть')), di.toasts);
// ---- Тон-деревья: три тона ----
await client.call('gotoArea', 'rust_grove');
await client.call('stepTicks', 5);
for (const [i, [tx, ty]] of TREES.entries()) {
const c = center({ x: tx, y: ty });
await client.call('heroTeleport', c.x + 10, c.z);
await client.call('stepTicks', 3);
await client.call('interact');
di = await client.call('dialogueInfo');
check(`дерево ${i + 1}: тон взят (${i + 1}/3), флаг tone_tree_${i + 1}`,
di.vars.tones === i + 1 && di.flags.includes(`tone_tree_${i + 1}`),
{ tones: di.vars.tones, flag: di.flags.includes(`tone_tree_${i + 1}`) });
}
const c1 = center({ x: TREES[0][0], y: TREES[0][1] }); // повтор дерева 1
await client.call('heroTeleport', c1.x + 10, c1.z);
await client.call('stepTicks', 3);
await client.call('interact');
di = await client.call('dialogueInfo');
check('повтор дерева: «уже прозвенело», тоны не растут',
di.toasts.some((t) => t.includes('уже прозвенело')) && di.vars.tones === 3,
{ toasts: di.toasts, tones: di.vars.tones });
// ---- Резонатор до настройки: молчит ----
const rez = center({ x: 24, y: 16 });
await client.call('heroTeleport', rez.x + 10, rez.z);
await client.call('stepTicks', 3);
await client.call('interact');
di = await client.call('dialogueInfo');
check('резонатор до настройки: «ржавый, молчит»',
di.toasts.some((t) => t.includes('молчит')), di.toasts);
// ---- Сдача: clint_tune → катсцена настройки ----
const dT = await client.call('talkTo', 'clint');
check('тоны 3/3 — clint_tune', dT.graphId === 'clint_tune', dT.graphId);
await client.call('dialogueAdvance'); // accept: флаг + custom tune_resonator
di = await client.call('dialogueInfo');
check('резонатор настроен (quest_resonator_done)', di.flags.includes('quest_resonator_done'), {});
await client.call('dialogueAdvance'); // ring
di = await client.call('dialogueAdvance'); // конец → катсцена
await client.call('stepTicks', 100);
let cs = await client.call('cutsceneInfo');
check('катсцена настройки играет', cs.active && cs.step >= 1, cs);
await client.call('stepTicks', 70); // шаг 3 — камера глайдит к дереву
const hi = await client.call('heroInfo');
cs = await client.call('cutsceneInfo');
check('катсцена: герой стоит, камера едет к дереву рощи',
cs.active && cs.step === 2 && hi.camTarget[0] > 125 && hi.camTarget[0] < 255
&& hi.tile.x === 25 && hi.tile.y === 16,
{ step: cs.step, cam: hi.camTarget, tile: hi.tile });
await client.call('stepTicks', 300);
di = await client.call('dialogueInfo');
cs = await client.call('cutsceneInfo');
const hiEnd = await client.call('heroInfo');
check('конец катсцены: тост «Роща слышит», камера вернулась к герою',
!cs.active && di.toasts.some((t) => t.includes('Роща слышит'))
&& Math.round(hiEnd.camTarget[0]) === Math.round(rez.x + 10), { toasts: di.toasts, cam: hiEnd.camTarget });
// ---- После настройки: реакции в лад ----
await client.call('interact'); // герой ещё у резонатора
di = await client.call('dialogueInfo');
check('резонатор после настройки: «держит тон ровно»',
di.toasts.some((t) => t.includes('держит тон ровно')), di.toasts);
const dR = await client.call('talkTo', 'clint'); // эпилог
check('эпилог — clint_repeat', dR.graphId === 'clint_repeat', dR.graphId);
for (let i = 0; i < 3; i++) di = await client.call('dialogueAdvance');
check('clint_repeat: machine_hook', di.flags.includes('machine_hook'), {});
// ---- Усыпление Машины: катсцена «правильного звона» ----
await client.call('gotoArea', 'machine_hall');
await client.call('stepTicks', 5);
await client.call('heroTeleport', mach.x, mach.z + 10);
await client.call('stepTicks', 3);
await client.call('interact');
let cs2 = await client.call('cutsceneInfo');
di = await client.call('dialogueInfo');
check('правильный звон: machine_lulled + катсцена усыпления',
di.flags.includes('machine_lulled') && cs2.active, { cs: cs2 });
await client.call('stepTicks', 300);
di = await client.call('dialogueInfo');
cs2 = await client.call('cutsceneInfo');
check('конец: тост «Впервые за годы — тише»',
!cs2.active && di.toasts.some((t) => t.includes('Впервые за годы — тише')), di.toasts.slice(-2));
await client.call('interact');
di = await client.call('dialogueInfo');
check('повтор: «Машина спит»', di.toasts.some((t) => t.includes('Машина спит')), di.toasts.slice(-2));
// ---- Фауна: олени лугов ----
await client.call('gotoArea', 'meadows');
await client.call('stepTicks', 2);
let fi = await client.call('faunaInfo');
check('луга: два оленя на спавнах (18,36) и (24,40)',
fi.deer.length === 2
&& fi.deer[0].tile.x === 18 && fi.deer[0].tile.y === 36
&& fi.deer[1].tile.x === 24 && fi.deer[1].tile.y === 40, fi.deer);
await client.call('stepTicks', 400); // брожение: 6.7 с — шагают
const fi2 = await client.call('faunaInfo');
check('олени бродят: тайлы сместились',
fi2.deer.some((d, i) => d.tile.x !== fi.deer[i].tile.x || d.tile.y !== fi.deer[i].tile.y),
{ before: fi.deer.map((d) => d.tile), after: fi2.deer.map((d) => d.tile) });
// Подход к звону: звон в 1 тайле от первого оленя
const d0 = fi2.deer[0];
await client.call('startleFauna', d0.x + 10, d0.z);
fi = await client.call('faunaInfo');
check('звон рядом: олень идёт к источнику (approach)',
fi.deer[0].state === 'approach', fi.deer[0]);
await client.call('stepTicks', 120); // 1 тайл ≈ 1.5 с на скорости подхода
fi = await client.call('faunaInfo');
check('олень дошёл до звона и стоит',
fi.deer[0].state === 'idle' && Math.abs(fi.deer[0].x - (d0.x + 10)) < 0.1
&& Math.abs(fi.deer[0].z - d0.z) < 0.1, fi.deer[0]);
// Кадр оленя у воды (визуальный артефакт)
const shot = decodePng(readFileSync(await client.screenshot('/tmp/v2_act2_deer.png')));
check('кадр с оленем не пустой', shot.rgba.length > 1000, {});
} finally {
await browser.close();
}
} catch (e) {
console.error(e);
report.fatal = String(e);
}
report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok);
console.log(JSON.stringify(report));
process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно