feedback7 G-TOOLS: add the battle performance bench under public/dev (PR #198 by @Cloudnyco)
Applied as-is. Captured real battles live in public/dev/perf (including the round-13 联防 on the wide round battlefield), with a browser speed page and a headless benchmark. public/dev stays out of the package. The Chrome benchmark was not run. The numbers in the original pull request are from 0.1.4 and are not quoted here. Tests: node --test test/perfbench.test.js test/render/battle-perf.browser.test.js — 13 pass, 0 fail; the Chrome suite skipped (RENDER_E2E unset).
This commit is contained in:
1 parent
1f147a288f
commit
e8ba091298
13 files changed
+984
No files matched your search
@@ -199,9 +199,11 @@ SP_E2E=1 node --test test/ui/mock.e2e.test.js # 浏览器端到端测试
|
||||
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载的素材
|
||||
RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染测试,部分需要本地提取的棋盘贴图
|
||||
GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定种子的整套战斗与人机对局摘要(默认只跑快速子集)
|
||||
node tools/perfbench.mjs --cpu 1,4,6 --profile # 真实战斗的帧耗时基准(Chrome 降速 CPU 近似中低端手机),需要 Chrome + 已下载的素材
|
||||
```
|
||||
|
||||
- 游戏数据由 `npm run build-data`(`tools/build-data.mjs`)从官方数据表生成,不要手工修改 `data/*.json`。
|
||||
- 性能测试:`/dev/battle-perf.html` 在浏览器里跑一场真实战斗(对局用的战斗运行器、模拟和渲染),实时显示帧率与逐帧耗时;在手机上打开后点击「开始测量(10 秒)」,生成的报告可以复制后附在反馈中。用到的战斗来自 `node tools/capture-specs.mjs` 从固定种子的机器人对局里截取的数据(`public/dev/perf/`)。
|
||||
- 只重构、不改玩法的提交不能改变 `test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,检查差异后随改动一起提交(见 [test/golden/README.md](test/golden/README.md))。
|
||||
- GitHub Actions([.github/workflows/ci.yml](.github/workflows/ci.yml))在 Ubuntu 与 Windows、Node 22 / 24 上运行 `npm ci`、`node --test` 和服务器冒烟测试。
|
||||
- 代码怎么分层、改某个规则该从哪个文件入手,见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Battle perf · 卫戍协议:盟约</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<script type="importmap">
|
||||
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
|
||||
</script>
|
||||
<!-- PixiJS 7 + pixi-spine as classic scripts (global PIXI / PIXI.spine), like the game page -->
|
||||
<script src="/vendor/pixi.min.js"></script>
|
||||
<script src="/vendor/pixi-spine.js"></script>
|
||||
<style>
|
||||
/* Battle perf panel: the game's own tokens (css/theme.css — green-grey glass, mint, Bender figures, bracket corners)
|
||||
in px, not the game's viewport-scaled rem. The one loud element is the frame-time strip. */
|
||||
html, body { margin: 0; height: 100%; background: var(--bg-0); overflow: hidden; }
|
||||
#field { position: fixed; inset: 0; }
|
||||
.bp { position: fixed; top: 10px; right: 10px; z-index: 5; width: 320px; max-width: calc(100vw - 20px); max-height: calc(100vh - 20px);
|
||||
overflow: auto; background: var(--bg-glass); border: 1px solid var(--line); color: var(--text-md);
|
||||
font: 13px/1.45 var(--font-cjk); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); --bk-l: 10px; }
|
||||
.bp.is-hidden, .bp [hidden] { display: none; }
|
||||
.bp-body { display: grid; gap: 10px; padding: 10px 12px 12px; }
|
||||
.bp-head { display: flex; align-items: center; gap: 8px; }
|
||||
.bp-head h1 { margin: 0; font-size: 14px; font-weight: 700; color: var(--text-hi); flex: 1; }
|
||||
.bp button, .bp select { font: 13px/1.2 var(--font-cjk); color: var(--text-hi); background: var(--bg-2); border: 1px solid var(--line-2);
|
||||
padding: 5px 8px; cursor: pointer; min-height: 30px; }
|
||||
.bp button:hover, .bp select:hover { border-color: var(--line-3); }
|
||||
.bp button:focus-visible, .bp select:focus-visible, .bp summary:focus-visible { outline: 2px solid var(--mint-500); outline-offset: 1px; }
|
||||
.bp button:disabled { opacity: .5; cursor: default; }
|
||||
.bp-ctl { display: grid; grid-template-columns: 1fr auto; gap: 6px; }
|
||||
.bp-ctl select { min-width: 0; }
|
||||
.bp-ctl #spec { grid-column: 1 / -1; }
|
||||
|
||||
.bp-live { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; align-items: end; }
|
||||
.bp-fps { font: 600 44px/0.95 var(--font-num); color: var(--text-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.bp-fps small { font: 400 13px var(--font-cjk); color: var(--text-lo); margin-left: 4px; }
|
||||
.bp-figs { display: grid; grid-template-columns: auto auto; gap: 1px 10px; justify-content: end; font-size: 12px; color: var(--text-lo); }
|
||||
/* decimals: Bender's point is nearly invisible at small sizes (4.2 reads as 42), so small figures use the CJK face */
|
||||
.bp-figs b { font: 600 13px var(--font-cjk); color: var(--text-hi); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.bp-meta { grid-column: 1 / -1; font-size: 12px; color: var(--text-lo); }
|
||||
|
||||
.bp-strip { position: relative; height: 70px; background: var(--bg-0); border: 1px solid var(--line); }
|
||||
.bp-strip canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
.bp-freeze { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; padding: 0 12px;
|
||||
font-size: 12px; color: var(--text-md); background: rgba(12, 15, 14, .72); }
|
||||
.bp-key { display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 11.5px; color: var(--text-lo); }
|
||||
.bp-key i { display: inline-block; width: 8px; height: 8px; margin-right: 4px; }
|
||||
|
||||
.bp .bp-measure { position: relative; overflow: hidden; width: 100%; min-height: 36px; font-weight: 700;
|
||||
color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-500); }
|
||||
.bp .bp-measure:hover { background: var(--mint-400); border-color: var(--mint-400); }
|
||||
.bp-fill { position: absolute; inset: 0; background: var(--mint-800); transform-origin: left; transform: scaleX(0); }
|
||||
.bp .bp-measure.is-running { color: var(--text-hi); background: var(--mint-900); border-color: var(--mint-700); cursor: default; opacity: 1; }
|
||||
.bp-measure.is-running .bp-fill { animation: bp-fill 10s linear forwards; }
|
||||
.bp-measure span { position: relative; }
|
||||
@keyframes bp-fill { to { transform: scaleX(1); } }
|
||||
|
||||
.bp-result { display: grid; gap: 8px; border-top: 1px solid var(--line); padding-top: 10px; }
|
||||
.bp-verdict { display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 8px; color: var(--text-hi); font-weight: 700; }
|
||||
.bp-verdict::before { content: ''; width: 10px; height: 10px; flex: none; align-self: center; background: var(--v, var(--mint-500)); }
|
||||
.bp-verdict small { font-weight: 400; color: var(--text-lo); font-size: 12px; }
|
||||
.bp-sum { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.bp-sum div { display: grid; background: var(--bg-1); border: 1px solid var(--line); padding: 5px 7px; min-width: 0; }
|
||||
.bp-sum span { font-size: 11.5px; color: var(--text-lo); }
|
||||
.bp-sum b { font: 700 16px/1.3 var(--font-cjk); color: var(--text-hi); font-variant-numeric: tabular-nums; }
|
||||
.bp-result details { font-size: 12px; }
|
||||
.bp-result summary { cursor: pointer; color: var(--text-md); }
|
||||
.bp-report { margin: 6px 0 0; font: 12px/1.6 var(--font-cjk); color: var(--text-hi); white-space: pre-wrap; word-break: break-word;
|
||||
background: var(--bg-0); border: 1px solid var(--line); padding: 6px 8px; max-height: 30vh; overflow: auto; user-select: text; }
|
||||
|
||||
/* collapsed: a chip with the live figures and a mini strip */
|
||||
.bp .bp-chip { display: none; align-items: center; gap: 8px; width: 100%; padding: 4px 8px 4px 10px; background: none; border: 0; }
|
||||
.bp-chip b { font: 600 20px/1 var(--font-num); color: var(--text-hi); font-variant-numeric: tabular-nums; }
|
||||
.bp-chip span { font-size: 12px; color: var(--text-lo); white-space: nowrap; }
|
||||
.bp-chip canvas { width: 72px; height: 22px; display: block; margin-left: 4px; }
|
||||
.bp.is-collapsed { width: auto; }
|
||||
.bp.is-collapsed .bp-body { display: none; }
|
||||
.bp.is-collapsed .bp-chip { display: flex; }
|
||||
@media (prefers-reduced-motion: reduce) { .bp-measure.is-running .bp-fill { animation: none; transform: scaleX(1); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="field"></div>
|
||||
<section class="bp brackets" id="panel" aria-label="战斗性能测试">
|
||||
<button class="bp-chip" id="expand" type="button" aria-expanded="false" aria-controls="bp-body" title="展开面板">
|
||||
<b id="chip-fps">–</b><span id="chip-ms">FPS</span><canvas id="chip-strip" aria-hidden="true"></canvas>
|
||||
</button>
|
||||
<div class="bp-body" id="bp-body">
|
||||
<div class="bp-head"><h1>战斗性能测试</h1><button id="collapse" type="button" aria-expanded="true" aria-controls="bp-body">收起</button></div>
|
||||
<div class="bp-ctl">
|
||||
<select id="spec" aria-label="战斗场景"></select>
|
||||
<select id="quality" aria-label="画质"><option value="high">高画质</option><option value="medium">中画质</option><option value="low">低画质</option></select>
|
||||
<button id="restart" type="button">重新开始</button>
|
||||
</div>
|
||||
<div class="bp-live">
|
||||
<div class="bp-fps"><span id="fps">–</span><small>FPS</small></div>
|
||||
<div class="bp-figs">
|
||||
<span>帧耗时</span><b id="f-frame">–</b>
|
||||
<span>渲染 CPU</span><b id="f-view">–</b>
|
||||
<span>模拟单步</span><b id="f-sim">–</b>
|
||||
</div>
|
||||
<div class="bp-meta" id="meta">正在加载战斗…</div>
|
||||
</div>
|
||||
<div class="bp-strip" title="近 10 秒逐帧耗时">
|
||||
<canvas id="strip" role="img" aria-label="近 10 秒逐帧耗时"></canvas>
|
||||
<div class="bp-freeze" id="freeze" hidden>测量进行中,图表暂停刷新以避免干扰结果</div>
|
||||
</div>
|
||||
<div class="bp-key"><span><i style="background:var(--mint-500)"></i>≤ 16.7 ms(≥ 60 FPS)</span><span><i style="background:var(--amber)"></i>16.7–33.3 ms</span><span><i style="background:var(--red)"></i>> 33.3 ms(卡顿帧)</span></div>
|
||||
<button class="bp-measure" id="measure" type="button" disabled><i class="bp-fill" aria-hidden="true"></i><span id="measure-label">开始测量(10 秒)</span></button>
|
||||
<div class="bp-result" id="result" hidden aria-live="polite">
|
||||
<div class="bp-verdict" id="verdict"></div>
|
||||
<div class="bp-sum">
|
||||
<div><span>平均帧率</span><b id="r-fps">–</b></div>
|
||||
<div><span>P95 帧耗时</span><b id="r-p95">–</b></div>
|
||||
<div><span>卡顿帧占比</span><b id="r-over">–</b></div>
|
||||
</div>
|
||||
<details><summary>完整报告(提交反馈时请附上)</summary><pre class="bp-report" id="out"></pre></details>
|
||||
<button id="copy" type="button">复制报告</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script type="module" src="/dev/battle-perf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,331 @@
|
||||
// Battle perf page (dev): one real battle in the browser, run the way the match screen runs it — the client-side battle
|
||||
// runner (js/battle/runner.js: the shared sim at the battle speed) feeding the field view (js/render/app.js) — from a
|
||||
// BattleSpec captured off a bot match (tools/capture-specs.mjs → /dev/perf/<name>.json). Players can open it on a
|
||||
// phone and report the numbers (「开始测量」→ a verdict and a copyable report); tools/perfbench.mjs drives it headlessly.
|
||||
//
|
||||
// Query: ?spec=<name> (default: the first of /dev/perf/index.json) &quality=high|medium|low &panel=0 (hide the panel)
|
||||
// &board=2d|3d (render/app.js reads it)
|
||||
// Puppeteer hook: window.__perf = { ready, error, view, runner, sample(ms) → figures over `ms` of animation frames }
|
||||
//
|
||||
// The panel must not disturb what it measures: frame times go into a ring buffer (one store per frame), the strip is
|
||||
// redrawn 4× a second from it, binned to pixel columns, and both the strip and the live figures stand still during a
|
||||
// measurement (only a CSS progress animation runs).
|
||||
|
||||
import { createFieldView } from '../js/render/app.js';
|
||||
import { data } from '../js/data.js';
|
||||
import { assets } from '../js/assets.js';
|
||||
import { createBattleRunner } from '../js/battle/runner.js';
|
||||
import { round, frameFigures } from './frame-stats.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const q = new URLSearchParams(location.search);
|
||||
const perf = { ready: false, error: null };
|
||||
window.__perf = perf;
|
||||
|
||||
const longTasks = [];
|
||||
try {
|
||||
new PerformanceObserver((list) => { for (const e of list.getEntries()) longTasks.push(e.duration); }).observe({ type: 'longtask', buffered: true });
|
||||
} catch { /* not supported */ }
|
||||
|
||||
// Frame-time budgets (ms) shared by the verdict, the strip colours and the stutter share: 16.7 / 33.3 ms are 60 / 30 FPS.
|
||||
// The 60 FPS budget allows 5% for vsync jitter (a 60 Hz display measures frames of 16.6–17.4 ms); a frame over the 30 FPS
|
||||
// budget counts as a stutter frame.
|
||||
export const FRAME_60_MS = 17.5;
|
||||
export const FRAME_30_MS = 33.4;
|
||||
const QUALITY_NAME = { high: '高', medium: '中', low: '低' };
|
||||
|
||||
/**
|
||||
* Frame figures over `ms` of animation frames: average fps, frame-time percentiles, the share of frames over 33.4 ms
|
||||
* (below 30 fps), long tasks, the sim's ticks and their cost (runner.stats), and the view's own CPU / render times.
|
||||
* The first frame time is partial (from the call to the first frame) and dropped, so the sample runs for at least two
|
||||
* frames whatever `ms` is: a sample that saw a single frame (a short `ms`, a frame longer than `ms`) had no frame time
|
||||
* left and divided by zero.
|
||||
*/
|
||||
perf.sample = (ms = 10000) => new Promise((resolve) => {
|
||||
const deltas = [];
|
||||
let last = performance.now();
|
||||
const t0 = last;
|
||||
const r0 = { ...perf.runner.stats() };
|
||||
const lt0 = longTasks.length;
|
||||
const step = (t) => {
|
||||
deltas.push(t - last);
|
||||
last = t;
|
||||
if (t - t0 < ms || deltas.length < 2) { requestAnimationFrame(step); return; }
|
||||
deltas.shift();
|
||||
const r1 = perf.runner.stats();
|
||||
const v = perf.view.stats();
|
||||
const lt = longTasks.slice(lt0);
|
||||
const ticks = r1.ticks - r0.ticks;
|
||||
resolve({
|
||||
...frameFigures(deltas, FRAME_30_MS),
|
||||
longTasks: lt.length, longTaskMs: Math.round(lt.reduce((a, b) => a + b, 0)),
|
||||
ticks, simMs: round(r1.stepMs - r0.stepMs), simMsPerTick: ticks > 0 ? round((r1.stepMs - r0.stepMs) / ticks, 3) : null,
|
||||
viewCpuMs: v.cpuMs, renderMs: v.renderMs, units: v.units, lod: v.lod, particles: v.particles,
|
||||
board: v.board3d?.on ? '3d' : '2d', done: !!perf.runner.state()?.done,
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
/** The device line of a report: renderer, screen, cores (what a phone report needs to be comparable). */
|
||||
function deviceInfo() {
|
||||
let gpu = '?';
|
||||
try {
|
||||
const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
|
||||
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
|
||||
gpu = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : (gl ? gl.getParameter(gl.RENDERER) : 'no WebGL');
|
||||
} catch { /* ignore */ }
|
||||
return {
|
||||
ua: navigator.userAgent, gpu, cores: navigator.hardwareConcurrency || null, memoryGB: navigator.deviceMemory || null,
|
||||
viewport: `${innerWidth}×${innerHeight}`, dpr: devicePixelRatio,
|
||||
};
|
||||
}
|
||||
|
||||
/** The copyable report: one item per line, the panel's terms and units (pasted into feedback as is). */
|
||||
function report(spec, title, quality, s) {
|
||||
const d = deviceInfo();
|
||||
const lines = [
|
||||
'战斗性能测试报告(/dev/battle-perf.html)',
|
||||
`场景:${title}(${spec})`,
|
||||
`画质:${QUALITY_NAME[quality] || quality};棋盘:${s.board === '3d' ? '3D' : '2D'}`,
|
||||
`帧率:${s.fps} FPS`,
|
||||
`帧耗时:P50 ${s.p50} ms,P95 ${s.p95} ms,P99 ${s.p99} ms`,
|
||||
`卡顿帧占比(单帧 > 33.3 ms):${s.over33}%`,
|
||||
`模拟单步:${s.simMsPerTick} ms(共 ${s.ticks} 步)`,
|
||||
`渲染 CPU:${s.viewCpuMs} ms;渲染提交:${s.renderMs} ms`,
|
||||
`场上单位:${s.units};自适应负载档位:${s.lod}`,
|
||||
`长任务:${s.longTasks} 次,共 ${s.longTaskMs} ms`,
|
||||
`GPU:${d.gpu}`,
|
||||
`设备:${d.cores ?? '未知'} 个 CPU 线程${d.memoryGB ? `,内存约 ${d.memoryGB} GB` : ''};视口 ${d.viewport},像素比 ${d.dpr}`,
|
||||
`浏览器:${d.ua}`,
|
||||
];
|
||||
if (s.done) lines.splice(10, 0, '注意:战斗在测量期间结束,结果仅供参考');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** The verdict a player reads first, on the frame-time budgets of the strip (60 / 30 FPS). */
|
||||
export function verdictOf(s) {
|
||||
if (s.p95 <= FRAME_60_MS && s.over33 < 1) return { label: '流畅', note: `P95 帧耗时 ${s.p95} ms,达到 60 FPS`, color: '--mint-500' };
|
||||
if (s.p95 <= FRAME_30_MS && s.over33 < 1) return { label: '基本流畅', note: `P95 帧耗时 ${s.p95} ms,介于 30–60 FPS`, color: '--amber' };
|
||||
return { label: '卡顿', note: `卡顿帧占比 ${s.over33}%,P95 帧耗时 ${s.p95} ms`, color: '--red' };
|
||||
}
|
||||
|
||||
// ---- frame ring buffer + strip ------------------------------------------------------------------------------------
|
||||
|
||||
const RING = 4096;
|
||||
const ringT = new Float64Array(RING); // frame end (performance.now)
|
||||
const ringD = new Float32Array(RING); // frame time (ms)
|
||||
let ringN = 0;
|
||||
let lastT = 0;
|
||||
function recordFrames(t) {
|
||||
if (lastT) { const i = ringN % RING; ringT[i] = t; ringD[i] = t - lastT; ringN++; }
|
||||
lastT = t;
|
||||
requestAnimationFrame(recordFrames);
|
||||
}
|
||||
|
||||
const SPAN_MS = 10000; // the strip shows the last 10 s
|
||||
const TOP_MS = 50; // frame times above clip to the top (a red spike)
|
||||
let palette = null;
|
||||
function colors() {
|
||||
if (!palette) {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const v = (n) => cs.getPropertyValue(n).trim();
|
||||
palette = { good: v('--mint-500'), warn: v('--amber'), bad: v('--red'), line: v('--line-2'), text: v('--text-lo') };
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
const band = (ms) => (ms <= FRAME_60_MS ? 'good' : ms <= FRAME_30_MS ? 'warn' : 'bad');
|
||||
|
||||
/** Draw the last SPAN_MS of frame times into `cv`: one column per pixel (its slowest frame), budget lines at 60 / 30 fps. */
|
||||
function drawStrip(cv, { labels = true } = {}) {
|
||||
const w = cv.clientWidth, h = cv.clientHeight;
|
||||
if (!w || !h) return;
|
||||
const dpr = Math.min(2, devicePixelRatio || 1);
|
||||
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
|
||||
const g = cv.getContext('2d');
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
g.clearRect(0, 0, w, h);
|
||||
const c = colors();
|
||||
const pad = labels ? 4 : 1;
|
||||
const y = (ms) => h - pad - (Math.min(ms, TOP_MS) / TOP_MS) * (h - pad * 2);
|
||||
g.fillStyle = c.line;
|
||||
for (const ms of [16.7, 33.3]) g.fillRect(0, Math.round(y(ms)), w, 1);
|
||||
if (labels) {
|
||||
g.fillStyle = c.text;
|
||||
g.font = '10px "Noto Sans SC", sans-serif';
|
||||
g.textAlign = 'right';
|
||||
g.fillText('60 FPS', w - 3, y(16.7) - 3);
|
||||
g.fillText('30 FPS', w - 3, y(33.3) - 3);
|
||||
}
|
||||
const now = performance.now();
|
||||
const cols = new Float32Array(Math.ceil(w));
|
||||
for (let k = 1; k <= Math.min(ringN, RING); k++) {
|
||||
const i = (ringN - k) % RING;
|
||||
const age = now - ringT[i];
|
||||
if (age > SPAN_MS) break;
|
||||
const x = Math.floor(w - 1 - (age / SPAN_MS) * w);
|
||||
if (x >= 0 && ringD[i] > cols[x]) cols[x] = ringD[i];
|
||||
}
|
||||
const barW = labels ? 1 : 1.5;
|
||||
for (let x = 0; x < cols.length; x++) {
|
||||
const ms = cols[x];
|
||||
if (!ms) continue;
|
||||
g.fillStyle = c[band(ms)];
|
||||
const top = y(ms);
|
||||
g.fillRect(x, top, barW, h - pad - top);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- panel ----------------------------------------------------------------------------------------------------------
|
||||
|
||||
const PANEL_KEY = 'sp.dev.battlePerfPanel';
|
||||
function loadPanelPref() {
|
||||
try { return localStorage.getItem(PANEL_KEY); } catch { return null; }
|
||||
}
|
||||
function savePanelPref(v) {
|
||||
try { localStorage.setItem(PANEL_KEY, v); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
function setupPanel() {
|
||||
const panel = $('panel');
|
||||
const set = (collapsed, save = true) => {
|
||||
panel.classList.toggle('is-collapsed', collapsed);
|
||||
$('expand').setAttribute('aria-expanded', String(!collapsed));
|
||||
$('collapse').setAttribute('aria-expanded', String(!collapsed));
|
||||
if (save) savePanelPref(collapsed ? 'collapsed' : 'open');
|
||||
};
|
||||
const pref = loadPanelPref();
|
||||
// phones (a landscape phone is short): start folded, the battle is what is being measured
|
||||
set(pref ? pref === 'collapsed' : (innerWidth < 900 || innerHeight < 500), false);
|
||||
$('collapse').onclick = () => { set(true); $('expand').focus(); };
|
||||
$('expand').onclick = () => { set(false); $('collapse').focus(); };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (q.get('panel') === '0') $('panel').classList.add('is-hidden');
|
||||
setupPanel();
|
||||
const index = await fetch('/dev/perf/index.json').then((r) => (r.ok ? r.json() : []), () => []);
|
||||
const specName = q.get('spec') || index[0]?.name;
|
||||
if (!specName) throw new Error('未找到战斗数据。请先运行 node tools/capture-specs.mjs 生成。');
|
||||
const quality = ['high', 'medium', 'low'].includes(q.get('quality')) ? q.get('quality') : 'high';
|
||||
for (const s of index) $('spec').append(new Option(s.title || s.name, s.name));
|
||||
$('spec').value = specName;
|
||||
$('quality').value = quality;
|
||||
const reload = () => {
|
||||
const p = new URLSearchParams(location.search);
|
||||
p.set('spec', $('spec').value);
|
||||
p.set('quality', $('quality').value);
|
||||
location.search = p.toString();
|
||||
};
|
||||
$('restart').onclick = reload;
|
||||
$('spec').onchange = reload;
|
||||
$('quality').onchange = reload;
|
||||
|
||||
await data.loadAll('chess', 'tokens', 'items', 'enemies', 'stages', 'bonds', 'config');
|
||||
await assets.ready();
|
||||
const msg = await fetch(`/dev/perf/${encodeURIComponent(specName)}.json`).then((r) => {
|
||||
if (!r.ok) throw new Error(`战斗数据 ${specName} 加载失败(HTTP ${r.status})。请确认该文件位于 public/dev/perf/。`);
|
||||
return r.json();
|
||||
});
|
||||
const view = await createFieldView($('field'), { data, assets, settings: { quality, damageNumbers: true } });
|
||||
view.setStage(data.lookup('stages', msg.spec.stageId));
|
||||
// the runner needs no server here: reports go nowhere, the battle runs from its start
|
||||
const net = { on: () => () => {}, send() {}, request: async () => ({ ok: true }) };
|
||||
const runner = createBattleRunner({ net, store: null });
|
||||
const camKind = msg.kind === 'hidden' ? 'boss' : msg.kind;
|
||||
runner.on('field', (field) => { view.enterBattle(field); view.setCamera(camKind, { rect: field.rect, side: 'L', instant: true }); });
|
||||
runner.on('snap', (snap) => view.pushSnapshot(snap));
|
||||
runner.on('ev', (ev) => view.pushEvents(ev));
|
||||
perf.view = view;
|
||||
perf.runner = runner;
|
||||
await runner.onStart({ ...msg, elapsed: 0, authoritative: true, watch: false, done: false });
|
||||
perf.ready = true;
|
||||
// frame times from here on: the loading hitches before the battle are not the battle's
|
||||
requestAnimationFrame(recordFrames);
|
||||
|
||||
let measuring = false;
|
||||
let prev = { ...runner.stats() };
|
||||
const live = () => {
|
||||
if (measuring) return;
|
||||
const v = view.stats();
|
||||
const r = runner.stats();
|
||||
const ticks = r.ticks - prev.ticks;
|
||||
const simMs = ticks > 0 ? (r.stepMs - prev.stepMs) / ticks : 0;
|
||||
prev = { ...r };
|
||||
const fps = Math.round(v.fps);
|
||||
$('fps').textContent = fps;
|
||||
$('f-frame').textContent = `${v.frameMs.toFixed(1)} ms`;
|
||||
$('f-view').textContent = `${v.cpuMs.toFixed(1)} ms`;
|
||||
$('f-sim').textContent = `${simMs.toFixed(2)} ms`;
|
||||
$('chip-fps').textContent = fps;
|
||||
$('chip-ms').textContent = `FPS / ${v.frameMs.toFixed(1)} ms`;
|
||||
const st = runner.state();
|
||||
$('meta').textContent = st?.done
|
||||
? '战斗已结束。如需继续测量,请点击「重新开始」。'
|
||||
: `场上单位 ${v.units} 个,自适应负载 ${v.lod} 档,${v.board3d?.on ? '3D' : '2D'} 棋盘`;
|
||||
};
|
||||
setInterval(live, 500);
|
||||
setInterval(() => {
|
||||
if (measuring) return;
|
||||
if ($('panel').classList.contains('is-collapsed')) drawStrip($('chip-strip'), { labels: false });
|
||||
else drawStrip($('strip'));
|
||||
}, 250);
|
||||
|
||||
const btn = $('measure');
|
||||
btn.disabled = false;
|
||||
btn.onclick = async () => {
|
||||
if (measuring) return;
|
||||
measuring = true;
|
||||
btn.classList.add('is-running');
|
||||
btn.setAttribute('aria-disabled', 'true');
|
||||
$('freeze').hidden = false;
|
||||
const t0 = Date.now();
|
||||
const tick = setInterval(() => { $('measure-label').textContent = `测量中(剩余 ${Math.max(1, 10 - Math.floor((Date.now() - t0) / 1000))} 秒)`; }, 1000);
|
||||
$('measure-label').textContent = '测量中(剩余 10 秒)';
|
||||
const s = await perf.sample(10000);
|
||||
clearInterval(tick);
|
||||
measuring = false;
|
||||
btn.classList.remove('is-running');
|
||||
btn.removeAttribute('aria-disabled');
|
||||
$('freeze').hidden = true;
|
||||
$('measure-label').textContent = '重新测量(10 秒)';
|
||||
|
||||
const v = verdictOf(s);
|
||||
const verdict = $('verdict');
|
||||
verdict.style.setProperty('--v', `var(${v.color})`);
|
||||
verdict.replaceChildren(document.createTextNode(v.label));
|
||||
const note = document.createElement('small');
|
||||
note.textContent = v.note;
|
||||
verdict.append(note);
|
||||
$('r-fps').textContent = s.fps;
|
||||
$('r-p95').textContent = `${s.p95} ms`;
|
||||
$('r-over').textContent = `${s.over33}%`;
|
||||
const text = report(specName, index.find((x) => x.name === specName)?.title || specName, quality, s);
|
||||
$('out').textContent = text;
|
||||
$('result').hidden = false;
|
||||
const copy = $('copy');
|
||||
copy.textContent = '复制报告';
|
||||
copy.onclick = () => {
|
||||
const done = () => { copy.textContent = '已复制到剪贴板'; };
|
||||
const fallback = () => {
|
||||
const d = $('result').querySelector('details');
|
||||
d.open = true;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents($('out'));
|
||||
const sel = getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
copy.textContent = '无法写入剪贴板,已选中报告文本,请手动复制';
|
||||
};
|
||||
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done, fallback);
|
||||
else fallback();
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
perf.error = String(err?.stack || err);
|
||||
console.error(err);
|
||||
const meta = document.getElementById('meta');
|
||||
if (meta) meta.textContent = `加载失败:${err?.message || err}`;
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// public/dev/frame-stats.js — the frame figures of the battle perf page (battle-perf.js), free of the DOM so the Node
|
||||
// tests can import them (test/perfbench.test.js).
|
||||
|
||||
/** `v` rounded to `d` decimals; null for NaN / ±Infinity (a figure the report shows as missing, not as 0). */
|
||||
export const round = (v, d = 1) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
|
||||
|
||||
/**
|
||||
* Average fps, frame-time percentiles (ms) and the share (%) of frames over `stutterMs`, from a sample's frame times.
|
||||
* Every figure is null without a frame time (perf.sample() always records one, but an empty run must not divide by 0).
|
||||
* @param {number[]} deltas frame times in ms (sorted in place)
|
||||
* @param {number} stutterMs a frame over this counts as a stutter frame
|
||||
*/
|
||||
export function frameFigures(deltas, stutterMs) {
|
||||
if (!deltas.length) return { fps: null, p50: null, p95: null, p99: null, over33: null };
|
||||
deltas.sort((a, b) => a - b);
|
||||
const pick = (p) => deltas[Math.min(deltas.length - 1, Math.floor(deltas.length * p))];
|
||||
const avg = deltas.reduce((a, b) => a + b, 0) / deltas.length;
|
||||
return {
|
||||
fps: avg > 0 ? round(1000 / avg) : null, p50: round(pick(0.5)), p95: round(pick(0.95)), p99: round(pick(0.99)),
|
||||
over33: round((deltas.filter((x) => x > stutterMs).length / deltas.length) * 100),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"t":"b.start","battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","spec":{"v":1,"battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","seed":3380376830,"modeId":"mode_multi_hard","round":14,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":98},"swiftShip":{"count":3,"active":true,"tier":1,"layers":82},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":26},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1572,"contentInfo":{"round":14,"kind":"boss","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":10,"gainedChess":0,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":11,"col":4,"dir":"DOWN","items":[],"skillIndex":2},{"uid":73,"kind":"chess","chessId":"chess_char_1_01_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_inside"},{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":10,"col":7,"dir":"RIGHT","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_a"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tier":0Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"t":"b.start","battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","spec":{"v":1,"battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","seed":725852008,"modeId":"mode_multi_hard","round":15,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_3_03_e_a"],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_05_e_a","chess_item_4_02_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":108},"swiftShip":{"count":3,"active":true,"tier":1,"layers":92},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":32},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1205,"contentInfo":{"round":15,"kind":"hidden","shopLevel":6,"funds":0,"handUnits":6,"handChess":6,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":11,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":11,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":274,"kind":"chess","chessId":"chess_char_2_17_b","row":10,"col":7,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_branch"},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_b"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"t":"b.start","battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","spec":{"v":1,"battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","seed":720961930,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":58},"preciShip":{"count":3,"active":true,"tier":2,"layers":80},"swiftShip":{"count":3,"active":true,"tier":1,"layers":64},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"normal","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":7,"enemyKey":"enemy_1166_dusbr_2","routeIndex":0,"count":3,"interval":3,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":0,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":10,"enemyKey":"enemy_1166_dusbr_2","routeIndex":1,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":1,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":39,"enemyKey":"enemy_1166_dusbr_2","routeIndex":2,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":2,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":62,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":3,"interval":4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":3,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":11,"enemyKey":"enemy_1169_duphlx_2","routeIndex":4,"count":5,"interval":5.4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":37,"enemyKey":"enemy_1169_duphlx_2","routeIndex":5,"count":5,"interval":7.2,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[9,10]Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"t":"b.start","battleId":"7.13.62.u","fieldId":"u","kind":"unite","spec":{"v":1,"battleId":"7.13.62.u","fieldId":"u","kind":"unite","seed":3495193848,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":8,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"carryState":{"hpPct":0.3342892236024844,"sp":0}},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo","carryState":{"down":true}},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst","carryState":{"hpPct":1,"sp":8}},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn","carryState":{"down":true}},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd","carryState":{"down":true}},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma","carryState":{"hpPct":1,"sp":0}},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2,"carryState":{"hpPct":0.7218991466789668,"sp":0}},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2,"carryState":{"down":true}}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":88},"swiftShip":{"count":3,"active":true,"tier":1,"layers":72},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"unite","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"ai_3","seat":3,"side":"L","colOffset":0,"units":[{"uid":80,"kind":"chess","chessId":"chess_char_3_11_a","row":12,"col":4,"dir":"RIGHT","items":["chess_item_1_02_e_b","chess_item_6_04_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":13.13}},{"uid":143,"kind":"chess","chessId":"chess_char_4_22_a","row":12,"col":5,"dir":"DOWN","items":["chess_item_3_02_e_a","chess_item_6_09_e_a"],"skillIndex":2,"carryState":{"hpPct":1,"sp":0.17}},{"uid":158,"kind":"chess","chessId":"chess_char_5_11_a","row":12,"col":6,"dir":"RIGHT","items":["chess_item_4_06_e_a","chess_item_4_04_e_b"],"skillIndex":1,"carryState":{"hpPct":0.9422580123247037,"sp":4.57}},{"uid":112,"kind":"chess","chessId":"chess_char_4_13_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_10_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":1.85}},{"uid":59,"kind":"chess","chessId":"chess_char_3_20_a","row":11,"col":5,"dir":"RIGHT","items":["chess_item_6_06_e_a","chess_item_6_07_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":0}},{"uid":146,"kind":"chess","chessId":"chess_char_4_21_a","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"carryState":{"down":true}},{"uid":81,"kind":"chess","chessId":"chess_char_3_14_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_6_01_e_a","chess_item_3_11_e_b"],"skillIndex":1,"carrySLine truncated
|
||||
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"name": "hard-normal-r13",
|
||||
"title": "普通战 · 第 13 回合",
|
||||
"kind": "normal",
|
||||
"round": 13,
|
||||
"stageId": "act2autochess_m01",
|
||||
"players": 1,
|
||||
"units": 8
|
||||
},
|
||||
{
|
||||
"name": "hard-unite-r13",
|
||||
"title": "联防 · 第 13 回合",
|
||||
"kind": "unite",
|
||||
"round": 13,
|
||||
"stageId": "act2autochess_m01",
|
||||
"players": 2,
|
||||
"units": 16
|
||||
},
|
||||
{
|
||||
"name": "hard-boss-r14",
|
||||
"title": "最终攻势 · 第 14 回合",
|
||||
"kind": "boss",
|
||||
"round": 14,
|
||||
"stageId": "act2autochess_m01",
|
||||
"players": 2,
|
||||
"units": 16
|
||||
},
|
||||
{
|
||||
"name": "hard-hidden-r15",
|
||||
"title": "隐秘核心 · 第 15 回合",
|
||||
"kind": "hidden",
|
||||
"round": 15,
|
||||
"stageId": "act2autochess_m01",
|
||||
"players": 2,
|
||||
"units": 16
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
// test/perfbench.test.js — the battle perf tooling: the committed specs of public/dev/perf (tools/capture-specs.mjs) still
|
||||
// build and run with the current data and sim, the capture helpers, the perf page's frame figures (public/dev/frame-stats.js),
|
||||
// and tools/perfbench.mjs's profile summary and per-platform Chrome flags. The page itself:
|
||||
// test/render/battle-perf.browser.test.js (opt-in).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getData } from '../server/data.js';
|
||||
import { DataSource } from '../server/sim/simdata.js';
|
||||
import { createBattleFromSpec } from '../server/sim/spec.js';
|
||||
import { specMeta, pickDefaults, cleanStart } from '../tools/capture-specs.mjs';
|
||||
import { aggregateProfile, gpuArgs, areaOf } from '../tools/perfbench.mjs';
|
||||
import { frameFigures } from '../public/dev/frame-stats.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DIR = path.join(ROOT, 'public/dev/perf');
|
||||
const index = JSON.parse(readFileSync(path.join(DIR, 'index.json'), 'utf8'));
|
||||
|
||||
describe('committed perf specs (public/dev/perf)', () => {
|
||||
const ds = new DataSource(getData({ log: { warn() {}, error() {}, info() {} } }), null);
|
||||
|
||||
test('the index lists one spec of each kind, each file a clean b.start', () => {
|
||||
assert.deepEqual(index.map((s) => s.kind), ['normal', 'unite', 'boss', 'hidden']);
|
||||
for (const s of index) {
|
||||
const f = path.join(DIR, `${s.name}.json`);
|
||||
assert.ok(existsSync(f), s.name);
|
||||
const msg = JSON.parse(readFileSync(f, 'utf8'));
|
||||
assert.equal(msg.t, 'b.start');
|
||||
assert.equal(msg.kind, s.kind);
|
||||
assert.equal(msg.elapsed, 0);
|
||||
assert.equal(msg.authoritative, true);
|
||||
assert.equal(msg.spec.stageId, s.stageId);
|
||||
assert.equal(msg.spec.players.length, s.players);
|
||||
}
|
||||
});
|
||||
|
||||
for (const s of index) {
|
||||
test(`${s.name}: builds with the current data and runs 20 game seconds`, () => {
|
||||
const msg = JSON.parse(readFileSync(path.join(DIR, `${s.name}.json`), 'utf8'));
|
||||
const b = createBattleFromSpec(msg.spec, ds, { recordEvents: false });
|
||||
for (let i = 0; i < 600 && !b.finished; i++) b.step();
|
||||
assert.ok(b.finished || b.time >= 19.9, `battle time ${b.time}`);
|
||||
const snap = b.snapshot();
|
||||
assert.ok(Array.isArray(snap.units) && snap.units.length > 0, 'units on the field');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('tools/capture-specs.mjs helpers', () => {
|
||||
const start = (kind, n) => ({ t: 'b.start', kind, spec: { stageId: 'st', players: [{ units: Array(n).fill({}) }, { units: Array(n).fill({}) }] }, elapsed: 3.5, startAt: 9, serverNow: 9, authoritative: false });
|
||||
test('specMeta names and counts a b.start', () => {
|
||||
assert.deepEqual(specMeta('HARD', start('unite', 8), 9), { name: 'hard-unite-r9', title: '联防 · 第 9 回合', kind: 'unite', round: 9, stageId: 'st', players: 2, units: 16 });
|
||||
});
|
||||
test('pickDefaults keeps the latest normal / unite / boss / hidden, the last send of a resent battle', () => {
|
||||
const resent = start('boss', 8);
|
||||
const picked = pickDefaults([
|
||||
{ round: 4, msg: start('normal', 1) }, { round: 13, msg: start('normal', 8) }, { round: 7, msg: start('unite', 4) },
|
||||
{ round: 9, msg: start('unite', 8) }, { round: 14, msg: start('boss', 8) }, { round: 14, msg: resent },
|
||||
]);
|
||||
assert.deepEqual(picked.map((c) => `${c.msg.kind}-r${c.round}`), ['normal-r13', 'unite-r9', 'boss-r14']);
|
||||
assert.equal(picked[2].msg, resent);
|
||||
});
|
||||
test('cleanStart: from the start, authoritative, no capture clocks', () => {
|
||||
const c = cleanStart(start('normal', 1));
|
||||
assert.deepEqual([c.elapsed, c.startAt, c.serverNow, c.authoritative, c.watch, c.done], [0, 0, 0, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the perf page's frame figures (public/dev/frame-stats.js)", () => {
|
||||
test('average fps, nearest-rank percentiles and the stutter share of a sample', () => {
|
||||
assert.deepEqual(frameFigures([40, 16, 17, 17], 33.4), { fps: 44.4, p50: 17, p95: 40, p99: 40, over33: 25 });
|
||||
});
|
||||
|
||||
test('one frame time is enough; no frame time gives null figures, not NaN / Infinity (the single-frame sample)', () => {
|
||||
assert.deepEqual(frameFigures([16.7], 33.4), { fps: 59.9, p50: 16.7, p95: 16.7, p99: 16.7, over33: 0 });
|
||||
assert.deepEqual(frameFigures([], 33.4), { fps: null, p50: null, p95: null, p99: null, over33: null });
|
||||
assert.equal(frameFigures([0, 0], 33.4).fps, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tools/perfbench.mjs', () => {
|
||||
test('Chrome GPU flags per platform: D3D11 on Windows, Metal on macOS, the default elsewhere', () => {
|
||||
assert.ok(gpuArgs('win32').includes('--use-angle=d3d11'));
|
||||
assert.ok(gpuArgs('darwin').includes('--use-angle=metal'));
|
||||
assert.ok(!gpuArgs('linux').some((a) => a.startsWith('--use-angle')));
|
||||
});
|
||||
|
||||
test('areaOf sorts call frames by source', () => {
|
||||
assert.equal(areaOf({ url: 'http://h/vendor/pixi-spine.js', functionName: 'apply' }), 'spine');
|
||||
assert.equal(areaOf({ url: 'http://h/vendor/pixi.min.js', functionName: 'render' }), 'pixi');
|
||||
assert.equal(areaOf({ url: 'http://h/sim/Battle.js', functionName: 'step' }), 'sim');
|
||||
assert.equal(areaOf({ url: '', functionName: '(idle)' }), 'idle');
|
||||
});
|
||||
|
||||
test('aggregateProfile: idle excluded; self time by area / function; stages inclusive, once per stack', () => {
|
||||
const cf = (functionName, url) => ({ functionName, url, lineNumber: 0 });
|
||||
const profile = {
|
||||
nodes: [
|
||||
{ id: 1, callFrame: cf('(root)', ''), children: [2, 6, 7] },
|
||||
{ id: 2, callFrame: cf('frameBody', 'http://h/js/render/app.js'), children: [3] },
|
||||
{ id: 3, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [4] },
|
||||
{ id: 4, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [5] }, // recursion: counted once
|
||||
{ id: 5, callFrame: cf('apply', 'http://h/vendor/pixi-spine.js'), children: [] },
|
||||
{ id: 6, callFrame: cf('(idle)', ''), children: [] },
|
||||
{ id: 7, callFrame: cf('(program)', ''), children: [] },
|
||||
],
|
||||
samples: [5, 5, 3, 6, 6, 7],
|
||||
timeDeltas: [1000, 1000, 1000, 5000, 5000, 1000],
|
||||
};
|
||||
const s = aggregateProfile(profile);
|
||||
assert.equal(s.busyMs, 4);
|
||||
assert.deepEqual(s.areas.map((a) => [a.area, a.pct]), [['spine', 50], ['render', 25], ['program', 25]]);
|
||||
const st = Object.fromEntries(s.stages.map((x) => [x.stage, x.pct]));
|
||||
assert.equal(st.spinePose, 75);
|
||||
assert.equal(st.frameBody, 75);
|
||||
assert.equal(st.program, 25);
|
||||
assert.equal(st.sim, 0);
|
||||
assert.match(s.top[0].fn, /^apply \/vendor\/pixi-spine\.js:1$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// test/render/battle-perf.browser.test.js — the battle perf page (public/dev/battle-perf.html) in headless Chrome: every
|
||||
// committed spec starts, the battle runner steps the sim and the field view draws it, with a clean console; the sample
|
||||
// hook tools/perfbench.mjs reads returns its figures.
|
||||
//
|
||||
// Opt-in (starts Chrome, needs the downloaded assets): RENDER_E2E=1 node --test test/render/battle-perf.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the platform default of tools/perfbench.mjs.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gpuArgs } from '../../tools/perfbench.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHROME = process.env.CHROME_PATH || {
|
||||
win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
linux: '/usr/bin/google-chrome',
|
||||
}[process.platform];
|
||||
const enabled = process.env.RENDER_E2E === '1' && !!CHROME && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and the downloaded assets)';
|
||||
const index = JSON.parse(readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8'));
|
||||
|
||||
describe('battle perf page in headless Chrome', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: gpuArgs() });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
for (const s of index) {
|
||||
test(`${s.name}: the battle runs and draws, the sample hook reports`, async () => {
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||||
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2 });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${s.name}`);
|
||||
await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 });
|
||||
assert.equal(await page.evaluate(() => window.__perf.error), null);
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const r = await page.evaluate(() => window.__perf.sample(2000));
|
||||
const short = await page.evaluate(() => window.__perf.sample(0)); // still a full frame time, not 0 / 0
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
assert.ok(r.fps > 10, `fps ${r.fps}`);
|
||||
assert.ok(r.ticks > 60, `sim ticks ${r.ticks}`);
|
||||
assert.ok(r.units > 0, `units ${r.units}`);
|
||||
assert.ok(Number.isFinite(r.p95) && r.p95 > 0);
|
||||
assert.ok(Number.isFinite(short.fps) && short.fps > 0 && short.p95 > 0, `sample(0): ${JSON.stringify(short)}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/capture-specs.mjs — capture real BattleSpecs for the battle perf page (public/dev/battle-perf.html) and
|
||||
// tools/perfbench.mjs: the b.start a browser receives under client-side combat (DESIGN §14), taken from a headless bot
|
||||
// match (virtual time, real simulation, like tools/matchrun.mjs). Co-op with 4 seats: the first two are humans on AI
|
||||
// autoplay (so the match sends b.start to seat 1, whose battles are kept) and two bots; every player starts with a high
|
||||
// LP so the match reaches the Final Assault and the Hidden Core. By default the last normal round, the last 联防 field
|
||||
// of seat 1, the Final Assault and the Hidden Core are written:
|
||||
//
|
||||
// <out>/<difficulty>-<kind>-r<round>.json the b.start message (elapsed / clocks zeroed, authoritative)
|
||||
// <out>/index.json [{ name, title, kind, round, stageId, players, units }]
|
||||
//
|
||||
// The match is deterministic for a seed, so a plain run reproduces the committed specs byte for byte as long as the
|
||||
// game data and the bots do not change.
|
||||
//
|
||||
// Usage: node tools/capture-specs.mjs [--difficulty HARD] [--seed 7] [--out public/dev/perf] [--all]
|
||||
// --all write every battle seat 1 played, not only the four defaults
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Match } from '../server/match/Match.js';
|
||||
import { VirtualScheduler } from '../server/match/scheduler.js';
|
||||
import { getData } from '../server/data.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const KIND_TITLE = { normal: '普通战', unite: '联防', boss: '最终攻势', hidden: '隐秘核心' };
|
||||
|
||||
/** One spec's file name and index row. */
|
||||
export function specMeta(difficulty, msg, round) {
|
||||
const spec = msg.spec || {};
|
||||
const players = Array.isArray(spec.players) ? spec.players : [];
|
||||
return {
|
||||
name: `${difficulty.toLowerCase()}-${msg.kind}-r${round}`,
|
||||
title: `${KIND_TITLE[msg.kind] || msg.kind} · 第 ${round} 回合`,
|
||||
kind: msg.kind, round, stageId: spec.stageId,
|
||||
players: players.length,
|
||||
units: players.reduce((n, p) => n + (Array.isArray(p?.units) ? p.units.length : 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** The defaults: the last normal round, the last 联防, the Final Assault and the Hidden Core (latest send of each). */
|
||||
export function pickDefaults(captured) {
|
||||
const last = new Map();
|
||||
for (const c of captured) last.set(`${c.msg.kind}-r${c.round}`, c);
|
||||
const all = [...last.values()];
|
||||
const latest = (kind) => all.filter((c) => c.msg.kind === kind).sort((a, b) => b.round - a.round)[0];
|
||||
return ['normal', 'unite', 'boss', 'hidden'].map(latest).filter(Boolean);
|
||||
}
|
||||
|
||||
/** A b.start as the perf page starts it: from the beginning, authoritative, no clocks of the capturing run. */
|
||||
export function cleanStart(msg) {
|
||||
return { ...msg, elapsed: 0, startAt: 0, serverNow: 0, authoritative: true, watch: false, done: false };
|
||||
}
|
||||
|
||||
/** Run one bot match and return seat 1's b.start messages in order: [{ round, msg }]. */
|
||||
export function captureMatch({ difficulty = 'HARD', seed = 7, data = getData({ log: { warn() {}, error() {}, info() {} } }) } = {}) {
|
||||
const sched = new VirtualScheduler();
|
||||
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: i < 2 ? `p_${i}` : `ai_${i}`, name: i < 2 ? `P${i + 1}` : `AI-${i + 1}`, isBot: i >= 2, connected: true }));
|
||||
const captured = [];
|
||||
let summary = null;
|
||||
const m = new Match({
|
||||
roomCode: 'PERF', mode: 'coop', difficulty, seats, seed, data, scheduler: sched, clientCombat: true,
|
||||
log: { info() {}, debug() {}, warn() {}, error() {} },
|
||||
send: (pid, msg) => {
|
||||
if (pid === 'p_0' && msg && msg.t === 'b.start' && !msg.watch) captured.push({ round: m.round, msg: JSON.parse(JSON.stringify(msg)) });
|
||||
return true;
|
||||
},
|
||||
broadcast: () => {}, onEnd: (s) => { summary = s; },
|
||||
});
|
||||
for (const ps of m.players.values()) if (!ps.isBot) ps.autoplay = true;
|
||||
let lpSet = false;
|
||||
m.start();
|
||||
sched.runUntil(() => {
|
||||
if (!lpSet && m.phase === 'PREP' && m.round === 1) { for (const ps of m.players.values()) ps.lp = 400; lpSet = true; }
|
||||
return summary != null;
|
||||
}, { maxSteps: 5e6 });
|
||||
m.dispose();
|
||||
return { captured, summary };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
|
||||
const difficulty = String(opt('difficulty', 'HARD')).toUpperCase();
|
||||
const seed = Number(opt('seed', 7));
|
||||
const out = path.resolve(ROOT, opt('out', 'public/dev/perf'));
|
||||
const { captured, summary } = captureMatch({ difficulty, seed });
|
||||
if (!summary) { console.error('the match did not end'); return 1; }
|
||||
const keep = argv.includes('--all') ? [...new Map(captured.map((c) => [`${c.msg.kind}-r${c.round}`, c])).values()] : pickDefaults(captured);
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const index = [];
|
||||
for (const c of keep) {
|
||||
const meta = specMeta(difficulty, c.msg, c.round);
|
||||
fs.writeFileSync(path.join(out, `${meta.name}.json`), JSON.stringify(cleanStart(c.msg)));
|
||||
index.push(meta);
|
||||
console.log(`${meta.name.padEnd(18)} ${meta.title} stage ${meta.stageId} players ${meta.players} units ${meta.units}`);
|
||||
}
|
||||
fs.writeFileSync(path.join(out, 'index.json'), `${JSON.stringify(index, null, 1)}\n`);
|
||||
console.log(`${index.length} specs → ${path.relative(ROOT, out)} (match: ${summary.reason}, rounds ${summary.roundsPassed})`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main();
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/perfbench.mjs — frame-time benchmark of real battles in headless Chrome (public/dev/battle-perf.html: the battle
|
||||
// runner + sim + field view, as in a match) for every spec × quality × CPU slowdown. Chrome DevTools CPU throttling
|
||||
// stands in for slower phones (4× ≈ mid-range, 6× ≈ low-end — an approximation: no phone GPU, thermals or Safari).
|
||||
// With --profile, a CPU profile of each sample is summarised by source area, by function (self time) and by pipeline
|
||||
// stage (inclusive time: Spine pose, impostor redraw, Pixi render, three.js board, battle runner, sim, …).
|
||||
//
|
||||
// Usage: CHROME_PATH=<chrome> node tools/perfbench.mjs [--specs a,b] [--quality high,low] [--cpu 1,4,6] [--warm 20]
|
||||
// [--sample 6] [--width 844] [--height 390] [--dpr 2] [--profile] [--out test/e2e/out/perf] [--json]
|
||||
// --specs names of public/dev/perf/index.json (default: all) --warm seconds before sampling (default 20)
|
||||
// --sample seconds per sample (default 6) --profile also record and summarise a CPU profile
|
||||
// Needs the downloaded assets (public/assets: real Spine models; without them every unit is a diamond and the numbers
|
||||
// mean little). Writes <out>/results.json (+ <spec>-<quality>-<cpu>x.cpuprofile / .summary.json with --profile).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_CHROME = {
|
||||
win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
linux: '/usr/bin/google-chrome',
|
||||
};
|
||||
|
||||
/** GPU flags for headless Chrome per platform (ANGLE backend; Metal exists on macOS only, D3D11 on Windows only). */
|
||||
export function gpuArgs(platform = process.platform) {
|
||||
const angle = platform === 'win32' ? ['--use-angle=d3d11'] : platform === 'darwin' ? ['--use-angle=metal'] : [];
|
||||
return ['--no-first-run', '--enable-gpu', '--ignore-gpu-blocklist', ...angle];
|
||||
}
|
||||
|
||||
/** Source area of a profile call frame (vendor libraries, the game's own modules, the browser's own work). */
|
||||
export function areaOf(cf) {
|
||||
const url = cf.url || '';
|
||||
if (!url) {
|
||||
if (cf.functionName === '(garbage collector)') return 'gc';
|
||||
if (cf.functionName === '(idle)') return 'idle';
|
||||
if (cf.functionName === '(program)') return 'program';
|
||||
return 'native';
|
||||
}
|
||||
if (/\/sim\//.test(url)) return 'sim';
|
||||
if (/\/js\/battle\//.test(url)) return 'runner';
|
||||
if (/\/js\/render\//.test(url)) return 'render';
|
||||
if (/\/js\/(ui|screens)\//.test(url)) return 'ui';
|
||||
if (/pixi-spine/.test(url)) return 'spine';
|
||||
if (/pixi(\.min)?\.js/.test(url)) return 'pixi';
|
||||
if (/three\./.test(url)) return 'three';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Pipeline stages measured inclusively (a sample counts once per stage on its stack). */
|
||||
export const STAGES = [
|
||||
['spinePose', 'Spine pose (actor.update)', (cf) => cf.functionName === 'update' && /render\/spine\.js/.test(cf.url)],
|
||||
['impostorFlush', 'impostor redraw (skeletons → atlas)', (cf) => cf.functionName === 'flush' && /render\/impostor\.js/.test(cf.url)],
|
||||
['unitUpdate', 'unit views (units.js update)', (cf) => cf.functionName === 'update' && /render\/units\.js/.test(cf.url)],
|
||||
['frameBody', 'view frame (all JS before the Pixi render)', (cf) => cf.functionName === 'frameBody' && /render\/app\.js/.test(cf.url)],
|
||||
['pixiRender', 'Pixi render (main + render textures)', (cf) => cf.functionName === 'render' && /pixi(\.min)?\.js/.test(cf.url)],
|
||||
['board3d', 'three.js board render', (cf) => cf.functionName === 'render' && /board3d\/scene\.js/.test(cf.url)],
|
||||
['runner', 'battle runner frame', (cf) => cf.functionName === 'frame' && /js\/battle\/runner\.js/.test(cf.url)],
|
||||
['sim', 'sim (any /sim/ module)', (cf) => /\/sim\//.test(cf.url || '')],
|
||||
['gc', 'garbage collector', (cf) => cf.functionName === '(garbage collector)'],
|
||||
['program', 'browser-native (program)', (cf) => cf.functionName === '(program)'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Summary of a CDP CPU profile ({ nodes, samples, timeDeltas }): busy time (idle excluded), the share of each source
|
||||
* area (self time), the top functions by self time, and each STAGES entry's inclusive share.
|
||||
*/
|
||||
export function aggregateProfile(profile, top = 30) {
|
||||
const nodes = new Map(profile.nodes.map((n) => [n.id, n]));
|
||||
const parent = new Map();
|
||||
for (const n of profile.nodes) for (const c of n.children || []) parent.set(c, n.id);
|
||||
const area = new Map(), fn = new Map(), stage = new Map(STAGES.map(([k]) => [k, 0]));
|
||||
let busy = 0;
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const dt = profile.timeDeltas[i] || 0;
|
||||
const leaf = nodes.get(profile.samples[i]);
|
||||
if (!leaf) continue;
|
||||
const a = areaOf(leaf.callFrame);
|
||||
if (a === 'idle') continue;
|
||||
busy += dt;
|
||||
area.set(a, (area.get(a) || 0) + dt);
|
||||
const cf = leaf.callFrame;
|
||||
const key = `${cf.functionName || '(anonymous)'} ${cf.url ? `${cf.url.replace(/^[a-z]+:\/\/[^/]+/, '')}:${cf.lineNumber + 1}` : ''}`.trim();
|
||||
fn.set(key, (fn.get(key) || 0) + dt);
|
||||
const hit = new Set();
|
||||
for (let id = profile.samples[i]; id != null; id = parent.get(id)) {
|
||||
const f = nodes.get(id).callFrame;
|
||||
for (const [k, , test] of STAGES) if (!hit.has(k) && test(f)) hit.add(k);
|
||||
}
|
||||
for (const k of hit) stage.set(k, stage.get(k) + dt);
|
||||
}
|
||||
const pct = (us) => (busy > 0 ? Math.round((us / busy) * 1000) / 10 : 0);
|
||||
const ms = (us) => Math.round(us / 100) / 10;
|
||||
return {
|
||||
busyMs: ms(busy),
|
||||
areas: [...area].sort((x, y) => y[1] - x[1]).map(([name, us]) => ({ area: name, ms: ms(us), pct: pct(us) })),
|
||||
stages: STAGES.map(([k, label]) => ({ stage: k, label, ms: ms(stage.get(k)), pct: pct(stage.get(k)) })),
|
||||
top: [...fn].sort((x, y) => y[1] - x[1]).slice(0, top).map(([name, us]) => ({ fn: name, ms: ms(us), pct: pct(us) })),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
|
||||
const list = (k, d) => String(opt(k, d)).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const index = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8'));
|
||||
const specs = argv.includes('--specs') ? list('specs', '') : index.map((s) => s.name);
|
||||
const qualities = list('quality', 'high');
|
||||
const cpus = list('cpu', '1,4,6').map(Number);
|
||||
const warm = Number(opt('warm', 20)), sample = Number(opt('sample', 6));
|
||||
const width = Number(opt('width', 844)), height = Number(opt('height', 390)), dpr = Number(opt('dpr', 2));
|
||||
const profileOn = argv.includes('--profile');
|
||||
const out = path.resolve(ROOT, opt('out', 'test/e2e/out/perf'));
|
||||
const chrome = process.env.CHROME_PATH || DEFAULT_CHROME[process.platform];
|
||||
if (!chrome || !fs.existsSync(chrome)) { console.error(`Chrome not found (${chrome}); set CHROME_PATH`); return 2; }
|
||||
if (!fs.existsSync(path.join(ROOT, 'public/assets/spine'))) console.warn('warning: public/assets/spine is missing (npm run setup): units draw as diamonds');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../server/index.js');
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: gpuArgs() });
|
||||
const results = [];
|
||||
try {
|
||||
for (const spec of specs) for (const quality of qualities) for (const cpu of cpus) {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setViewport({ width, height, deviceScaleFactor: dpr });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${encodeURIComponent(spec)}&quality=${quality}&panel=0`);
|
||||
await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 });
|
||||
const err = await page.evaluate(() => globalThis.__perf.error);
|
||||
if (err) { console.log(`${spec}: ${err.split('\n')[0]}`); await page.close(); continue; }
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpu });
|
||||
await new Promise((r) => setTimeout(r, warm * 1000));
|
||||
if (profileOn) {
|
||||
await cdp.send('Profiler.enable');
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 200 });
|
||||
await cdp.send('Profiler.start');
|
||||
}
|
||||
const s = await page.evaluate((ms) => globalThis.__perf.sample(ms), sample * 1000);
|
||||
let summary = null;
|
||||
if (profileOn) {
|
||||
const { profile } = await cdp.send('Profiler.stop');
|
||||
summary = aggregateProfile(profile);
|
||||
const base = path.join(out, `${spec}-${quality}-${cpu}x`);
|
||||
fs.writeFileSync(`${base}.cpuprofile`, JSON.stringify(profile));
|
||||
fs.writeFileSync(`${base}.summary.json`, JSON.stringify(summary, null, 1));
|
||||
}
|
||||
const rec = { spec, quality, cpu, ...s, profiled: profileOn, errors: errors.slice(0, 3) };
|
||||
results.push(rec);
|
||||
if (!argv.includes('--json')) {
|
||||
console.log(`${spec.padEnd(18)} ${quality.padEnd(6)} ${String(cpu).padStart(2)}x fps ${String(s.fps).padStart(6)} p95 ${String(s.p95).padStart(6)} ms >33ms ${String(s.over33).padStart(5)}%`
|
||||
+ ` sim ${s.simMsPerTick} ms/tick view CPU ${s.viewCpuMs} ms units ${s.units} load ${s.lod}${errors.length ? ` ERRORS ${errors.length}` : ''}`);
|
||||
if (summary) console.log(` ${summary.stages.filter((x) => x.pct > 0).map((x) => `${x.stage} ${x.pct}%`).join(' · ')}`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(out, 'results.json'), JSON.stringify(results, null, 1));
|
||||
await browser.close();
|
||||
await srv.close();
|
||||
}
|
||||
if (argv.includes('--json')) console.log(JSON.stringify(results));
|
||||
return results.some((r) => r.errors.length) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().then((code) => { process.exitCode = code; }, (err) => { console.error(err); process.exitCode = 1; });
|
||||
}
|
||||
Reference in new issue
Block a user