Files
ark-stronghold-protocol/test/perfbench.test.js
T
Cloudnyco e8ba091298 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).
2026-10-08 18:48:42 +08:00

124 lines
6.6 KiB
JavaScript

// 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$/);
});
});