diff --git a/CLAUDE.md b/CLAUDE.md index 77890ae..c23f943 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ node apps/game/tools/agent.mjs snapshot --new-game # снапшот игры (JSON) node apps/game/tools/smoke.mjs # смоук-тест в реальном Chromium (скриншот + консоль) node apps/game/tools/smoke-act1.mjs # полный прогон акта 1 через агентный мост +node apps/game/tools/final-smoke.mjs # интеграционный смоук: 3 поездки луга<->Звенец, бой, guard аудио ``` Dev-сервер по умолчанию на http://localhost:5173; для отдельного порта: `npm run dev -- --port 5199`. diff --git a/apps/game/tools/final-smoke.mjs b/apps/game/tools/final-smoke.mjs new file mode 100644 index 0000000..08fd74c --- /dev/null +++ b/apps/game/tools/final-smoke.mjs @@ -0,0 +1,95 @@ +/** + * Финальный визуальный смоук (финал ревью): переходы луга<->Звенец x3, + * бой с врагом, тост знака — проверка отсутствия наслоения вьюх/звука + * после повторных переходов. Скриншоты в /tmp/rpg_final_*.png. + * Запуск: node apps/game/tools/final-smoke.mjs [url] + */ +import { openGame, startDevServer } from './lib.mjs'; + +const server = await startDevServer(); +let ctx; +try { + ctx = await openGame({ url: server.url, newGame: true }); + const { page, agent } = ctx; + const fail = []; + + /** Туда-обратно: пешком к тропе (26,14) -> Звенец -> колодец (9,6) -> луга. */ + const roundTrip = async (n) => { + await agent.command('scene:sleepAll'); + const w1 = await agent.walkTo(26, 14, { timeoutTicks: 3000 }); + if (!w1) fail.push(`поездка ${n}: не дошёл до тропы`); + const z = await agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + if (!z.ok) fail.push(`поездка ${n}: не в Звенце`); + await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await agent.tapTile(9, 6); // колодец у дома Ирвина + const m = await agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); + if (!m.ok) fail.push(`поездка ${n}: колодец не вернул на луга`); + await agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await page.screenshot({ path: `/tmp/rpg_final_trip${n}.png` }); + }; + + for (let n = 1; n <= 3; n++) await roundTrip(n); + console.log('3 поездки туда-обратно: ок'); + + // Тост знака у тропы (25,13): текст виден, тик не дублируется. + await agent.tapTile(25, 13); + const t = await agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { timeoutTicks: 1500 }); + console.log('тост знака:', t.ok ? t.snapshot.lastToast?.text : 'НЕ ПОКАЗАН'); + if (!t.ok) fail.push('тост знака не показан'); + + // Бой: клик по ближайшему живому врагу (авто-подход + удар конусом). + // Позиция врага плавает (сам идёт на героя) — предикат по минимуму hp живых. + const before = await agent.snapshot(); + const alive = (s) => (s.enemies ?? []).filter((e) => !e.dead && e.hp > 0); + const minHp = (s) => Math.min(...alive(s).map((e) => e.hp)); + const foe = alive(before).sort( + (a, b) => Math.hypot(a.pos.x - before.hero.pos.x, a.pos.y - before.hero.pos.y) - + Math.hypot(b.pos.x - before.hero.pos.x, b.pos.y - before.hero.pos.y) + )[0]; + if (foe) { + await agent.tapTile(Math.floor(foe.pos.x), Math.floor(foe.pos.y)); // worldToTile = floor + const hit = await agent.waitFor( + `(s.enemies.filter((e) => !e.dead && e.hp > 0).length) < ${alive(before).length} || (Math.min(...s.enemies.filter((e) => !e.dead && e.hp > 0).map((e) => e.hp))) < ${minHp(before)}`, + { timeoutTicks: 1200 } + ); + console.log('бой:', hit.ok ? `враг получил урон (мин hp ${minHp(before)} -> ${minHp(hit.snapshot)})` : 'УРОНА НЕТ'); + if (!hit.ok) fail.push('враг не получил урон'); + } else { + console.log('бой: живых врагов рядом нет — пропускаю'); + } + await page.screenshot({ path: '/tmp/rpg_final_combat.png' }); + + // Наслоение звука: после 3-го возврата амбиент не должен перезапуститься + // (guard ключа). Лог — кольцевой на 24 записи, старт с бута давно вытеснен, + // поэтому следим за хвостом лога после перехода. + let ambRestarted = false; + for (let i = 0; i < 4 && !ambRestarted; i++) { + await agent.step(30); + const tail = await page.evaluate(() => + (window.__gameAudioLog ?? []).slice(-8).map((e) => e.key) + ); + ambRestarted = tail.includes('ambience/meadows'); + } + console.log('перезапуск амбиента после 3-го возврата:', ambRestarted ? 'ЕСТЬ' : 'нет'); + if (ambRestarted) fail.push('амбиент перезапустился после возврата на луга'); + // Объекты в снапшоте по одному на id; враги — без id в снапшоте, поэтому + // проверяем, что их число не растёт от поездок (наслоения вьюх нет). + const snap = await agent.snapshot(); + const dupIds = (arr) => arr.length - new Set(arr.map((x) => x.id)).size; + const dup = dupIds(snap.interactables ?? []); + const dupEnemies = (snap.enemies ?? []).length - before.enemies?.length; + console.log('дубли id объектов:', dup, '; врагов было/стало:', before.enemies?.length, '/', snap.enemies?.length); + if (dup > 0) fail.push(`дубли в снапшоте: ${dup}`); + if (dupEnemies > 0) fail.push(`врагов стало больше: ${before.enemies?.length} -> ${snap.enemies?.length}`); + + const errs = ((await agent.invariants()) ?? []).filter((i) => i.severity === 'error'); + if (errs.length) fail.push(`инварианты: ${JSON.stringify(errs)}`); + + console.log(fail.length ? 'ПРОВАЛЫ: ' + fail.join('; ') : 'ИТОГ: чисто'); + process.exitCode = fail.length ? 1 : 0; +} finally { + try { + await ctx?.browser?.close(); + } catch {} + server.stop(); +} \ No newline at end of file