diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index f06780c..f78ca43 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -113,6 +113,11 @@ return mod.default({ args, pretty: args.pretty }); }, + /** Бенчмарк производительности (не входит в check — замер, а не гейт). */ + async perf() { + return runScenario('perf', 'apps/game/tools/checks/perf.mjs'); + }, + /** Снимок снапшота в файл/stdout. */ async snapshot() { const server = await startDevServer({ port: args.port ?? 5199 }); diff --git a/apps/game/tools/checks/perf.mjs b/apps/game/tools/checks/perf.mjs new file mode 100644 index 0000000..0dcde6e --- /dev/null +++ b/apps/game/tools/checks/perf.mjs @@ -0,0 +1,92 @@ +/** + * Сценарий perf — базовый замер производительности (baseline оптимизаций). + * Не гейтовский чек: числа фиксируются в коммитах до/после, ассерты только + * на «всё живо» (fps > 0, путь найден, инварианты чисты). + * Важно: launchBrowser гоняет рендер на swiftshader (программный) — fps здесь + * уровень слабого железа, а не обычного десктопа; сравнивать можно только + * числа одинаковых прогонов (до/после), абсолютные значения — не про-player. + * 1) старт: страница+ассеты до маяка (включая фиксированные 1,5 с сна моста — + * вычитать при сравнении), затем newGame() → location; + * 2) путь: время scene:route (один A*) и walkTo через луга (клики по узлам); + * 3) бой: погоня + урон + эффекты — fps/heap после боя; + * 4) fps меряется в реальном времени (rAF), не в ручных шагах моста: + * окно ~0,9 с реального времени, затем снапшот + performance.memory. + * Запуск: node tools/agent.mjs run tools/checks/perf.mjs [--pretty] + */ +import { withChecks } from '../lib.mjs'; + +/** Пауза в реальном времени (rAF-цикл живёт, fpsMeter копит кадры). */ +const idle = (page, ms) => page.evaluate((m) => new Promise((r) => setTimeout(r, m)), ms); + +export default async function ({ pretty }) { + return withChecks( + 'perf', + async (t) => { + const { c } = t; + + await c.run('старт: страница+ассеты, затем newGame → location', async () => { + const t0 = Date.now(); + await t.open({ newGame: false }); // до маяка «ассеты загружены» + 1,5 c сна + const bootMs = Date.now() - t0; + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const t1 = Date.now(); + await t.ctx.agent.newGame(); // сам ждёт s.scene === 'location' + const newGameMs = Date.now() - t1; + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'после newGame не в локации', s.scene); + return { bootMs: bootMs - 1500, bootMsRaw: bootMs, newGameMs, fps: s.fps }; + }); + + await c.run('путь: один A* (scene:route) и walkTo через луга', async () => { + const t0 = Date.now(); + const route = await t.ctx.agent.command('scene:route', { x: 24, y: 14 }); + const routeMs = Date.now() - t0; + c.expect(Array.isArray(route) && route.length > 0, 'маршрут на восток лугов не найден', route); + const t1 = Date.now(); + const walked = await t.ctx.agent.walkTo(24, 14, { timeoutTicks: 3000 }); + const walkMs = Date.now() - t1; + c.expect(walked, 'walkTo(24,14) не дошёл', null); + return { routeMs, routeLen: route.length, walkMs }; + }); + + await c.run('fps/heap в покое после прогулки', async () => { + await idle(t.ctx.page, 900); + const s = await t.ctx.agent.snapshot(); + const heap = await t.ctx.page.evaluate( + () => (performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null) + ); + c.expect(s.fps > 0, 'fps-метр молчит (нет кадров?)', s.fps); + return { fps: s.fps, heapMB: heap }; + }); + + await c.run('бой: погоня, урон, эффекты — fps/heap под нагрузкой', async () => { + await t.ctx.agent.command('scene:sleepAll'); // чистый старт сценария боя + await t.ctx.agent.command('scene:teleport', { x: 7, y: 9 }); // у сталкера (8,9) + await t.ctx.agent.command('scene:noise', { x: 8, y: 9, level: 1 }); // будим + const w = await t.ctx.agent.waitFor( + 's.enemies.some((e) => e.state === "chase")', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'после шума никто не погнался', w.snapshot?.enemies); + // Полноценный бой: сгусток догоняет и бьёт, эффекты/частицы живут. + await t.ctx.agent.step(360); + for (let i = 0; i < 3; i++) { + await t.ctx.agent.command('scene:damageEnemy', { value: 3 }); + await t.ctx.agent.step(60); + } + await idle(t.ctx.page, 900); + const s = await t.ctx.agent.snapshot(); + const heap = await t.ctx.page.evaluate( + () => (performance.memory ? Math.round(performance.memory.usedJSHeapSize / 1048576) : null) + ); + return { fps: s.fps, heapMB: heap, heroHp: s.hero?.hp, enemies: s.enemies?.length }; + }); + + await c.run('инварианты чисты под нагрузкой', async () => { + await t.expectInvariantsClean('в perf-сценарии'); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/apps/game/tools/lib.mjs b/apps/game/tools/lib.mjs index 001a5f9..a411c40 100644 --- a/apps/game/tools/lib.mjs +++ b/apps/game/tools/lib.mjs @@ -49,6 +49,14 @@ await h.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); return h.ctx; }, + /** + * Открыть игру с сырыми опциями openGame (boot = open({newGame: true}) + + * ожидание fade). Для сценариев, которым нужен свой тайминг старта. + */ + open: async (opts = {}) => { + h.ctx = await openGame({ url: server.url, ...opts }); + return h.ctx; + }, /** Спим всех врагов (безопасная навигация по миру). */ sleepEnemies: () => h.ctx.agent.command('scene:sleepAll'), /** diff --git a/docs/engine/agent.md b/docs/engine/agent.md index c80863e..2e16248 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -102,6 +102,7 @@ interface AgentApi { version: 1; snapshot(): GameSnapshot; // движковый слой + сцена + флаги/вары/сумка + // s.fps — реальный FPS отрисовки (FpsMeter, окно ~0,5 с) invariants(): Invariant[]; // движковые + сценические + контентные step(n?, {render}?): {tick}; // n фиксированных шагов (60 Гц), рендер опционален waitFor(pred: string, opts?): Promise<{ok, snapshot, ticks}>; diff --git a/eslint.config.js b/eslint.config.js index a1cf36e..dd26360 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -39,7 +39,8 @@ setTimeout: 'readonly', clearTimeout: 'readonly', // Страница игры в puppeteer-сценариях (page.evaluate). - window: 'readonly' + window: 'readonly', + performance: 'readonly' } } }, diff --git a/packages/engine/src/agent/EngineAgent.ts b/packages/engine/src/agent/EngineAgent.ts index 6f535ed..77b6416 100644 --- a/packages/engine/src/agent/EngineAgent.ts +++ b/packages/engine/src/agent/EngineAgent.ts @@ -41,7 +41,7 @@ const h = this.host; return { tick: h.tickCount, - fps: h.fixedStep > 0 ? Math.round((1 / h.fixedStep) * 100) / 100 : 0, + fps: h.fps, fixedStep: h.fixedStep, scenes: [h.scenes.current?.constructor.name ?? null].filter( (s): s is string => s !== null diff --git a/packages/engine/src/agent/__tests__/EngineAgent.test.ts b/packages/engine/src/agent/__tests__/EngineAgent.test.ts index 272cd36..589b706 100644 --- a/packages/engine/src/agent/__tests__/EngineAgent.test.ts +++ b/packages/engine/src/agent/__tests__/EngineAgent.test.ts @@ -21,6 +21,7 @@ virtualWidth: 480, virtualHeight: 270, fixedStep: 1 / 60, + fps: 0, get tickCount() { return ticks; }, diff --git a/packages/engine/src/agent/types.ts b/packages/engine/src/agent/types.ts index 69ead7c..1834f2d 100644 --- a/packages/engine/src/agent/types.ts +++ b/packages/engine/src/agent/types.ts @@ -53,6 +53,8 @@ renderFrame(): void; readonly tickCount: number; readonly fixedStep: number; + /** Реальный FPS отрисовки (окно ~0,5 с). */ + readonly fps: number; } /** Движковый слой снапшота. */ diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index 7f84ccd..29e443a 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -128,6 +128,11 @@ return this.loop.step; } + /** Реальный FPS отрисовки (окно ~0,5 с) — для моста и отладки. */ + get fps(): number { + return this.fpsMeter.fps; + } + /** Виртуальное разрешение (для пересчёта виртуальных px в CSS). */ get virtualWidth(): number { return this.renderer.virtualWidth;