diff --git a/CLAUDE.md b/CLAUDE.md index fd68a12..77890ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ npm run art:lint # линтер арта: палитра/слоты/«мыло» по apps/game/assets npm run aiart # пайплайн AI-атласов: gen/build/promote (apps/game/tools/aiart) node apps/game/tools/aiart/tryon.mjs # примерка AI-сборки в игре до promote (скриншот /tmp/rpg_tryon.png) -npm run dialogues # визуальный редактор графов диалогов (порт 5199) +npm run dialogues # визуальный редактор графов диалогов (порт 5299) npm run audio # перегенерация WAV (apps/game/tools/audio/gen.mjs) npm run maps # перезапись карт-файлов (RUN_MAPS_GEN=1; обычный npm test только сверяет fresh) npm run agent:check # полный прогон проверок через агентный мост (JSON) @@ -51,7 +51,7 @@ ### Где что лежит - `docs/llms.txt` — **точка входа для ИИ-агента**: карта всех док одной строкой на док. -- `docs/engine/` — **документация движка** (по-русски): `README.md` (архитектура и принципы), `getting-started.md`, `core.md`, `render.md`, `input.md`, `maps.md`, `ui-and-dialogue.md`, `cutscene.md`, `assets-audio-save.md`, `art-pipeline.md`, `recipes.md`, `agent.md` (агентный мост), `practices.md` (живой документ практик агента). При изменении API движка обновляй соответствующий файл и `practices.md` (если появился новый приём) **в том же коммите**. +- `docs/engine/` — **документация движка** (по-русски): `README.md` (архитектура и принципы), `getting-started.md`, `core.md`, `render.md`, `anim.md` (клипы/аниматоры/тик), `input.md`, `maps.md`, `inventory.md` (модель Inventory), `registry.md` (SceneRegistry), `ui-and-dialogue.md`, `cutscene.md`, `assets-audio-save.md`, `art-pipeline.md`, `recipes.md`, `agent.md` (агентный мост), `practices.md` (живой документ практик агента). При изменении API движка обновляй соответствующий файл и `practices.md` (если появился новый приём) **в том же коммите**. - `docs/demo.md` — **внутренняя проектная дока среза**: матрица «подсистема движка → где показана в игре» + статус, боевая модель, архитектура, дорожная карта. - `docs/plan.md` — **живой план развития**: актуальные задачи, проработка крупных направлений (AI-генерация спрайтов/аудио, ограничения железа). Новые планы вести здесь. - `docs/world.md` — библия мира (сеттинг, локации, персонажи, сюжет). **Любой новый контент сверять с ней.** diff --git a/apps/game/tools/checks/agent-invariants.mjs b/apps/game/tools/checks/agent-invariants.mjs index ea9b5c3..cf8451f 100644 --- a/apps/game/tools/checks/agent-invariants.mjs +++ b/apps/game/tools/checks/agent-invariants.mjs @@ -4,35 +4,30 @@ * инварианты не содержат ошибок. * Запуск: node tools/agent.mjs run tools/checks/agent-invariants.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('agent-invariants'); - const server = await startDevServer(); - let ctx; - try { - await c.run('новая игра через мост', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - const s = await ctx.agent.snapshot(); - c.expect(s.scene === 'location', `сцена не location`, { scene: s.scene, err: s.error }); - return null; - }); - await c.run('300 шагов без ошибок инвариантов', async () => { - await ctx.agent.step(300); - const inv = await ctx.agent.invariants(); - const errs = inv.filter((i) => i.severity === 'error'); - c.expect(errs.length === 0, 'инварианты нарушены', errs); - return null; - }); - await c.run('снапшот содержит героя и флаги', async () => { - const s = await ctx.agent.snapshot(); - c.expect(!!s.hero?.tile, 'нет s.hero.tile', s); - c.expect(Array.isArray(s.flags), 'нет s.flags'); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + return withChecks( + 'agent-invariants', + async (t) => { + await t.c.run('новая игра через мост', async () => { + await t.boot(); + const s = await t.ctx.agent.snapshot(); + t.c.expect(s.scene === 'location', 'сцена не location', { scene: s.scene, err: s.error }); + return null; + }); + await t.c.run('300 шагов без ошибок инвариантов', async () => { + await t.ctx.agent.step(300); + await t.expectInvariantsClean('после 300 шагов'); + return null; + }); + await t.c.run('снапшот содержит героя и флаги', async () => { + const s = await t.ctx.agent.snapshot(); + t.c.expect(!!s.hero?.tile, 'нет s.hero.tile', s); + t.c.expect(Array.isArray(s.flags), 'нет s.flags'); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/ai.mjs b/apps/game/tools/checks/ai.mjs index 8aee97b..b716432 100644 --- a/apps/game/tools/checks/ai.mjs +++ b/apps/game/tools/checks/ai.mjs @@ -9,100 +9,96 @@ * 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты. * Запуск: node tools/agent.mjs run tools/checks/ai.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); /** Тяжеловес из снапшота. */ const heavy = (s) => (s.enemies ?? []).find((e) => e.kind === 'heavy'); export default async function ({ pretty }) { - const c = new Checks('ai'); - const server = await startDevServer(); - let ctx; - try { - await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - const s0 = await ctx.agent.snapshot(); - const h0 = heavy(s0); - c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0); - // Спавн (6,22), патруль к (4,20): позиция меняется без участия героя. - const w = await ctx.agent.waitFor( - '(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 6', - { timeoutTicks: 600 } - ); - c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies); - return null; - }); + return withChecks( + 'ai', + async (t) => { + const { c } = t; + await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => { + await t.boot(); + const s0 = await t.ctx.agent.snapshot(); + const h0 = heavy(s0); + c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0); + // Спавн (6,22), патруль к (4,20): позиция меняется без участия героя. + const w = await t.ctx.agent.waitFor( + '(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 6', + { timeoutTicks: 600 } + ); + c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies); + return null; + }); - await c.run('тихий шум настораживает патрульного', async () => { - // Герой далеко (спавн 14,14), шум у маршрута тяжеловеса. - await ctx.agent.command('scene:noise', { x: 5, y: 21, level: 0.35 }); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "wary"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies); - return null; - }); + await c.run('тихий шум настораживает патрульного', async () => { + // Герой далеко (спавн 14,14), шум у маршрута тяжеловеса. + await t.ctx.agent.command('scene:noise', { x: 5, y: 21, level: 0.35 }); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "wary"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies); + return null; + }); - await c.run('настороженность гаснет: wary -> return -> patrol', async () => { - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies); - return null; - }); + await c.run('настороженность гаснет: wary -> return -> patrol', async () => { + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies); + return null; + }); - await c.run('герой рядом — погоня', async () => { - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); - await ctx.agent.command('scene:teleport', { x: 5, y: 22 }); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "chase"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies); - return null; - }); + await c.run('герой рядом — погоня', async () => { + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + await t.ctx.agent.command('scene:teleport', { x: 5, y: 22 }); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "chase"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies); + return null; + }); - await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => { - await ctx.agent.command('scene:teleport', { x: 14, y: 14 }); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies); - return null; - }); + await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => { + await t.ctx.agent.command('scene:teleport', { x: 14, y: 14 }); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies); + return null; + }); - await c.run('мало hp — отступление бегом', async () => { - await ctx.agent.command('scene:teleport', { x: 6, y: 21 }); - const dmg = await ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 }); - c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса'); - const w = await ctx.agent.waitFor( - 's.enemies.find((e) => e.kind === "heavy")?.state === "flee"', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies); - // Держится подальше: дистанция до неподвижного героя растёт. - const s1 = await ctx.agent.snapshot(); - const d1 = dist(heavy(s1).pos, s1.hero.pos); - await ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения - const s2 = await ctx.agent.snapshot(); - const d2 = dist(heavy(s2).pos, s2.hero.pos); - c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 }); - return null; - }); + await c.run('мало hp — отступление бегом', async () => { + await t.ctx.agent.command('scene:teleport', { x: 6, y: 21 }); + const dmg = await t.ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 }); + c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса'); + const w = await t.ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "flee"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies); + // Держится подальше: дистанция до неподвижного героя растёт. + const s1 = await t.ctx.agent.snapshot(); + const d1 = dist(heavy(s1).pos, s1.hero.pos); + await t.ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения + const s2 = await t.ctx.agent.snapshot(); + const d2 = dist(heavy(s2).pos, s2.hero.pos); + c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 }); + return null; + }); - await c.run('инварианты ИИ чисты', async () => { - const inv = await ctx.agent.invariants(); - const errors = (inv ?? []).filter((i) => i.severity === 'error'); - c.expect(errors.length === 0, 'инварианты нарушены', errors); - return null; - }); - } finally { - await ctx?.browser?.close().catch(() => {}); - await server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + await c.run('инварианты ИИ чисты', async () => { + await t.expectInvariantsClean('в ИИ-сценарии'); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/audio.mjs b/apps/game/tools/checks/audio.mjs index f4e004c..9188ae0 100644 --- a/apps/game/tools/checks/audio.mjs +++ b/apps/game/tools/checks/audio.mjs @@ -8,83 +8,63 @@ * 5) лог ограничен 24 записями. * Запуск: node tools/agent.mjs run tools/checks/audio.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; - -/** Прочитать DEV-лог аудио из страницы. */ -const readLog = (page) => page.evaluate(() => window.__gameAudioLog ?? null); - -/** - * Ждать условие на логе: декод WAV в headless занимает заметное время, - * поэтому после действия шагаем и опрашиваем лог, а не читаем его сразу. - */ -async function waitAudio({ page, agent }, pred, tries = 12) { - let log = await readLog(page); - for (let i = 0; i < tries && !pred(log); i++) { - await agent.step(30); - log = await readLog(page); - } - return log; -} +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('audio'); - const server = await startDevServer(); - let ctx; - try { - await c.run('новая игра: амбиент лугов запущен ровно один раз', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const log = await readLog(ctx.page); - c.expect(Array.isArray(log), 'DEV-лог аудио недоступен (прод-сборка?)', log); - const amb = log.filter((e) => e.key === 'ambience/meadows'); - c.expect(amb.length === 1, 'амбиент лугов должен стартовать ровно один раз', amb); - return null; - }); - await c.run('шаги: step_* в логе, боевых звуков нет', async () => { - await ctx.agent.command('scene:sleepAll'); - const before = (await readLog(ctx.page)).filter((e) => e.key.startsWith('sfx/step_')).length; - await ctx.agent.walkTo(16, 14, { timeoutTicks: 1500 }); - const log = await waitAudio( - ctx, - (l) => l.filter((e) => e.key.startsWith('sfx/step_')).length > before - ); - const steps = log.filter((e) => e.key.startsWith('sfx/step_')); - c.expect(steps.length > before, 'шагов в логе нет', { steps: steps.length }); - const combat = log.filter((e) => ['sfx/hurt', 'sfx/ash_die', 'sfx/spit'].includes(e.key)); - c.expect(combat.length === 0, 'боевые звуки без боя', combat); - return null; - }); - await c.run('атака: bell_hit в логе', async () => { - await ctx.agent.press('attack'); - const log = await waitAudio(ctx, (l) => l.some((e) => e.key === 'sfx/bell_hit')); - c.expect(log.some((e) => e.key === 'sfx/bell_hit'), 'атака не сыграла bell_hit', log); - return null; - }); - await c.run('guard амбиента: тот же ключ не перезапускается', async () => { - const count = () => - readLog(ctx.page).then((log) => log.filter((e) => e.key === 'ambience/meadows').length); - const before = await count(); - await ctx.page.evaluate(() => window.__game.playAmbience('ambience/meadows')); - await ctx.agent.step(60); - c.expect((await count()) === before, 'повторный playAmbience перезапустил трек', { - before, - after: await count() + return withChecks( + 'audio', + async (t) => { + const { c } = t; + await c.run('новая игра: амбиент лугов запущен ровно один раз', async () => { + await t.boot(); + const log = await t.readAudioLog(); + c.expect(Array.isArray(log), 'DEV-лог аудио недоступен (прод-сборка?)', log); + const amb = log.filter((e) => e.key === 'ambience/meadows'); + c.expect(amb.length === 1, 'амбиент лугов должен стартовать ровно один раз', amb); + return null; }); - return null; - }); - await c.run('лог ограничен 24 записями', async () => { - // Прогулка туда-обратно — шагов больше, чем вмещает кольцевой буфер. - for (const [x, y] of [[17, 14], [15, 14], [17, 14], [15, 14], [17, 14], [15, 14]]) { - await ctx.agent.walkTo(x, y, { timeoutTicks: 1500 }); - } - const log = await readLog(ctx.page); - c.expect(log.length <= 24, 'лог не обрезается до 24', log.length); - c.expect(log.length > 0, 'лог пуст после прогулки'); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + await c.run('шаги: step_* в логе, боевых звуков нет', async () => { + await t.sleepEnemies(); + const before = (await t.readAudioLog()).filter((e) => e.key.startsWith('sfx/step_')).length; + await t.ctx.agent.walkTo(16, 14, { timeoutTicks: 1500 }); + const log = await t.waitAudioLog( + (l) => l.filter((e) => e.key.startsWith('sfx/step_')).length > before + ); + const steps = log.filter((e) => e.key.startsWith('sfx/step_')); + c.expect(steps.length > before, 'шагов в логе нет', { steps: steps.length }); + const combat = log.filter((e) => ['sfx/hurt', 'sfx/ash_die', 'sfx/spit'].includes(e.key)); + c.expect(combat.length === 0, 'боевые звуки без боя', combat); + return null; + }); + await c.run('атака: bell_hit в логе', async () => { + await t.ctx.agent.press('attack'); + const log = await t.waitAudioLog((l) => l.some((e) => e.key === 'sfx/bell_hit')); + c.expect(log.some((e) => e.key === 'sfx/bell_hit'), 'атака не сыграла bell_hit', log); + return null; + }); + await c.run('guard амбиента: тот же ключ не перезапускается', async () => { + const count = async () => + (await t.readAudioLog()).filter((e) => e.key === 'ambience/meadows').length; + const before = await count(); + await t.ctx.page.evaluate(() => window.__game.playAmbience('ambience/meadows')); + await t.ctx.agent.step(60); + c.expect((await count()) === before, 'повторный playAmbience перезапустил трек', { + before, + after: await count() + }); + return null; + }); + await c.run('лог ограничен 24 записями', async () => { + // Прогулка туда-обратно — шагов больше, чем вмещает кольцевой буфер. + for (const [x, y] of [[17, 14], [15, 14], [17, 14], [15, 14], [17, 14], [15, 14]]) { + await t.ctx.agent.walkTo(x, y, { timeoutTicks: 1500 }); + } + const log = await t.readAudioLog(); + c.expect(log.length <= 24, 'лог не обрезается до 24', log.length); + c.expect(log.length > 0, 'лог пуст после прогулки'); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/collision.mjs b/apps/game/tools/checks/collision.mjs index 1d962be..ae5bc19 100644 --- a/apps/game/tools/checks/collision.mjs +++ b/apps/game/tools/checks/collision.mjs @@ -7,87 +7,85 @@ * 5) расталкивание тел: враг выталкивает себя из героя. * Запуск: node tools/agent.mjs run tools/checks/collision.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('collision'); - const server = await startDevServer(); - let ctx; - try { - await c.run('снапшот: слой collision валиден', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - const s = await ctx.agent.snapshot(); - const col = s.collision; - c.expect(col && col.width > 0 && col.height > 0, 'нет карты коллизий', col); - c.expect( - col.blocked.length === col.width * col.height, - 'blocked не накрывает карту', - { len: col.blocked.length, w: col.width, h: col.height } - ); - c.expect( - col.blocked.every((v) => v === 0 || v === 1), - 'blocked не бинарный', - col.blocked.filter((v) => v !== 0 && v !== 1) - ); - c.expect( - col.props.every((p) => p.w >= 1 && p.h >= 1), - 'проп без footprint', - col.props - ); - const idx = s.hero.tile.y * col.width + s.hero.tile.x; - c.expect(col.blocked[idx] === 0, 'герой заспавнился в блоке', s.hero.tile); - return null; - }); - await c.run('scene:walkable: вода и дерево непроходимы', async () => { - const walk = (x, y) => ctx.agent.command('scene:walkable', { x, y }); - const s = await ctx.agent.snapshot(); - const heroWalk = await walk(s.hero.tile.x, s.hero.tile.y); - c.expect(heroWalk === true, 'тайл героя непроходим', s.hero.tile); - c.expect((await walk(19, 8)) === false, 'вода проходима?'); - c.expect((await walk(0, 4)) === false, 'дерево проходимо?'); - return null; - }); - await c.run('scene:raycast: дерево перекрывает, вода прозрачна', async () => { - const ray = (from, to) => ctx.agent.command('scene:raycast', { from, to }); - // Вертикальный луч (3,8)->(3,10) задевает дерево (3,9). - c.expect((await ray({ x: 3, y: 8 }, { x: 3, y: 10 })) === false, 'дерево не перекрыло луч'); - // Берег-берег через пруд: вода прозрачна, луч чист. - c.expect((await ray({ x: 18, y: 8 }, { x: 24, y: 8 })) === true, 'вода перекрыла луч'); - return null; - }); - await c.run('клик в непроходимый тайл: герой остаётся в проходимом', async () => { - await ctx.agent.command('scene:sleepAll'); - // Ближе к дереву: маршрут от (2,3) не задевает спящих ползунов у тропы - // (сам (2,2) — тайл перехода на пруды, туда телепортировать нельзя). - await ctx.agent.command('scene:teleport', { x: 2, y: 3 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - // Клик по дереву (0,4): A* ведёт к проходимому соседу. - await ctx.agent.tapTile(0, 4); - const w = await ctx.agent.waitFor('s.hero && !s.hero.moving', { timeoutTicks: 1200 }); - c.expect(w.ok, 'герой не остановился после клика по дереву'); - const s = await ctx.agent.snapshot(); - const col = s.collision; - const idx = s.hero.tile.y * col.width + s.hero.tile.x; - c.expect(col.blocked[idx] === 0, 'герой стоит в блоке', s.hero.tile); - return null; - }); - await c.run('расталкивание: враг выталкивает себя из героя', async () => { - await ctx.agent.command('scene:sleepAll'); - // Ползун спит в (10.5,7.5); телепортируемся внутрь его тела. - await ctx.agent.command('scene:teleport', { x: 10, y: 7 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); - const s = await ctx.agent.snapshot(); - const en = (s.enemies ?? []).find( - (e) => !e.dead && Math.hypot(e.pos.x - 10.5, e.pos.y - 7.5) < 1.2 - ); - c.expect(!!en, 'ползун у тропы не найден', s.enemies); - const gap = Math.hypot(en.pos.x - s.hero.pos.x, en.pos.y - s.hero.pos.y); - c.expect(gap >= 0.4, 'враг остался внутри тела героя', { gap, en: en.pos, hero: s.hero.pos }); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + return withChecks( + 'collision', + async (t) => { + const { c } = t; + await c.run('снапшот: слой collision валиден', async () => { + await t.boot(); + const s = await t.ctx.agent.snapshot(); + const col = s.collision; + c.expect(col && col.width > 0 && col.height > 0, 'нет карты коллизий', col); + c.expect( + col.blocked.length === col.width * col.height, + 'blocked не накрывает карту', + { len: col.blocked.length, w: col.width, h: col.height } + ); + c.expect( + col.blocked.every((v) => v === 0 || v === 1), + 'blocked не бинарный', + col.blocked.filter((v) => v !== 0 && v !== 1) + ); + c.expect( + col.props.every((p) => p.w >= 1 && p.h >= 1), + 'проп без footprint', + col.props + ); + const idx = s.hero.tile.y * col.width + s.hero.tile.x; + c.expect(col.blocked[idx] === 0, 'герой заспавнился в блоке', s.hero.tile); + return null; + }); + await c.run('scene:walkable: вода и дерево непроходимы', async () => { + const walk = (x, y) => t.ctx.agent.command('scene:walkable', { x, y }); + const s = await t.ctx.agent.snapshot(); + const heroWalk = await walk(s.hero.tile.x, s.hero.tile.y); + c.expect(heroWalk === true, 'тайл героя непроходим', s.hero.tile); + c.expect((await walk(19, 8)) === false, 'вода проходима?'); + c.expect((await walk(0, 4)) === false, 'дерево проходимо?'); + return null; + }); + await c.run('scene:raycast: дерево перекрывает, вода прозрачна', async () => { + const ray = (from, to) => t.ctx.agent.command('scene:raycast', { from, to }); + // Вертикальный луч (3,8)->(3,10) задевает дерево (3,9). + c.expect((await ray({ x: 3, y: 8 }, { x: 3, y: 10 })) === false, 'дерево не перекрыло луч'); + // Берег-берег через пруд: вода прозрачна, луч чист. + c.expect((await ray({ x: 18, y: 8 }, { x: 24, y: 8 })) === true, 'вода перекрыла луч'); + return null; + }); + await c.run('клик в непроходимый тайл: герой остаётся в проходимом', async () => { + await t.sleepEnemies(); + // Ближе к дереву: маршрут от (2,3) не задевает спящих ползунов у тропы + // (сам (2,2) — тайл перехода на пруды, туда телепортировать нельзя). + await t.ctx.agent.command('scene:teleport', { x: 2, y: 3 }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + // Клик по дереву (0,4): A* ведёт к проходимому соседу. + await t.ctx.agent.tapTile(0, 4); + const w = await t.ctx.agent.waitFor('s.hero && !s.hero.moving', { timeoutTicks: 1200 }); + c.expect(w.ok, 'герой не остановился после клика по дереву'); + const s = await t.ctx.agent.snapshot(); + const col = s.collision; + const idx = s.hero.tile.y * col.width + s.hero.tile.x; + c.expect(col.blocked[idx] === 0, 'герой стоит в блоке', s.hero.tile); + return null; + }); + await c.run('расталкивание: враг выталкивает себя из героя', async () => { + await t.sleepEnemies(); + // Ползун спит в (10.5,7.5); телепортируемся внутрь его тела. + await t.ctx.agent.command('scene:teleport', { x: 10, y: 7 }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 60 }); + const s = await t.ctx.agent.snapshot(); + const en = (s.enemies ?? []).find( + (e) => !e.dead && Math.hypot(e.pos.x - 10.5, e.pos.y - 7.5) < 1.2 + ); + c.expect(!!en, 'ползун у тропы не найден', s.enemies); + const gap = Math.hypot(en.pos.x - s.hero.pos.x, en.pos.y - s.hero.pos.y); + c.expect(gap >= 0.4, 'враг остался внутри тела героя', { gap, en: en.pos, hero: s.hero.pos }); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/interact-world.mjs b/apps/game/tools/checks/interact-world.mjs index afec757..f9a90df 100644 --- a/apps/game/tools/checks/interact-world.mjs +++ b/apps/game/tools/checks/interact-world.mjs @@ -9,121 +9,108 @@ * 7) инвариант interact-used-consistent чист. * Запуск: node tools/agent.mjs run tools/checks/interact-world.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('interact-world'); - const server = await startDevServer(); - let ctx; - try { - await c.run('столб у тропы: тост с текстом знака', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.command('scene:sleepAll'); - // Столб (25,13) у тропы в Звенец — клик издалека: герой подойдёт сам. - await ctx.agent.tapTile(25, 13); - const w = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { - timeoutTicks: 1500 + return withChecks( + 'interact-world', + async (t) => { + const { c } = t; + await c.run('столб у тропы: тост с текстом знака', async () => { + await t.boot(); + await t.sleepEnemies(); + // Столб (25,13) у тропы в Звенец — клик издалека: герой подойдёт сам. + await t.ctx.agent.tapTile(25, 13); + const w = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("Звенец")', { + timeoutTicks: 1500 + }); + c.expect(w.ok, 'знак не показал текст', w.snapshot.lastToast); + return null; }); - c.expect(w.ok, 'знак не показал текст', w.snapshot.lastToast); - return null; - }); - await c.run('мот 1: предмет + вар + used', async () => { - await ctx.agent.tapTile(11, 14); - const w = await ctx.agent.waitFor( - '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', - { timeoutTicks: 1500 } - ); - c.expect(w.ok, 'подбор мота не дал предмет и вар', { - inventory: w.snapshot.inventory, - vars: w.snapshot.vars + await c.run('мот 1: предмет + вар + used', async () => { + await t.ctx.agent.tapTile(11, 14); + const w = await t.ctx.agent.waitFor( + '(s.inventory.find((i) => i.id === "mote")?.count ?? 0) === 1 && s.vars.motes === 1', + { timeoutTicks: 1500 } + ); + c.expect(w.ok, 'подбор мота не дал предмет и вар', { + inventory: w.snapshot.inventory, + vars: w.snapshot.vars + }); + const s = await t.ctx.agent.snapshot(); + const mote = (s.interactables ?? []).find((o) => o.id === 'mote_1'); + c.expect(mote?.used === true, 'мот не помечен used', mote); + return null; }); - const s = await ctx.agent.snapshot(); - const mote = (s.interactables ?? []).find((o) => o.id === 'mote_1'); - c.expect(mote?.used === true, 'мот не помечен used', mote); - return null; - }); - await c.run('моты 2 и 3: вар копится (addVar)', async () => { - await ctx.agent.tapTile(14, 20); - const w2 = await ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); - c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); - await ctx.agent.tapTile(14, 6); - const w3 = await ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); - c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); - return null; - }); - await c.run('знак наката у прудов', async () => { - // Тропа на пруды (2,2): step-переход; у берега — столб (3,2). - const w = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); - c.expect(w, 'не дошёл до тропы на пруды'); - await ctx.agent.waitFor('s.area === "ponds"', { timeoutTicks: 600 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(3, 2); - const ws = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { - timeoutTicks: 900 + await c.run('моты 2 и 3: вар копится (addVar)', async () => { + await t.ctx.agent.tapTile(14, 20); + const w2 = await t.ctx.agent.waitFor('s.vars.motes === 2', { timeoutTicks: 1500 }); + c.expect(w2.ok, 'второй мот не поднял вар до 2', w2.snapshot.vars); + await t.ctx.agent.tapTile(14, 6); + const w3 = await t.ctx.agent.waitFor('s.vars.motes === 3', { timeoutTicks: 1500 }); + c.expect(w3.ok, 'третий мот не поднял вар до 3', w3.snapshot.vars); + return null; }); - c.expect(ws.ok, 'знак наката не показал текст', ws.snapshot.lastToast); - // Обратно на луга (2,2) прудов. - const wb = await ctx.agent.walkTo(2, 2, { timeoutTicks: 3000 }); - c.expect(wb, 'не вернулся к тропе с прудов'); - await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 600 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return null; - }); - await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { - // Звенец через тропу (26,14); верёвка у башни (13,7) — клик издалека. - const w = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(w, 'не дошёл до тропы в Звенец'); - await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(13, 7); - const wr = await ctx.agent.waitFor('s.flags.includes("rang_tower")', { - timeoutTicks: 1500 + await c.run('знак наката у прудов', async () => { + // Тропа на пруды (2,2): step-переход; у берега — столб (3,2). + await t.goToArea('ponds', { x: 2, y: 2 }); + await t.ctx.agent.tapTile(3, 2); + const ws = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("накатом")', { + timeoutTicks: 900 + }); + c.expect(ws.ok, 'знак наката не показал текст', ws.snapshot.lastToast); + // Обратно на луга (2,2) прудов. + await t.goToArea('meadows', { x: 2, y: 2 }); + return null; }); - c.expect(wr.ok, 'звон не поднял rang_tower', wr.snapshot.flags); - const s1 = await ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); - await ctx.agent.tapTile(13, 7); - await ctx.agent.step(150); - const s2 = await ctx.agent.snapshot(); - const text2 = s2.lastToast?.text ?? ''; - c.expect( - text2.includes('снова') && text2 !== text1, - 'повторный звон не сменил текст', - text2 - ); - return null; - }); - await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { - // Первое знакомство (trader_first): полотно. - await ctx.agent.tapTile(17, 11); - const d1 = await ctx.agent.runDialogue(900); - c.expect(d1, 'первый диалог с Милой не завершился'); - // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт - // ветка о мота́х — листаем вручную и ловим её текст. - await ctx.agent.tapTile(17, 11); - const o = await ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 900 }); - c.expect(o.ok, 'повторный диалог не открылся'); - await ctx.agent.press('advance'); // догнать печать (typewriter) - await ctx.agent.press('advance'); // следующая реплика - const w = await ctx.agent.waitFor( - 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'в повторном диалоге нет ветки о мота́х', w.snapshot.dialogue); - const d2 = await ctx.agent.runDialogue(600); - c.expect(d2, 'повторный диалог не завершился'); - return null; - }); - await c.run('инвариант: used-флаги и снапшот согласованы', async () => { - const inv = await ctx.agent.invariants(); - const bad = inv.filter((i) => i.severity === 'error'); - c.expect(bad.length === 0, 'ошибки инвариантов', bad); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + await c.run('верёвка колокола: флаг + повтор с другим текстом', async () => { + // Звенец через тропу (26,14); верёвка у башни (13,7) — клик издалека. + await t.goToArea('zvenets', { x: 26, y: 14 }); + await t.ctx.agent.tapTile(13, 7); + const wr = await t.ctx.agent.waitFor('s.flags.includes("rang_tower")', { + timeoutTicks: 1500 + }); + c.expect(wr.ok, 'звон не поднял rang_tower', wr.snapshot.flags); + const s1 = await t.ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('ушёл в землю'), 'первый звон — не тот текст', text1); + await t.ctx.agent.tapTile(13, 7); + await t.ctx.agent.step(150); + const s2 = await t.ctx.agent.snapshot(); + const text2 = s2.lastToast?.text ?? ''; + c.expect( + text2.includes('снова') && text2 !== text1, + 'повторный звон не сменил текст', + text2 + ); + return null; + }); + await c.run('Мила: знакомство -> повтор с веткой по мотам', async () => { + // Первое знакомство (trader_first): полотно. + await t.ctx.agent.tapTile(17, 11); + const d1 = await t.ctx.agent.runDialogue(900); + c.expect(d1, 'первый диалог с Милой не завершился'); + // Повтор (trader_repeat): при моты >= 1 после первой реплики идёт + // ветка о мота́х — листаем вручную и ловим её текст. + await t.ctx.agent.tapTile(17, 11); + const o = await t.ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 900 }); + c.expect(o.ok, 'повторный диалог не открылся'); + await t.ctx.agent.press('advance'); // догнать печать (typewriter) + await t.ctx.agent.press('advance'); // следующая реплика + const w = await t.ctx.agent.waitFor( + 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'в повторном диалоге нет ветки о мота́х', w.snapshot.dialogue); + const d2 = await t.ctx.agent.runDialogue(600); + c.expect(d2, 'повторный диалог не завершился'); + return null; + }); + await c.run('инвариант: used-флаги и снапшот согласованы', async () => { + await t.expectInvariantsClean('после обхода мира'); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/interact.mjs b/apps/game/tools/checks/interact.mjs index 90335bc..d9fb1df 100644 --- a/apps/game/tools/checks/interact.mjs +++ b/apps/game/tools/checks/interact.mjs @@ -8,126 +8,121 @@ * 6) лавка Милы: прилавок меняет реакцию с флагом. * Запуск: node tools/agent.mjs run tools/checks/interact.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('interact'); - const server = await startDevServer(); - let ctx; - let entryTile = null; // тайл, с которого герой кликнул дверь (цель kind 'return') - try { - await c.run('дверь: дом Ирвина -> интерьер', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.command('scene:sleepAll'); - // Добираемся до Звенца пешком (луга -> тропа (26,14)). - const w0 = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(w0, 'walkTo(26,14) не дошёл'); - const wz = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - c.expect(wz.ok, 'не в Звенце после перехода', { area: wz.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - // Подводим героя к дому (тайл дома (7,6)) и кликаем по нему. - const wh = await ctx.agent.walkTo(8, 6, { timeoutTicks: 2000 }); - c.expect(wh, 'не дошёл до дома Ирвина'); - entryTile = (await ctx.agent.snapshot()).hero.tile; - await ctx.agent.tapTile(7, 6); - const w = await ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'клик по дому не открыл интерьер', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 5 && s.hero.tile.y === 7, 'герой не у проёма (5,7)', s.hero.tile); - return null; - }); - await c.run('сундук: предмет + used, повтор молчит', async () => { - const before = await ctx.agent.snapshot(); - const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; - // Клик по сундуку (3,3) — герой подходит и открывает. - await ctx.agent.tapTile(3, 3); - const w = await ctx.agent.waitFor( - `(s.inventory.find((i) => i.id === 'cloth')?.count ?? 0) === ${had + 1}`, - { timeoutTicks: 1200 } - ); - c.expect(w.ok, 'сундук не дал полотно', { inventory: w.snapshot.inventory }); - const s = await ctx.agent.snapshot(); - const chest = (s.interactables ?? []).find((o) => o.id === 'chest_elder'); - c.expect(chest?.used === true, 'сундук не помечен used', chest); - c.expect((s.flags ?? []).includes('used:chest_elder'), 'нет флага used:chest_elder'); - // Повтор: тик последнего тоста не меняется. - const t0 = s.lastToast?.tick ?? -1; - await ctx.agent.tapTile(3, 3); - await ctx.agent.step(120); - const s2 = await ctx.agent.snapshot(); - c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); - return null; - }); - await c.run('очаг: реакция меняется с флагом', async () => { - await ctx.agent.tapTile(8, 0); - await ctx.agent.step(120); - const s1 = await ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); - await ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); - await ctx.agent.tapTile(8, 0); - await ctx.agent.step(120); - const s2 = await ctx.agent.snapshot(); - c.expect( - (s2.lastToast?.text ?? '').includes('ровнее'), - 'реакция очага после флага не та', - s2.lastToast - ); - return null; - }); - await c.run('записка: флаг read_note', async () => { - await ctx.agent.tapTile(10, 4); - await ctx.agent.step(120); - const s = await ctx.agent.snapshot(); - c.expect((s.flags ?? []).includes('read_note'), 'записка не подняла read_note', s.flags); - return null; - }); - await c.run('выход через проём: возврат в Звенец', async () => { - // Клик по тайлу проёма (5,8) — герой доходит, step-переход kind 'return'. - await ctx.agent.tapTile(5, 8); - const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect( - s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, - 'герой не на тайле, откуда кликнул дверь', - { entry: entryTile, after: s.hero.tile } - ); - return null; - }); - await c.run('лавка Милы: прилавок меняет реакцию', async () => { - // Клик по лавке (21,9) — интерьер; прилавок (5,3). - await ctx.agent.tapTile(21, 9); - const w = await ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(5, 3); - await ctx.agent.step(120); - const s1 = await ctx.agent.snapshot(); - const text1 = s1.lastToast?.text ?? ''; - c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); - await ctx.agent.tapTile(5, 3); - await ctx.agent.step(120); - const s2 = await ctx.agent.snapshot(); - c.expect( - (s2.lastToast?.text ?? '').includes('цветы'), - 'прилавок не изменил реакцию после флага', - s2.lastToast - ); - return null; - }); - await c.run('снапшот: интерактивные объекты области', async () => { - const s = await ctx.agent.snapshot(); - const list = s.interactables ?? []; - c.expect(list.some((o) => o.id === 'counter_shop'), 'прилавка нет в снапшоте', list); - c.expect(list.every((o) => typeof o.used === 'boolean'), 'объект без поля used', list); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + // Тайл, с которого герой кликнул дверь (цель kind 'return'). + let entryTile = null; + return withChecks( + 'interact', + async (t) => { + const { c } = t; + await c.run('дверь: дом Ирвина -> интерьер', async () => { + await t.boot(); + await t.sleepEnemies(); + // Добираемся до Звенца пешком (луга -> тропа (26,14)). + await t.goToArea('zvenets', { x: 26, y: 14 }); + // Подводим героя к дому (тайл дома (7,6)) и кликаем по нему. + const wh = await t.ctx.agent.walkTo(8, 6, { timeoutTicks: 2000 }); + c.expect(wh, 'не дошёл до дома Ирвина'); + entryTile = (await t.ctx.agent.snapshot()).hero.tile; + await t.ctx.agent.tapTile(7, 6); + const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'клик по дому не открыл интерьер', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 5 && s.hero.tile.y === 7, 'герой не у проёма (5,7)', s.hero.tile); + return null; + }); + await c.run('сундук: предмет + used, повтор молчит', async () => { + const before = await t.ctx.agent.snapshot(); + const had = before.inventory.find((i) => i.id === 'cloth')?.count ?? 0; + // Клик по сундуку (3,3) — герой подходит и открывает. + await t.ctx.agent.tapTile(3, 3); + const w = await t.ctx.agent.waitFor( + `(s.inventory.find((i) => i.id === 'cloth')?.count ?? 0) === ${had + 1}`, + { timeoutTicks: 1200 } + ); + c.expect(w.ok, 'сундук не дал полотно', { inventory: w.snapshot.inventory }); + const s = await t.ctx.agent.snapshot(); + const chest = (s.interactables ?? []).find((o) => o.id === 'chest_elder'); + c.expect(chest?.used === true, 'сундук не помечен used', chest); + c.expect((s.flags ?? []).includes('used:chest_elder'), 'нет флага used:chest_elder'); + // Повтор: тик последнего тоста не меняется. + const t0 = s.lastToast?.tick ?? -1; + await t.ctx.agent.tapTile(3, 3); + await t.ctx.agent.step(120); + const s2 = await t.ctx.agent.snapshot(); + c.expect((s2.lastToast?.tick ?? -1) === t0, 'повторное взаимодействие не молчит', s2.lastToast); + return null; + }); + await c.run('очаг: реакция меняется с флагом', async () => { + await t.ctx.agent.tapTile(8, 0); + await t.ctx.agent.step(120); + const s1 = await t.ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('щепки'), 'реакция очага до флага не та', text1); + await t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); + await t.ctx.agent.tapTile(8, 0); + await t.ctx.agent.step(120); + const s2 = await t.ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('ровнее'), + 'реакция очага после флага не та', + s2.lastToast + ); + return null; + }); + await c.run('записка: флаг read_note', async () => { + await t.ctx.agent.tapTile(10, 4); + await t.ctx.agent.step(120); + const s = await t.ctx.agent.snapshot(); + c.expect((s.flags ?? []).includes('read_note'), 'записка не подняла read_note', s.flags); + return null; + }); + await c.run('выход через проём: возврат в Звенец', async () => { + // Клик по тайлу проёма (5,8) — герой доходит, step-переход kind 'return'. + await t.ctx.agent.tapTile(5, 8); + const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'проём не вернул в Звенец', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect( + s.hero.tile.x === entryTile.x && s.hero.tile.y === entryTile.y, + 'герой не на тайле, откуда кликнул дверь', + { entry: entryTile, after: s.hero.tile } + ); + return null; + }); + await c.run('лавка Милы: прилавок меняет реакцию', async () => { + // Клик по лавке (21,9) — интерьер; прилавок (5,3). + await t.ctx.agent.tapTile(21, 9); + const w = await t.ctx.agent.waitFor('s.area === "shop"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'лавка не открылась', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + await t.ctx.agent.tapTile(5, 3); + await t.ctx.agent.step(120); + const s1 = await t.ctx.agent.snapshot(); + const text1 = s1.lastToast?.text ?? ''; + c.expect(text1.includes('соль'), 'реакция прилавка до флага не та', text1); + await t.ctx.agent.tapTile(5, 3); + await t.ctx.agent.step(120); + const s2 = await t.ctx.agent.snapshot(); + c.expect( + (s2.lastToast?.text ?? '').includes('цветы'), + 'прилавок не изменил реакцию после флага', + s2.lastToast + ); + return null; + }); + await c.run('снапшот: интерактивные объекты области', async () => { + const s = await t.ctx.agent.snapshot(); + const list = s.interactables ?? []; + c.expect(list.some((o) => o.id === 'counter_shop'), 'прилавка нет в снапшоте', list); + c.expect(list.every((o) => typeof o.used === 'boolean'), 'объект без поля used', list); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/lighting.mjs b/apps/game/tools/checks/lighting.mjs index 5618d7b..bd45c86 100644 --- a/apps/game/tools/checks/lighting.mjs +++ b/apps/game/tools/checks/lighting.mjs @@ -14,7 +14,7 @@ * 8) инварианты чисты. * Запуск: node tools/agent.mjs run tools/checks/lighting.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; /** Ambient областей (те же константы, что в data/locations.ts). */ const AMB = { @@ -33,106 +33,98 @@ } export default async function ({ pretty }) { - const c = new Checks('lighting'); - const server = await startDevServer(); - let ctx; - try { - await c.run('луга в полдень: дневной ambient, источников нет', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.command('scene:setTime', { hours: 12 }); - const w = await ctx.agent.waitFor( - `s.lighting.ambient === ${AMB.meadows} && s.lighting.sources.length === 0`, - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'днём луга не светлы или есть источники', w.snapshot?.lighting); - c.expect(w.snapshot.time.night === 0, 'в полдень фактор ночи не нулевой', w.snapshot.time); - return null; - }); - await c.run('ночь на лугах (21:00): ночная цель ambient, лампа героя', async () => { - await ctx.agent.command('scene:setTime', { hours: 21 }); - const w = await ctx.agent.waitFor( - `s.lighting.ambient === ${AMB.meadowsNight} && s.lighting.sources.some((l) => l.id === "lamp")`, - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'ночью луга не потемнели или лампа не зажглась', w.snapshot?.lighting); - c.expect(w.snapshot.time.night === 1, 'в 21:00 фактор ночи не единичный', w.snapshot.time); - return null; - }); - await c.run('Звенец ночью: ночная цель, три окна, башня молчит', async () => { - await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - c.expect(w.ok, 'не пришли в Звенец'); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.command('scene:setTime', { hours: 21 }); // пин: ходьба ест время - const s = await ctx.agent.snapshot(); - c.expect(s.lighting.ambient === AMB.zvenetsNight, 'ambient Звенца не ночной', s.lighting); - const windows = s.lighting.sources.filter((l) => l.id.startsWith('window_')); - c.expect(windows.length === 3, 'ночью должно гореть три окна домов', s.lighting.sources); - c.expect(!s.lighting.sources.some((l) => l.id === 'tower_resonance'), 'башня светит без звонка', s.lighting.sources); - c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'ночью на улице нет лампы героя', s.lighting.sources); - return null; - }); - await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => { - await ctx.agent.tapTile(7, 6); // дверь дома — клик-переход - const w = await ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'не вошли в дом Ирвина'); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.lighting.ambient === AMB.house_elder, 'ambient интерьера не тёмный', s.lighting); - c.expect(s.lighting.sources.some((l) => l.id === 'hearth_low'), 'нет тлеющего очага', s.lighting.sources); - c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_high'), 'очаг горит ровно до сдачи квеста', s.lighting.sources); - c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'в тёмном доме нет лампы героя', s.lighting.sources); - // Мерцание: серия снапшотов — интенсивность очага гуляет (с допуском). - const seen = new Set(); - for (let i = 0; i < 8; i++) { - const t = await ctx.agent.snapshot(); - const h = t.lighting.sources.find((l) => l.id === 'hearth_low'); - if (h) seen.add(Math.round(h.intensity * 100)); - await ctx.agent.step(6); - } - c.expect(seen.size >= 3, `мерцание очага не видно (вариантов ${seen.size})`, [...seen]); - return null; - }); - await c.run('лампа героя: следует за телепортом', async () => { - const v1 = lightVec(await ctx.agent.snapshot(), 'hearth_low', 'lamp'); - await ctx.agent.command('scene:teleport', { x: 2, y: 2 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); - const v2 = lightVec(await ctx.agent.snapshot(), 'hearth_low', 'lamp'); - c.expect(v1 !== null && v2 !== null, 'нет лампы или очага в снапшоте', { v1, v2 }); - // Очаг статичен: v2 - v1 — чистое смещение лампы (камера двигает оба поровну). - const shift = Math.hypot(v2.x - v1.x, v2.y - v1.y); - c.expect(shift > 60, `лампа не переехала за героем (смещение ${shift.toFixed(0)} px)`, { v1, v2 }); - return null; - }); - await c.run('сдача квеста: hearth_high вместо hearth_low и ярче', async () => { - await ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); - const w = await ctx.agent.waitFor('s.lighting.sources.some((l) => l.id === "hearth_high")', { - timeoutTicks: 300 + return withChecks( + 'lighting', + async (t) => { + const { c } = t; + await c.run('луга в полдень: дневной ambient, источников нет', async () => { + await t.boot(); + await t.ctx.agent.command('scene:setTime', { hours: 12 }); + const w = await t.ctx.agent.waitFor( + `s.lighting.ambient === ${AMB.meadows} && s.lighting.sources.length === 0`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'днём луга не светлы или есть источники', w.snapshot?.lighting); + c.expect(w.snapshot.time.night === 0, 'в полдень фактор ночи не нулевой', w.snapshot.time); + return null; }); - c.expect(w.ok, 'после сдачи квеста очаг не разгорелся', w.snapshot.lighting.sources); - const s = await ctx.agent.snapshot(); - c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_low'), 'тлеющий очаг не погас', s.lighting.sources); - const high = s.lighting.sources.find((l) => l.id === 'hearth_high'); - // Минимум hearth_high (1.05·0.8) выше максимума hearth_low (0.75). - c.expect(high.intensity > 0.84 - 0.02, 'hearth_high не ярче тлеющего', high); - return null; - }); - await c.run('виньетка: урон до hp 2 — края экрана темнеют', async () => { - await ctx.agent.command('scene:damagePlayer', { value: 3 }); - const w = await ctx.agent.waitFor('s.lighting.vignette > 0.29', { timeoutTicks: 300 }); - c.expect(w.ok, 'виньетка не загорелась при hp 2', w.snapshot?.lighting); - return null; - }); - await c.run('инварианты чисты при работающем свете', async () => { - const inv = await ctx.agent.invariants(); - const bad = inv.filter((i) => i.severity === 'error'); - c.expect(bad.length === 0, 'ошибки инвариантов', bad); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + await c.run('ночь на лугах (21:00): ночная цель ambient, лампа героя', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 21 }); + const w = await t.ctx.agent.waitFor( + `s.lighting.ambient === ${AMB.meadowsNight} && s.lighting.sources.some((l) => l.id === "lamp")`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'ночью луга не потемнели или лампа не зажглась', w.snapshot?.lighting); + c.expect(w.snapshot.time.night === 1, 'в 21:00 фактор ночи не единичный', w.snapshot.time); + return null; + }); + await c.run('Звенец ночью: ночная цель, три окна, башня молчит', async () => { + await t.goToArea('zvenets', { x: 26, y: 14 }); + await t.ctx.agent.command('scene:setTime', { hours: 21 }); // пин: ходьба ест время + const s = await t.ctx.agent.snapshot(); + c.expect(s.lighting.ambient === AMB.zvenetsNight, 'ambient Звенца не ночной', s.lighting); + const windows = s.lighting.sources.filter((l) => l.id.startsWith('window_')); + c.expect(windows.length === 3, 'ночью должно гореть три окна домов', s.lighting.sources); + c.expect(!s.lighting.sources.some((l) => l.id === 'tower_resonance'), 'башня светит без звонка', s.lighting.sources); + c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'ночью на улице нет лампы героя', s.lighting.sources); + return null; + }); + await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => { + await t.ctx.agent.tapTile(7, 6); // дверь дома — клик-переход + const w = await t.ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'не вошли в дом Ирвина'); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.lighting.ambient === AMB.house_elder, 'ambient интерьера не тёмный', s.lighting); + c.expect(s.lighting.sources.some((l) => l.id === 'hearth_low'), 'нет тлеющего очага', s.lighting.sources); + c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_high'), 'очаг горит ровно до сдачи квеста', s.lighting.sources); + c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'в тёмном доме нет лампы героя', s.lighting.sources); + // Мерцание: серия снапшотов — интенсивность очага гуляет (с допуском). + const seen = new Set(); + for (let i = 0; i < 8; i++) { + const shot = await t.ctx.agent.snapshot(); + const h = shot.lighting.sources.find((l) => l.id === 'hearth_low'); + if (h) seen.add(Math.round(h.intensity * 100)); + await t.ctx.agent.step(6); + } + c.expect(seen.size >= 3, `мерцание очага не видно (вариантов ${seen.size})`, [...seen]); + return null; + }); + await c.run('лампа героя: следует за телепортом', async () => { + const v1 = lightVec(await t.ctx.agent.snapshot(), 'hearth_low', 'lamp'); + await t.ctx.agent.command('scene:teleport', { x: 2, y: 2 }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + const v2 = lightVec(await t.ctx.agent.snapshot(), 'hearth_low', 'lamp'); + c.expect(v1 !== null && v2 !== null, 'нет лампы или очага в снапшоте', { v1, v2 }); + // Очаг статичен: v2 - v1 — чистое смещение лампы (камера двигает оба поровну). + const shift = Math.hypot(v2.x - v1.x, v2.y - v1.y); + c.expect(shift > 60, `лампа не переехала за героем (смещение ${shift.toFixed(0)} px)`, { v1, v2 }); + return null; + }); + await c.run('сдача квеста: hearth_high вместо hearth_low и ярче', async () => { + await t.ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); + const w = await t.ctx.agent.waitFor('s.lighting.sources.some((l) => l.id === "hearth_high")', { + timeoutTicks: 300 + }); + c.expect(w.ok, 'после сдачи квеста очаг не разгорелся', w.snapshot.lighting.sources); + const s = await t.ctx.agent.snapshot(); + c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_low'), 'тлеющий очаг не погас', s.lighting.sources); + const high = s.lighting.sources.find((l) => l.id === 'hearth_high'); + // Минимум hearth_high (1.05·0.8) выше максимума hearth_low (0.75). + c.expect(high.intensity > 0.84 - 0.02, 'hearth_high не ярче тлеющего', high); + return null; + }); + await c.run('виньетка: урон до hp 2 — края экрана темнеют', async () => { + await t.ctx.agent.command('scene:damagePlayer', { value: 3 }); + const w = await t.ctx.agent.waitFor('s.lighting.vignette > 0.29', { timeoutTicks: 300 }); + c.expect(w.ok, 'виньетка не загорелась при hp 2', w.snapshot?.lighting); + return null; + }); + await c.run('инварианты чисты при работающем свете', async () => { + await t.expectInvariantsClean('при работающем свете'); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/music.mjs b/apps/game/tools/checks/music.mjs index b923737..320700b 100644 --- a/apps/game/tools/checks/music.mjs +++ b/apps/game/tools/checks/music.mjs @@ -9,79 +9,66 @@ * и тема боя (погоня тяжеловеса у тропы — его аггро-радиус на пути). * Запуск: node tools/agent.mjs run tools/checks/music.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; - -const readLog = (page) => page.evaluate(() => window.__gameAudioLog ?? null); - -/** Подождать появления ключа в логе (компиляция темы занимает кадры). */ -async function waitKey({ page, agent }, key, tries = 12) { - for (let i = 0; i < tries; i++) { - if ((await readLog(page)).some((e) => e.key === key)) return true; - await agent.step(30); - } - return false; -} +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('music'); - const server = await startDevServer(); - let ctx; - try { - await c.run('новая игра в лугах: тема скомпилирована и играет', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - c.expect( - await waitKey(ctx, 'music/meadows'), - 'темы лугов нет в аудио-логе (компиляция не удалась?)', - await readLog(ctx.page) - ); - c.expect( - (await readLog(ctx.page)).some((e) => e.key === 'ambience/meadows'), - 'амбиент лугов пропал при старте темы' - ); - return null; - }); - await c.run('возврат в ту же область: тема не перезапускается (guard)', async () => { - const count = () => - readLog(ctx.page).then((log) => log.filter((e) => e.key === 'music/meadows').length); - const before = await count(); - // Шагнуть туда-обратно мимо переходов, сцена не меняется — guard ключа. - await ctx.agent.walkTo(15, 14, { timeoutTicks: 1500 }); - await ctx.agent.walkTo(14, 14, { timeoutTicks: 1500 }); - c.expect((await count()) === before, 'тема перезапустилась без смены ключа', { - before, - after: await count() - }); - return null; - }); - await c.run('уход в Звенец: слот темы снят, тема лугов не перезапускается', async () => { - const meadowsCount = () => - readLog(ctx.page).then((log) => log.filter((e) => e.key === 'music/meadows').length); - const before = await meadowsCount(); - // Пешком к тропе в Звенец (26,14) — честный step-переход. - const walked = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(walked, 'walkTo(26,14) не дошёл до перехода'); + return withChecks( + 'music', + async (t) => { + const { c } = t; + /** Сколько раз ключ встречается в логе (null-лог → 0). */ + const countKey = async (key) => + ((await t.readAudioLog()) ?? []).filter((e) => e.key === key).length; + /** Слот темы страницы (window.__game.themeKey) или null. */ const themeKey = () => - ctx.page.evaluate(() => { + t.ctx.page.evaluate(() => { const g = window.__game; return g ? g.themeKey : null; }); - // Слот темы освобождается (тема Звенца пуста; тема боя погони — - // временный ключ, ждём его снятия после деаггро). - for (let i = 0; i < 12 && (await themeKey()) !== ''; i++) { - await ctx.agent.step(30); - } - c.expect((await themeKey()) === '', 'слот темы не снят при уходе из лугов'); - c.expect( - (await meadowsCount()) === before, - 'тема лугов перезапустилась (guard ключа сломан)', - { before, after: await meadowsCount() } - ); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + await c.run('новая игра в лугах: тема скомпилирована и играет', async () => { + await t.boot(); + const log = await t.waitAudioLog((l) => l.some((e) => e.key === 'music/meadows')); + c.expect( + log.some((e) => e.key === 'music/meadows'), + 'темы лугов нет в аудио-логе (компиляция не удалась?)', + log + ); + c.expect( + log.some((e) => e.key === 'ambience/meadows'), + 'амбиент лугов пропал при старте темы' + ); + return null; + }); + await c.run('возврат в ту же область: тема не перезапускается (guard)', async () => { + const before = await countKey('music/meadows'); + // Шагнуть туда-обратно мимо переходов, сцена не меняется — guard ключа. + await t.ctx.agent.walkTo(15, 14, { timeoutTicks: 1500 }); + await t.ctx.agent.walkTo(14, 14, { timeoutTicks: 1500 }); + c.expect((await countKey('music/meadows')) === before, 'тема перезапустилась без смены ключа', { + before, + after: await countKey('music/meadows') + }); + return null; + }); + await c.run('уход в Звенец: слот темы снят, тема лугов не перезапускается', async () => { + const before = await countKey('music/meadows'); + // Пешком к тропе в Звенец (26,14) — честный step-переход. + const walked = await t.ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + c.expect(walked, 'walkTo(26,14) не дошёл до перехода'); + // Слот темы освобождается (тема Звенца пуста; тема боя погони — + // временный ключ, ждём его снятия после деаггро). + for (let i = 0; i < 12 && (await themeKey()) !== ''; i++) { + await t.ctx.agent.step(30); + } + c.expect((await themeKey()) === '', 'слот темы не снят при уходе из лугов'); + c.expect( + (await countKey('music/meadows')) === before, + 'тема лугов перезапустилась (guard ключа сломан)', + { before, after: await countKey('music/meadows') } + ); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/quest-bells.mjs b/apps/game/tools/checks/quest-bells.mjs index 3ed37d8..60382ed 100644 --- a/apps/game/tools/checks/quest-bells.mjs +++ b/apps/game/tools/checks/quest-bells.mjs @@ -10,98 +10,90 @@ * 4) инвариант interact-used-consistent и контент чисты. * Запуск: node tools/agent.mjs run tools/checks/quest-bells.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; /** Реплика героя в elder_first (выбор по тексту — не по индексу). */ const RING_REPLY = 'Прозвоню дорогу до прудов и вернусь до темноты.'; export default async function ({ pretty }) { - const c = new Checks('quest-bells'); - const server = await startDevServer(); - let ctx; - try { - await c.run('Ирвин: elder_first, выбор по тексту, флаги квеста', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.command('scene:sleepAll'); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - // NPC в Звенце: тропа на востоке (26,14), затем Ирвин (12,9). - const walk = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(walk, 'не дошёл до тропы в Звенец'); - await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(12, 9); // Ирвин — клик издалека - const o = await ctx.agent.waitFor('s.dialogue?.id === "elder_first"', { timeoutTicks: 900 }); - c.expect(o.ok, 'диалог elder_first не открылся', o.snapshot.dialogue); - // Листаем до выбора: advance при выборе активирует курсор — проверяем снапшот. - for (let i = 0; i < 12; i++) { - const s = await ctx.agent.snapshot(); - if (s.dialogue?.waitingForChoice) break; - if (!s.dialogue) break; - await ctx.agent.press('advance'); // догнать печать или следующая реплика - } - const picked = await ctx.agent.pickChoiceByText(RING_REPLY); - c.expect(picked, 'вариант по тексту не найден', (await ctx.agent.snapshot()).dialogue); - const d = await ctx.agent.runDialogue(900); - c.expect(d, 'диалог не завершился после выбора'); - const w = await ctx.agent.waitFor( - 's.flags.includes("met_elder") && s.flags.includes("quest_bells_taken")', - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); - return null; - }); - await c.run('сдача: elder_hand_in, кат-сцена посадки, поляна зеленеет', async () => { - // Чит-цветы: сам сбор (пруды) покрыт клик-роутингом, здесь — сдача. - await ctx.agent.command('scene:setVar', { id: 'flowers', value: 3 }); - await ctx.agent.tapTile(12, 9); - const o = await ctx.agent.waitFor('s.dialogue?.id === "elder_hand_in"', { timeoutTicks: 900 }); - c.expect(o.ok, 'квест-стадия не дала elder_hand_in', o.snapshot.dialogue); - const d = await ctx.agent.runDialogue(900); - c.expect(d, 'диалог сдачи не завершился'); - const w = await ctx.agent.waitFor( - 's.flags.includes("quest_bells_done") && s.vars.flowers === 0', - { timeoutTicks: 900 } - ); - c.expect(w.ok, 'нет флага сдачи или цветы не списаны', { - flags: w.snapshot.flags, - vars: w.snapshot.vars + return withChecks( + 'quest-bells', + async (t) => { + const { c } = t; + await c.run('Ирвин: elder_first, выбор по тексту, флаги квеста', async () => { + await t.boot(); + await t.sleepEnemies(); + // NPC в Звенце: тропа на востоке (26,14), затем Ирвин (12,9). + await t.goToArea('zvenets', { x: 26, y: 14 }); + await t.ctx.agent.tapTile(12, 9); // Ирвин — клик издалека + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_first"', { timeoutTicks: 900 }); + c.expect(o.ok, 'диалог elder_first не открылся', o.snapshot.dialogue); + // Листаем до выбора: advance при выборе активирует курсор — проверяем снапшот. + for (let i = 0; i < 12; i++) { + const s = await t.ctx.agent.snapshot(); + if (s.dialogue?.waitingForChoice) break; + if (!s.dialogue) break; + await t.ctx.agent.press('advance'); // догнать печать или следующая реплика + } + const picked = await t.ctx.agent.pickChoiceByText(RING_REPLY); + c.expect(picked, 'вариант по тексту не найден', (await t.ctx.agent.snapshot()).dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог не завершился после выбора'); + const w = await t.ctx.agent.waitFor( + 's.flags.includes("met_elder") && s.flags.includes("quest_bells_taken")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'граф не поднял флаги знакомства и взятия квеста', w.snapshot.flags); + return null; }); - // Кат-сцена посадки: тост и конец (клики блокируются до её конца). - const t = await ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("гудит")', { - timeoutTicks: 900 + await c.run('сдача: elder_hand_in, кат-сцена посадки, поляна зеленеет', async () => { + // Чит-цветы: сам сбор (пруды) покрыт клик-роутингом, здесь — сдача. + await t.ctx.agent.command('scene:setVar', { id: 'flowers', value: 3 }); + await t.ctx.agent.tapTile(12, 9); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "elder_hand_in"', { timeoutTicks: 900 }); + c.expect(o.ok, 'квест-стадия не дала elder_hand_in', o.snapshot.dialogue); + const d = await t.ctx.agent.runDialogue(900); + c.expect(d, 'диалог сдачи не завершился'); + const w = await t.ctx.agent.waitFor( + 's.flags.includes("quest_bells_done") && s.vars.flowers === 0', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'нет флага сдачи или цветы не списаны', { + flags: w.snapshot.flags, + vars: w.snapshot.vars + }); + // Кат-сцена посадки: тост и конец (клики блокируются до её конца). + const toast = await t.ctx.agent.waitFor('(s.lastToast?.text ?? "").includes("гудит")', { + timeoutTicks: 900 + }); + c.expect(toast.ok, 'нет тоста посадки', toast.snapshot.lastToast); + await t.ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); + return null; }); - c.expect(t.ok, 'нет тоста посадки', t.snapshot.lastToast); - await ctx.agent.waitFor('!s.cutscene?.active', { timeoutTicks: 900 }); - return null; - }); - await c.run('Мила: trader_first (полотно) -> trader_after (эпилог)', async () => { - await ctx.agent.tapTile(17, 11); - const o = await ctx.agent.waitFor('s.dialogue?.id === "trader_first"', { timeoutTicks: 900 }); - c.expect(o.ok, 'первый диалог Милы не открылся', o.snapshot.dialogue); - const d1 = await ctx.agent.runDialogue(900); - c.expect(d1, 'первый диалог Милы не завершился'); - const inv = await ctx.agent.waitFor( - '(s.inventory.find((i) => i.id === "cloth")?.count ?? 0) === 1', - { timeoutTicks: 300 } - ); - c.expect(inv.ok, 'полотно (do[] giveItem) не в сумке', inv.snapshot.inventory); - // Повторный разговор: квест-стадия-эпилог -> trader_after. - await ctx.agent.tapTile(17, 11); - const o2 = await ctx.agent.waitFor('s.dialogue?.id === "trader_after"', { timeoutTicks: 900 }); - c.expect(o2.ok, 'эпилог trader_after не открылся', o2.snapshot.dialogue); - const d2 = await ctx.agent.runDialogue(600); - c.expect(d2, 'эпилог не завершился'); - return null; - }); - await c.run('инварианты чисты после полного квеста', async () => { - const inv = await ctx.agent.invariants(); - const bad = inv.filter((i) => i.severity === 'error'); - c.expect(bad.length === 0, 'ошибки инвариантов', bad); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + await c.run('Мила: trader_first (полотно) -> trader_after (эпилог)', async () => { + await t.ctx.agent.tapTile(17, 11); + const o = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_first"', { timeoutTicks: 900 }); + c.expect(o.ok, 'первый диалог Милы не открылся', o.snapshot.dialogue); + const d1 = await t.ctx.agent.runDialogue(900); + c.expect(d1, 'первый диалог Милы не завершился'); + const inv = await t.ctx.agent.waitFor( + '(s.inventory.find((i) => i.id === "cloth")?.count ?? 0) === 1', + { timeoutTicks: 300 } + ); + c.expect(inv.ok, 'полотно (do[] giveItem) не в сумке', inv.snapshot.inventory); + // Повторный разговор: квест-стадия-эпилог -> trader_after. + await t.ctx.agent.tapTile(17, 11); + const o2 = await t.ctx.agent.waitFor('s.dialogue?.id === "trader_after"', { timeoutTicks: 900 }); + c.expect(o2.ok, 'эпилог trader_after не открылся', o2.snapshot.dialogue); + const d2 = await t.ctx.agent.runDialogue(600); + c.expect(d2, 'эпилог не завершился'); + return null; + }); + await c.run('инварианты чисты после полного квеста', async () => { + await t.expectInvariantsClean('после полного квеста'); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/synth.mjs b/apps/game/tools/checks/synth.mjs index 51bf6a3..d927aff 100644 --- a/apps/game/tools/checks/synth.mjs +++ b/apps/game/tools/checks/synth.mjs @@ -8,93 +8,88 @@ * 5) гул (hum) не глушит амбиент области (разные голоса). * Запуск: node tools/agent.mjs run tools/checks/synth.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; - -/** Прочитать DEV-лог аудио из страницы. */ -const readLog = (page) => page.evaluate(() => window.__gameAudioLog ?? null); - -/** Команда моста с ожиданием появления ключа в логе (синтез асинхронный). */ -async function synthAndWait({ page, agent }, spec, { key = 'agent/synth', volume, tries = 12 } = {}) { - const before = (await readLog(page)).filter((e) => e.key === key).length; - const res = await agent.command('scene:synthesize', { spec, key, ...(volume !== undefined ? { volume } : {}) }); - for (let i = 0; i < tries; i++) { - const log = await readLog(page); - if (log.filter((e) => e.key === key).length > before) return { res, log }; - await agent.step(30); - } - return { res, log: await readLog(page) }; -} +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('synth'); - const server = await startDevServer(); - let ctx; - try { - await c.run('новая игра: спек-синтез недоступен в прод-сборке с внятной ошибкой', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - c.expect(Array.isArray(await readLog(ctx.page)), 'DEV-лог аудио недоступен (прод-сборка?)'); - return null; - }); - await c.run('удар по спеку: запуск в логе с заданным ключом и громкостью', async () => { - const { res, log } = await synthAndWait( - ctx, - { kind: 'hit', dur: 0.3, low: 600, high: 3000, seed: 7 }, - { key: 'agent/hit-test', volume: 0.5 } - ); - c.expect(res === true, 'scene:synthesize не принят мостом', res); - const hits = log.filter((e) => e.key === 'agent/hit-test'); - c.expect(hits.length > 0, 'удар по спеку не сыграл', log); - c.expect(Math.abs(hits.at(-1).volume - 0.5) < 0.01, 'громкость не дошла до голоса', hits.at(-1)); - return null; - }); - await c.run('звон по спеку: другой ключ играет параллельно', async () => { - const { res, log } = await synthAndWait( - ctx, - { kind: 'chime', dur: 0.6, freq: 880 }, - { key: 'agent/chime-test' } - ); - c.expect(res === true, 'scene:synthesize не принят мостом', res); - c.expect(log.some((e) => e.key === 'agent/chime-test'), 'звон по спеку не сыграл', log); - c.expect( - log.some((e) => e.key === 'agent/hit-test'), - 'предыдущий голос пропал из лога (лог мал?)', - log - ); - return null; - }); - await c.run('детерминизм: тот же спек дважды — оба запуска в логе', async () => { - const spec = { kind: 'scrape', dur: 0.5, low: 80, high: 400, tone: 60, seed: 3 }; - const first = await synthAndWait(ctx, spec, { key: 'agent/scrape-test' }); - const second = await synthAndWait(ctx, spec, { key: 'agent/scrape-test' }); - c.expect(first.res === true && second.res === true, 'команда не принята', [first.res, second.res]); - const plays = second.log.filter((e) => e.key === 'agent/scrape-test'); - c.expect(plays.length >= 2, 'повторный синтез не сыграл', plays); - return null; - }); - await c.run('негодный спек: без dur и с неизвестным kind — null', async () => { - const noDur = await ctx.agent.command('scene:synthesize', { spec: { kind: 'hit' } }); - const badKind = await ctx.agent.command('scene:synthesize', { spec: { kind: 'thunder', dur: 1 } }); - const noSpec = await ctx.agent.command('scene:synthesize', {}); - c.expect(noDur === null, 'спек без dur должен вернуть null', noDur); - c.expect(badKind === null, 'неизвестный kind должен вернуть null', badKind); - c.expect(noSpec === null, 'вызов без спека должен вернуть null', noSpec); - return null; - }); - await c.run('гул (hum) не глушит амбиент области', async () => { - const { res, log } = await synthAndWait( - ctx, - { kind: 'hum', dur: 2, tone: 55, loop: true }, - { key: 'agent/hum-test' } - ); - c.expect(res === true, 'scene:synthesize не принят мостом', res); - c.expect(log.some((e) => e.key === 'agent/hum-test'), 'гул по спеку не сыграл', log); - c.expect(log.some((e) => e.key === 'ambience/meadows'), 'амбиент области пропал', log); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + return withChecks( + 'synth', + async (t) => { + const { c } = t; + /** + * Команда моста с ожиданием появления ключа в логе + * (синтез асинхронный): считаем запуски ключа до и после. + */ + const synthAndWait = async (spec, { key = 'agent/synth', volume, tries = 12 } = {}) => { + const count = async () => + (await t.readAudioLog()).filter((e) => e.key === key).length; + const before = await count(); + const res = await t.ctx.agent.command('scene:synthesize', { + spec, + key, + ...(volume !== undefined ? { volume } : {}) + }); + const log = await t.waitAudioLog((l) => l.filter((e) => e.key === key).length > before, tries); + return { res, log }; + }; + await c.run('новая игра: спек-синтез недоступен в прод-сборке с внятной ошибкой', async () => { + await t.boot(); + c.expect(Array.isArray(await t.readAudioLog()), 'DEV-лог аудио недоступен (прод-сборка?)'); + return null; + }); + await c.run('удар по спеку: запуск в логе с заданным ключом и громкостью', async () => { + const { res, log } = await synthAndWait( + { kind: 'hit', dur: 0.3, low: 600, high: 3000, seed: 7 }, + { key: 'agent/hit-test', volume: 0.5 } + ); + c.expect(res === true, 'scene:synthesize не принят мостом', res); + const hits = log.filter((e) => e.key === 'agent/hit-test'); + c.expect(hits.length > 0, 'удар по спеку не сыграл', log); + c.expect(Math.abs(hits.at(-1).volume - 0.5) < 0.01, 'громкость не дошла до голоса', hits.at(-1)); + return null; + }); + await c.run('звон по спеку: другой ключ играет параллельно', async () => { + const { res, log } = await synthAndWait( + { kind: 'chime', dur: 0.6, freq: 880 }, + { key: 'agent/chime-test' } + ); + c.expect(res === true, 'scene:synthesize не принят мостом', res); + c.expect(log.some((e) => e.key === 'agent/chime-test'), 'звон по спеку не сыграл', log); + c.expect( + log.some((e) => e.key === 'agent/hit-test'), + 'предыдущий голос пропал из лога (лог мал?)', + log + ); + return null; + }); + await c.run('детерминизм: тот же спек дважды — оба запуска в логе', async () => { + const spec = { kind: 'scrape', dur: 0.5, low: 80, high: 400, tone: 60, seed: 3 }; + const first = await synthAndWait(spec, { key: 'agent/scrape-test' }); + const second = await synthAndWait(spec, { key: 'agent/scrape-test' }); + c.expect(first.res === true && second.res === true, 'команда не принята', [first.res, second.res]); + const plays = second.log.filter((e) => e.key === 'agent/scrape-test'); + c.expect(plays.length >= 2, 'повторный синтез не сыграл', plays); + return null; + }); + await c.run('негодный спек: без dur и с неизвестным kind — null', async () => { + const noDur = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'hit' } }); + const badKind = await t.ctx.agent.command('scene:synthesize', { spec: { kind: 'thunder', dur: 1 } }); + const noSpec = await t.ctx.agent.command('scene:synthesize', {}); + c.expect(noDur === null, 'спек без dur должен вернуть null', noDur); + c.expect(badKind === null, 'неизвестный kind должен вернуть null', badKind); + c.expect(noSpec === null, 'вызов без спека должен вернуть null', noSpec); + return null; + }); + await c.run('гул (hum) не глушит амбиент области', async () => { + const { res, log } = await synthAndWait( + { kind: 'hum', dur: 2, tone: 55, loop: true }, + { key: 'agent/hum-test' } + ); + c.expect(res === true, 'scene:synthesize не принят мостом', res); + c.expect(log.some((e) => e.key === 'agent/hum-test'), 'гул по спеку не сыграл', log); + c.expect(log.some((e) => e.key === 'ambience/meadows'), 'амбиент области пропал', log); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/time.mjs b/apps/game/tools/checks/time.mjs index 09184a7..effaa36 100644 --- a/apps/game/tools/checks/time.mjs +++ b/apps/game/tools/checks/time.mjs @@ -13,86 +13,81 @@ * 7) инварианты чисты (time-bounded и прочие). * Запуск: node tools/agent.mjs run tools/checks/time.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; /** Ambient лугов и его ночной микс на середине заката (19:00). */ const AMB = { meadows: 0xd8dade, mix: 0xa1a6b3, night: 0x6a7288 }; export default async function ({ pretty }) { - const c = new Checks('time'); - const server = await startDevServer(); - let ctx; - try { - await c.run('новая игра: старт 8:00, день', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.time.minutes >= 480 && s.time.minutes < 485, `старт не 8:00 (${s.time.label})`, s.time); - c.expect(s.time.night === 0, 'утром фактор ночи не нулевой', s.time); - c.expect(/^\d{1,2}:\d{2}$/.test(s.time.label), `label не часы (${s.time.label})`, s.time); - return null; - }); - await c.run('ход времени: 600 шагов = +10 минут (±1)', async () => { - await ctx.agent.command('scene:setTime', { hours: 8 }); // пин: точка отсчёта 480 - await ctx.agent.step(600); // 600 тиков × (1/60 с) × 1 мин/с = 10 мин - const s = await ctx.agent.snapshot(); - const dt = s.time.minutes - 480; - c.expect(dt >= 9 && dt <= 11, `после 600 шагов прошло ${dt} мин (не ~10)`, s.time); - return null; - }); - await c.run('закат 19:00: night ≈ 0.5, ambient — микс по фактору', async () => { - await ctx.agent.command('scene:setTime', { hours: 19 }); - const w = await ctx.agent.waitFor('Math.abs(s.time.night - 0.5) < 0.02', { timeoutTicks: 300 }); - c.expect(w.ok, 'на середине заката фактор ночи не ~0.5', w.snapshot?.time); - // Время продолжает идти (1 тик ≈ +1/60 мин) — сверяем ambient с миксом - // по фактическому фактору из того же снапшота (округление по каналам). - const s = await ctx.agent.snapshot(); - const mix = (a, b, k) => - [16, 8, 0].map((sh) => { - const ca = (a >> sh) & 255, cb = (b >> sh) & 255; - return Math.round(ca * (1 - k) + cb * k) << sh; - }).reduce((x, y) => x | y); - const expected = mix(AMB.meadows, AMB.night, s.time.night); - c.expect(s.lighting.ambient === expected, `ambient ${s.lighting.ambient.toString(16)} не микс ${expected.toString(16)}`, { ambient: s.lighting.ambient, night: s.time.night }); - return null; - }); - await c.run('ночь 21:00: night 1, ночная цель и лампа героя', async () => { - await ctx.agent.command('scene:setTime', { hours: 21 }); - const w = await ctx.agent.waitFor( - `s.time.night === 1 && s.lighting.ambient === ${AMB.night} && s.lighting.sources.some((l) => l.id === "lamp")`, - { timeoutTicks: 300 } - ); - c.expect(w.ok, 'в 21:00 луга не ночные или лампа не зажглась', w.snapshot?.lighting); - return null; - }); - await c.run('часы в сумке: scene:give кладёт предмет', async () => { - await ctx.agent.command('scene:give', { id: 'clock' }); - const s = await ctx.agent.snapshot(); - c.expect(s.inventory.some((i) => i.id === 'clock' && i.count === 1), 'часов нет в снапшоте сумки', s.inventory); - return null; - }); - await c.run('сумка: время заморожено, после закрытия — идёт', async () => { - await ctx.agent.command('scene:setTime', { hours: 10 }); // пин перед открытием - await ctx.agent.press('inventory'); // открыть сумку (атомарно) - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 120 }); - await ctx.agent.press('advance'); // применить часы (первый пункт) — строка в панели - await ctx.agent.step(120); // 2 с: в сумке локация не тикается - const s = await ctx.agent.snapshot(); - c.expect(s.time.minutes === 600, `в сумке время уехало (${s.time.label})`, s.time); - await ctx.agent.press('inventory'); // закрыть сумку - const w = await ctx.agent.waitFor('s.time.minutes > 600', { timeoutTicks: 300 }); - c.expect(w.ok, 'после закрытия сумки время не пошло', w.snapshot?.time); - return null; - }); - await c.run('инварианты чисты при идущем времени', async () => { - const inv = await ctx.agent.invariants(); - const bad = inv.filter((i) => i.severity === 'error'); - c.expect(bad.length === 0, 'ошибки инвариантов', bad); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + return withChecks( + 'time', + async (t) => { + const { c } = t; + await c.run('новая игра: старт 8:00, день', async () => { + await t.boot(); + const s = await t.ctx.agent.snapshot(); + c.expect(s.time.minutes >= 480 && s.time.minutes < 485, `старт не 8:00 (${s.time.label})`, s.time); + c.expect(s.time.night === 0, 'утром фактор ночи не нулевой', s.time); + c.expect(/^\d{1,2}:\d{2}$/.test(s.time.label), `label не часы (${s.time.label})`, s.time); + return null; + }); + await c.run('ход времени: 600 шагов = +10 минут (±1)', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 8 }); // пин: точка отсчёта 480 + await t.ctx.agent.step(600); // 600 тиков × (1/60 с) × 1 мин/с = 10 мин + const s = await t.ctx.agent.snapshot(); + const dt = s.time.minutes - 480; + c.expect(dt >= 9 && dt <= 11, `после 600 шагов прошло ${dt} мин (не ~10)`, s.time); + return null; + }); + await c.run('закат 19:00: night ≈ 0.5, ambient — микс по фактору', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 19 }); + const w = await t.ctx.agent.waitFor('Math.abs(s.time.night - 0.5) < 0.02', { timeoutTicks: 300 }); + c.expect(w.ok, 'на середине заката фактор ночи не ~0.5', w.snapshot?.time); + // Время продолжает идти (1 тик ≈ +1/60 мин) — сверяем ambient с миксом + // по фактическому фактору из того же снапшота (округление по каналам). + const s = await t.ctx.agent.snapshot(); + const mix = (a, b, k) => + [16, 8, 0].map((sh) => { + const ca = (a >> sh) & 255, cb = (b >> sh) & 255; + return Math.round(ca * (1 - k) + cb * k) << sh; + }).reduce((x, y) => x | y); + const expected = mix(AMB.meadows, AMB.night, s.time.night); + c.expect(s.lighting.ambient === expected, `ambient ${s.lighting.ambient.toString(16)} не микс ${expected.toString(16)}`, { ambient: s.lighting.ambient, night: s.time.night }); + return null; + }); + await c.run('ночь 21:00: night 1, ночная цель и лампа героя', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 21 }); + const w = await t.ctx.agent.waitFor( + `s.time.night === 1 && s.lighting.ambient === ${AMB.night} && s.lighting.sources.some((l) => l.id === "lamp")`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'в 21:00 луга не ночные или лампа не зажглась', w.snapshot?.lighting); + return null; + }); + await c.run('часы в сумке: scene:give кладёт предмет', async () => { + await t.ctx.agent.command('scene:give', { id: 'clock' }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.inventory.some((i) => i.id === 'clock' && i.count === 1), 'часов нет в снапшоте сумки', s.inventory); + return null; + }); + await c.run('сумка: время заморожено, после закрытия — идёт', async () => { + await t.ctx.agent.command('scene:setTime', { hours: 10 }); // пин перед открытием + await t.ctx.agent.press('inventory'); // открыть сумку (атомарно) + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 120 }); + await t.ctx.agent.press('advance'); // применить часы (первый пункт) — строка в панели + await t.ctx.agent.step(120); // 2 с: в сумке локация не тикается + const s = await t.ctx.agent.snapshot(); + c.expect(s.time.minutes === 600, `в сумке время уехало (${s.time.label})`, s.time); + await t.ctx.agent.press('inventory'); // закрыть сумку + const w = await t.ctx.agent.waitFor('s.time.minutes > 600', { timeoutTicks: 300 }); + c.expect(w.ok, 'после закрытия сумки время не пошло', w.snapshot?.time); + return null; + }); + await c.run('инварианты чисты при идущем времени', async () => { + await t.expectInvariantsClean('при идущем времени'); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/checks/transitions.mjs b/apps/game/tools/checks/transitions.mjs index ac820ef..a6ca710 100644 --- a/apps/game/tools/checks/transitions.mjs +++ b/apps/game/tools/checks/transitions.mjs @@ -6,59 +6,52 @@ * 4) снапшот перечисляет переходы области с триггерами. * Запуск: node tools/agent.mjs run tools/checks/transitions.mjs */ -import { startDevServer, openGame, Checks } from '../lib.mjs'; +import { withChecks } from '../lib.mjs'; export default async function ({ pretty }) { - const c = new Checks('transitions'); - const server = await startDevServer(); - let ctx; - try { - await c.run('step-переход: луга -> Звенец пешком', async () => { - ctx = await openGame({ url: server.url, newGame: true }); - // Спим врагов, чтобы путь был безопасным; идём к тропе (26,14). - await ctx.agent.command('scene:sleepAll'); - const walked = await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); - c.expect(walked, 'walkTo(26,14) не дошёл'); - const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); - c.expect(w.ok, 'после тропы не в Звенце', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - return null; - }); - await c.run('клик-переход: колодец Звенца -> луга', async () => { - // Колодец (9,6) у дома Ирвина; entry на лугах — (22,21). - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - await ctx.agent.tapTile(9, 6); - const w = await ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 22 && s.hero.tile.y === 21, 'герой не на entry (22,21)', s.hero.tile); - return null; - }); - await c.run('обратный колодец: луга -> Звенец', async () => { - // Луговой колодец (22,20) стоит рядом с entry — кликаем по нему. - await ctx.agent.tapTile(22, 20); - const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); - c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); - await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); - const s = await ctx.agent.snapshot(); - c.expect(s.hero.tile.x === 9 && s.hero.tile.y === 7, 'герой не на entry (9,7) Звенца', s.hero.tile); - return null; - }); - await c.run('снапшот: переходы области с триггерами', async () => { - const s = await ctx.agent.snapshot(); - const wells = (s.transitions ?? []).filter((t) => t.trigger === 'click'); - c.expect(wells.length >= 1, 'нет click-переходов в снапшоте', s.transitions); - c.expect( - (s.transitions ?? []).every((t) => typeof t.to === 'string' && t.to.length > 0), - 'переход без цели', - s.transitions - ); - return null; - }); - } finally { - await ctx?.browser?.close(); - server.stop(); - } - return c.finish({ pretty }).ok ? 0 : 1; + return withChecks( + 'transitions', + async (t) => { + const { c } = t; + await c.run('step-переход: луга -> Звенец пешком', async () => { + await t.boot(); + // Спим врагов, чтобы путь был безопасным; идём к тропе (26,14). + await t.sleepEnemies(); + await t.goToArea('zvenets', { x: 26, y: 14 }); + return null; + }); + await c.run('клик-переход: колодец Звенца -> луга', async () => { + // Колодец (9,6) у дома Ирвина; entry на лугах — (22,21). + await t.ctx.agent.tapTile(9, 6); + const w = await t.ctx.agent.waitFor('s.area === "meadows"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'колодец не телепортировал на луга', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 22 && s.hero.tile.y === 21, 'герой не на entry (22,21)', s.hero.tile); + return null; + }); + await c.run('обратный колодец: луга -> Звенец', async () => { + // Луговой колодец (22,20) стоит рядом с entry — кликаем по нему. + await t.ctx.agent.tapTile(22, 20); + const w = await t.ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'луговой колодец не вернул в Звенец', { area: w.snapshot.area }); + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.hero.tile.x === 9 && s.hero.tile.y === 7, 'герой не на entry (9,7) Звенца', s.hero.tile); + return null; + }); + await c.run('снапшот: переходы области с триггерами', async () => { + const s = await t.ctx.agent.snapshot(); + const wells = (s.transitions ?? []).filter((tr) => tr.trigger === 'click'); + c.expect(wells.length >= 1, 'нет click-переходов в снапшоте', s.transitions); + c.expect( + (s.transitions ?? []).every((tr) => typeof tr.to === 'string' && tr.to.length > 0), + 'переход без цели', + s.transitions + ); + return null; + }); + }, + { pretty } + ); } \ No newline at end of file diff --git a/apps/game/tools/dialogue-editor/server.mjs b/apps/game/tools/dialogue-editor/server.mjs index 3c8f0fa..7a80164 100644 --- a/apps/game/tools/dialogue-editor/server.mjs +++ b/apps/game/tools/dialogue-editor/server.mjs @@ -5,7 +5,7 @@ * валидацию и dry-run делает спавном vite-node tools/dialogues/probe.ts — * правила одни (dialogueRules.ts), дублирования нет. * - * Запуск: npm run dialogues [-- --port 5199] + * Запуск: npm run dialogues [-- --port 5299] */ import http from 'node:http'; import { spawn } from 'node:child_process'; @@ -18,7 +18,12 @@ const DIALOGUES_DIR = path.join(ROOT, 'apps/game/src/data/dialogues'); const STATIC_DIR = path.join(ROOT, 'apps/game/tools/dialogue-editor'); const VITE_NODE = path.join(ROOT, 'node_modules/.bin/vite-node'); -const PORT = Number(process.argv[process.argv.indexOf('--port') + 1] ?? 5199); +/** Порт редактора: --port N (иначе 5299; строка `??` после indexOf(-1) дала бы NaN). */ +const portIdx = process.argv.indexOf('--port'); +const PORT = + portIdx !== -1 && Number.isFinite(Number(process.argv[portIdx + 1])) + ? Number(process.argv[portIdx + 1]) + : 5299; const JSON_SPACE = 4; diff --git a/apps/game/tools/lib.mjs b/apps/game/tools/lib.mjs index 1cb8be7..001a5f9 100644 --- a/apps/game/tools/lib.mjs +++ b/apps/game/tools/lib.mjs @@ -4,7 +4,11 @@ * примитивов. Смоуки и checks/*.mjs импортируют отсюда. */ import { fileURLToPath } from 'node:url'; -import { openGame as engineOpenGame, startDevServer as engineStartDevServer } from '@rpg/engine/tools/agent-lib.mjs'; +import { + Checks, + openGame as engineOpenGame, + startDevServer as engineStartDevServer +} from '@rpg/engine/tools/agent-lib.mjs'; export { CHROMIUM, launchBrowser, AgentClient, Checks, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; @@ -18,4 +22,78 @@ export const BEACON = '[boot] ассеты загружены'; /** openGame с игровым маяком загрузки. */ -export const openGame = (opts = {}) => engineOpenGame({ beacon: BEACON, ...opts }); \ No newline at end of file +export const openGame = (opts = {}) => engineOpenGame({ beacon: BEACON, ...opts }); + +/** + * Каркас чек-сценария: поднимает dev-сервер, даёт помощники (boot, goToArea, + * sleepEnemies, readAudioLog, waitAudioLog, expectInvariantsClean) и в любом + * исходе гасит браузер и сервер. Сценарий оставляет только шаги проверки. + * + * export default async function ({ pretty }) { + * return withChecks('имя', async (t) => { ... }, { pretty }); + * } + */ +export async function withChecks(name, fn, { pretty } = {}) { + const c = new Checks(name); + const server = await startDevServer(); + const h = { ctx: null }; + const t = { + c, + /** Контекст игры (заполняется boot). */ + get ctx() { + return h.ctx; + }, + /** Новая игра + выход из стартового fade-перехода. */ + boot: async () => { + h.ctx = await openGame({ url: server.url, newGame: true }); + await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return h.ctx; + }, + /** Спим всех врагов (безопасная навигация по миру). */ + sleepEnemies: () => h.ctx.agent.command('scene:sleepAll'), + /** + * Пешком к тайлу-триггеру step-перехода (via) и дождаться области + * (клик-переходы вроде дверей — явным tapTile в сценарии). + */ + goToArea: async (area, via) => { + const walked = await h.ctx.agent.walkTo(via.x, via.y, { timeoutTicks: 3000 }); + c.expect(walked, `walkTo(${via.x},${via.y}) не дошёл до перехода`); + const w = await h.ctx.agent.waitFor(`s.area === "${area}"`, { timeoutTicks: 1200 }); + c.expect(w.ok, `после перехода не в «${area}»`, w.snapshot?.area); + await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + return w; + }, + /** DEV-лог аудио страницы (window.__gameAudioLog) или null вне DEV. */ + readAudioLog: () => h.ctx.page.evaluate(() => window.__gameAudioLog ?? null), + /** + * Ждать условие на аудио-логе: декод WAV и компиляция спеков в headless + * занимают заметное время — шагаем и опрашиваем, а не читаем сразу. + */ + waitAudioLog: async (pred, tries = 12) => { + let log = await t.readAudioLog(); + for (let i = 0; i < tries && !pred(log); i++) { + await h.ctx.agent.step(30); + log = await t.readAudioLog(); + } + return log; + }, + /** Ошибки инвариантов моста: пусто — чисто (warn допустим). */ + expectInvariantsClean: async (what) => { + const inv = await h.ctx.agent.invariants(); + const errs = (inv ?? []).filter((i) => i.severity === 'error'); + c.expect(errs.length === 0, `инварианты нарушены${what ? `: ${what}` : ''}`, errs); + return errs; + } + }; + try { + await fn(t); + } finally { + try { + await h.ctx?.browser?.close(); + } catch { + // браузер мог умереть раньше — сервер всё равно гасим + } + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/apps/game/tsconfig.json b/apps/game/tsconfig.json index 3bc3728..d16bc7c 100644 --- a/apps/game/tsconfig.json +++ b/apps/game/tsconfig.json @@ -4,5 +4,5 @@ "types": ["vite/client"], "resolveJsonModule": true }, - "include": ["src", "../engine/src"] + "include": ["src"] } \ No newline at end of file diff --git a/docs/engine/agent.md b/docs/engine/agent.md index b0619e3..c80863e 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -201,6 +201,13 @@ `apps/game/tools/lib.mjs` (там маяк загрузки игры) (проверка возвращает `false`/кидает исключение или `c.expect(cond, msg, details)`). +**Новый сценарий — через `withChecks`** (`apps/game/tools/lib.mjs`): он поднимает и +гасит dev-сервер, а сценарий оставляет только шаги. Второй аргумент — `async (t) => {...}` +с помощниками: `t.boot()` (новая игра + выход из fade), `t.c`/`t.ctx` (Checks и контекст), +`t.sleepEnemies()`, `t.goToArea(area, via)` (пешком к step-переходу), `t.readAudioLog()`/ +`t.waitAudioLog(pred)`, `t.expectInvariantsClean(what)`. Контекст — **только через +`t.ctx`** (деструктуризация в начале снимет геттер до `boot` и даст `null`). + **startDevServer и cwd**: `startDevServer({ port, cwd })` поднимает vite из каталога `cwd` (по умолчанию — корень монорепо); приложение обязано передать свой каталог (index.html + vite-конфиг), иначе vite от корня отдаёт 404 на всё. Игровой diff --git a/docs/engine/getting-started.md b/docs/engine/getting-started.md index 87cba9b..ec194c4 100644 --- a/docs/engine/getting-started.md +++ b/docs/engine/getting-started.md @@ -18,9 +18,17 @@ ```ts import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ resolve: { - alias: { '@rpg/engine': '../../packages/engine/src/index.ts' } + // Regex-алиас: только точное имя. Строковый '@rpg/engine' перехватил бы + // и под-пути (@rpg/engine/assets/...), ломая их в index.ts/.... + alias: [ + { + find: /^@rpg\/engine$/, + replacement: fileURLToPath(new URL('../../packages/engine/src/index.ts', import.meta.url)) + } + ] } }); ``` diff --git a/docs/engine/practices.md b/docs/engine/practices.md index b938444..00325e9 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -22,8 +22,11 @@ 4. Валидатор проверит спавн/врагов/переходы автоматически (`agent:invariants`): **инвариант `in-wall` у врага — почти всегда реальный баг данных** (враг поставлен на воду/дерево). Чинить данные, а не валидатор. -5. Сценарий в `apps/game/tools/checks/`: переход туда и обратно через `walkTo` + `waitFor` - (готовый образец — `apps/game/tools/checks/transitions.mjs`). +5. Сценарий в `apps/game/tools/checks/`: писать через каркас `withChecks` из + `apps/game/tools/lib.mjs` (boot/goToArea/sleepEnemies/expectInvariantsClean — + готовые помощники; сервер гасится сам). Готовый образец — + `apps/game/tools/checks/transitions.mjs`. Контекст игры — только `t.ctx`: + деструктуризация до `boot` снимет геттер и даст `null`. ## Ситуация: меняю генераторы арта или карт @@ -77,7 +80,7 @@ недостижимые узлы → `warn` (сироты допустимы, но проверь, что это не забытая ветка), цикл без текста → `error`. Прогнать глазами: `npm run dialogues:dry []` — реплики на пресетах состояния + сироты/циклы. - Править граф глазами — визуальный редактор `npm run dialogues` (порт 5199): + Править граф глазами — визуальный редактор `npm run dialogues` (порт 5299): карточки/рёбра, инспектор узла, кнопка Save гоняет тот же checkGraph (422 на ошибках, mtime-гард от конкурентной правки), dry-run прямо в UI. Правила одни для всех трёх точек (валидатор, CLI, редактор) — они в @@ -184,7 +187,7 @@ 2. Экспорт только через `packages/engine/src/index.ts` — под-пути движка импортировать нельзя. 3. `npm run typecheck` ловит разрывы в обоих пакетах; тесты движка — в Vitest - без браузера (Pixi-зависимости — через стабы, см. `GameLoop.manual.test.ts`). + без браузера (Pixi-зависимости — через стабы, см. `GameLoop.timing.test.ts`). ## Ситуация: меняю границу движок / игра diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index 47f8d17..a58e254 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -256,7 +256,7 @@ `string-unknown` (textKey вне реестра строк), `quest-stage-unreachable` (doneFlag стадии не выставляется setFlags в графе стадии). -Визуальный редактор — `npm run dialogues` (`tools/dialogue-editor/`, порт 5199): +Визуальный редактор — `npm run dialogues` (`tools/dialogue-editor/`, порт 5299): слева список графов, в центре SVG-канва (карточки узлов, рёбра `next` и `choices` разными цветами, сироты пунктиром), справа инспектор узла (текст/speaker/mood/tags, условия, setFlags/setVars/do[], выборы). diff --git a/docs/llms.txt b/docs/llms.txt index d2942a9..bcdf3f7 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -17,6 +17,7 @@ - [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, pixel-perfect масштаб. - [Ввод](engine/input.md): действия, геймпад, VirtualJoystick, инъекция ввода. - [Карты](engine/maps.md): изометрия, A*, коллизии (круг поверх сетки), формат rpg-map, Tiled-импорт. +- [Анимация](engine/anim.md): клипы, SpriteAnimator, оживители, реестр тика — всё от фиксированного шага. - [Инвентарь](engine/inventory.md): модель Inventory — стаки, лимиты, события; реестр предметов и UI — в игре. - [UI и диалоги](engine/ui-and-dialogue.md): PixelText, Panel, Button, MenuList, DialogueBox, DialogueRunner. - [Кат-сцены](engine/cutscene.md): раннер кат-сцен. @@ -32,7 +33,7 @@ - `apps/game/tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). - `packages/engine/tools/`: жанронезависимые тулзы (png/canvas/imaging/wav, agent-lib, boundary) — импорт через `@rpg/engine/tools/*`; тулзы, знающие контент игры, остаются в `apps/game/tools/` как сценарии применения. - `apps/game/tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. -- `apps/game/tools/dialogues/`: dry-run CLI (`npm run dialogues:dry`) и probe/registries для редактора; визуальный редактор графов — `npm run dialogues` (порт 5199). +- `apps/game/tools/dialogues/`: dry-run CLI (`npm run dialogues:dry`) и probe/registries для редактора; визуальный редактор графов — `npm run dialogues` (порт 5299). - `apps/game/tools/guards/boundary.mjs` (`npm run guard`): сценарий запуска гварда; правила — `packages/engine/tools/boundary.mjs`. ## Мир и арт diff --git a/packages/engine/src/core/__tests__/GameLoop.manual.test.ts b/packages/engine/src/core/__tests__/GameLoop.manual.test.ts deleted file mode 100644 index 9d92b3d..0000000 --- a/packages/engine/src/core/__tests__/GameLoop.manual.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { GameLoop } from '../GameLoop'; - -/** Заглушки rAF: frame() продвигает кадры вручную. */ -function installRaf(): (advanceMs: number) => void { - let now = 0; - const callbacks: ((t: number) => void)[] = []; - vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { - callbacks.push(cb); - return callbacks.length; - }); - vi.stubGlobal('cancelAnimationFrame', () => undefined); - vi.stubGlobal('performance', { now: () => now }); - return (advanceMs: number) => { - now += advanceMs; - const list = [...callbacks]; - callbacks.length = 0; - for (const cb of list) cb(now); - }; -} - -describe('GameLoop — ручной режим', () => { - it('setManual(true) гасит кадры, resetTiming не даёт догонять', () => { - const frame = installRaf(); - const updates: number[] = []; - const loop = new GameLoop(60, { - update: (dt) => updates.push(dt), - render: () => undefined - }); - loop.start(); - frame(1000); // ~60 шагов сгущаются в максимум 5 - expect(updates.length).toBe(5); - - loop.setManual(true); - frame(1000); - expect(updates.length).toBe(5); // кадры в ручном режиме пустые - - loop.setManual(false); - frame(20); // resetTiming в setManual(true) обнулил аккумулятор — без «догоняния» - expect(updates.length).toBe(6); // ровно один шаг, а не 5+5 - loop.stop(); - vi.unstubAllGlobals(); - }); -}); - -describe('GameLoop — лок кадров (maxFps)', () => { - it('рендер не чаще лимита, шаги копятся и идут пачкой', () => { - const frame = installRaf(); - const updates: number[] = []; - let renders = 0; - const loop = new GameLoop( - 60, - { - update: (dt) => updates.push(dt), - render: () => renders++ - }, - { maxFps: 30 } - ); - loop.start(); - - frame(17); // 17 < 32-1: кадр срезан, ни рендера, ни шагов - expect(renders).toBe(0); - expect(updates.length).toBe(0); - - frame(17); // 34 мс с прошлого рендера: один кадр = 2 шага 60 Гц - expect(renders).toBe(1); - expect(updates.length).toBe(2); - expect(updates[0]).toBeCloseTo(1 / 60); - - frame(17); // снова срезан - frame(100); // крупный скачок: догоняет пачкой до maxStepsPerFrame - expect(renders).toBe(2); - expect(updates.length).toBe(7); // 2 + 5 (лимит за кадр) - loop.stop(); - vi.unstubAllGlobals(); - }); - - it('maxFps = 0 (по умолчанию) — без лока: рендер на каждом rAF', () => { - const frame = installRaf(); - let renders = 0; - const loop = new GameLoop(60, { - update: () => undefined, - render: () => renders++ - }); - loop.start(); - frame(16); - frame(16); - expect(renders).toBe(2); - loop.stop(); - vi.unstubAllGlobals(); - }); -}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/GameLoop.timing.test.ts b/packages/engine/src/core/__tests__/GameLoop.timing.test.ts new file mode 100644 index 0000000..9d92b3d --- /dev/null +++ b/packages/engine/src/core/__tests__/GameLoop.timing.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GameLoop } from '../GameLoop'; + +/** Заглушки rAF: frame() продвигает кадры вручную. */ +function installRaf(): (advanceMs: number) => void { + let now = 0; + const callbacks: ((t: number) => void)[] = []; + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { + callbacks.push(cb); + return callbacks.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => undefined); + vi.stubGlobal('performance', { now: () => now }); + return (advanceMs: number) => { + now += advanceMs; + const list = [...callbacks]; + callbacks.length = 0; + for (const cb of list) cb(now); + }; +} + +describe('GameLoop — ручной режим', () => { + it('setManual(true) гасит кадры, resetTiming не даёт догонять', () => { + const frame = installRaf(); + const updates: number[] = []; + const loop = new GameLoop(60, { + update: (dt) => updates.push(dt), + render: () => undefined + }); + loop.start(); + frame(1000); // ~60 шагов сгущаются в максимум 5 + expect(updates.length).toBe(5); + + loop.setManual(true); + frame(1000); + expect(updates.length).toBe(5); // кадры в ручном режиме пустые + + loop.setManual(false); + frame(20); // resetTiming в setManual(true) обнулил аккумулятор — без «догоняния» + expect(updates.length).toBe(6); // ровно один шаг, а не 5+5 + loop.stop(); + vi.unstubAllGlobals(); + }); +}); + +describe('GameLoop — лок кадров (maxFps)', () => { + it('рендер не чаще лимита, шаги копятся и идут пачкой', () => { + const frame = installRaf(); + const updates: number[] = []; + let renders = 0; + const loop = new GameLoop( + 60, + { + update: (dt) => updates.push(dt), + render: () => renders++ + }, + { maxFps: 30 } + ); + loop.start(); + + frame(17); // 17 < 32-1: кадр срезан, ни рендера, ни шагов + expect(renders).toBe(0); + expect(updates.length).toBe(0); + + frame(17); // 34 мс с прошлого рендера: один кадр = 2 шага 60 Гц + expect(renders).toBe(1); + expect(updates.length).toBe(2); + expect(updates[0]).toBeCloseTo(1 / 60); + + frame(17); // снова срезан + frame(100); // крупный скачок: догоняет пачкой до maxStepsPerFrame + expect(renders).toBe(2); + expect(updates.length).toBe(7); // 2 + 5 (лимит за кадр) + loop.stop(); + vi.unstubAllGlobals(); + }); + + it('maxFps = 0 (по умолчанию) — без лока: рендер на каждом rAF', () => { + const frame = installRaf(); + let renders = 0; + const loop = new GameLoop(60, { + update: () => undefined, + render: () => renders++ + }); + loop.start(); + frame(16); + frame(16); + expect(renders).toBe(2); + loop.stop(); + vi.unstubAllGlobals(); + }); +}); \ No newline at end of file diff --git a/packages/engine/tools/wav.mjs b/packages/engine/tools/wav.mjs index e1d22b2..4e62d30 100644 --- a/packages/engine/tools/wav.mjs +++ b/packages/engine/tools/wav.mjs @@ -3,7 +3,6 @@ * (сами примитивы — в synth.mjs, они же используются в рантайме браузера). * Формат: моно, RATE Гц, 16-бит PCM. */ -import { writeFileSync } from 'node:fs'; import { RATE, bandNoise, @@ -44,8 +43,3 @@ } return Buffer.from(data); } - -/** Записать WAV на диск (хелпер типового пайплайна «синтез -> файл»). */ -export function writeWav(path, samples, rate = RATE) { - writeFileSync(path, encodeWav(samples, rate)); -} diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json index 9fe9be8..495443e 100644 --- a/packages/engine/tsconfig.json +++ b/packages/engine/tsconfig.json @@ -1,7 +1,5 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["vite/client"] - }, + "compilerOptions": {}, "include": ["src"] } \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d971a8f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Явный список тестов: юнит-тесты движка и игры (src), тулзы с чистой + * математикой (engine/tools, pixelart/aiart/maps). Остальное (smoke, checks, + * диалоговый редактор) — браузерные сценарии agent.mjs/smoke.mjs, не Vitest. + */ +export default defineConfig({ + test: { + include: [ + 'packages/engine/src/**/*.test.{ts,tsx}', + 'packages/engine/tools/**/*.test.mjs', + 'apps/game/src/**/*.test.{ts,tsx}', + 'apps/game/tools/**/*.{test.ts,test.mjs}' + ], + exclude: ['**/node_modules/**', '**/dist/**'] + } +}); \ No newline at end of file