From e8ba09129812fbef7c1d9a5edb36384ea3018a7c Mon Sep 17 00:00:00 2001 From: Cloudnyco Date: Thu, 8 Oct 2026 18:48:42 +0800 Subject: [PATCH] feedback7 G-TOOLS: add the battle performance bench under public/dev (PR #198 by @Cloudnyco) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 2 + public/dev/battle-perf.html | 128 +++++++++ public/dev/battle-perf.js | 331 ++++++++++++++++++++++++ public/dev/frame-stats.js | 22 ++ public/dev/perf/hard-boss-r14.json | 1 + public/dev/perf/hard-hidden-r15.json | 1 + public/dev/perf/hard-normal-r13.json | 1 + public/dev/perf/hard-unite-r13.json | 1 + public/dev/perf/index.json | 38 +++ test/perfbench.test.js | 123 +++++++++ test/render/battle-perf.browser.test.js | 61 +++++ tools/capture-specs.mjs | 103 ++++++++ tools/perfbench.mjs | 172 ++++++++++++ 13 files changed, 984 insertions(+) create mode 100644 public/dev/battle-perf.html create mode 100644 public/dev/battle-perf.js create mode 100644 public/dev/frame-stats.js create mode 100644 public/dev/perf/hard-boss-r14.json create mode 100644 public/dev/perf/hard-hidden-r15.json create mode 100644 public/dev/perf/hard-normal-r13.json create mode 100644 public/dev/perf/hard-unite-r13.json create mode 100644 public/dev/perf/index.json create mode 100644 test/perfbench.test.js create mode 100644 test/render/battle-perf.browser.test.js create mode 100644 tools/capture-specs.mjs create mode 100644 tools/perfbench.mjs diff --git a/README.md b/README.md index d88a2217..70170017 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/public/dev/battle-perf.html b/public/dev/battle-perf.html new file mode 100644 index 00000000..7183871b --- /dev/null +++ b/public/dev/battle-perf.html @@ -0,0 +1,128 @@ + + + + + + + Battle perf · 卫戍协议:盟约 + + + + + + + + + + +
+
+ +
+

战斗性能测试

+
+ + + +
+
+
–FPS
+
+ 帧耗时– + 渲染 CPU– + 模拟单步– +
+
正在加载战斗…
+
+
+ + +
+
≤ 16.7 ms(≥ 60 FPS)16.7–33.3 ms> 33.3 ms(卡顿帧)
+ + +
+
+ + + diff --git a/public/dev/battle-perf.js b/public/dev/battle-perf.js new file mode 100644 index 00000000..79d6b15f --- /dev/null +++ b/public/dev/battle-perf.js @@ -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/.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= (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}`; +}); diff --git a/public/dev/frame-stats.js b/public/dev/frame-stats.js new file mode 100644 index 00000000..b1b882e2 --- /dev/null +++ b/public/dev/frame-stats.js @@ -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), + }; +} diff --git a/public/dev/perf/hard-boss-r14.json b/public/dev/perf/hard-boss-r14.json new file mode 100644 index 00000000..080bac4f --- /dev/null +++ b/public/dev/perf/hard-boss-r14.json @@ -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":0,"layers":5},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":16},"lateranoShip":{"count":3,"active":true,"tier":1,"layers":4},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":0,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":3,"active":true,"tier":1,"layers":93},"preciShip":{"count":2,"active":true,"tier":1,"layers":83},"swiftShip":{"count":3,"active":true,"tier":1,"layers":86},"skillfulShip":{"count":1,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":2,"active":true,"tier":1,"layers":12},"steadShip":{"count":2,"active":true,"tier":1,"layers":32},"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":3},"investShip":{"count":1,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":0},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":0,"active":false,"tier":0,"layers":0},"suntShip":{"count":3,"active":true,"tier":1,"layers":0}},"bandId":"band_kirara","playerEffects":[],"deviceOverrides":{},"lpForBoss":1572,"contentInfo":{"round":14,"kind":"boss","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":2,"buys":4,"sells":2,"spent":13,"gainedChess":4,"arts":0},"matchBands":["band_kirara","band_sarkazb","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_0","bandId":"band_sarkazb"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":3,"enemyKey":"enemy_1402_tgshd_2","routeIndex":0,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":0,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":5,"enemyKey":"enemy_1402_tgshd_2","routeIndex":1,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":1,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":7,"enemyKey":"enemy_1006_shield_2","routeIndex":4,"count":2,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":25,"enemyKey":"enemy_1006_shield_2","routeIndex":5,"count":2,"interval":20,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":42,"enemyKey":"enemy_1006_shield_2","routeIndex":6,"count":3,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":6,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":7,"enemyKey":"enemy_1006_shield_2","routeIndex":7,"count":2,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":7,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":25,"enemyKey":"enemy_1006_shield_2","routeIndex":8,"count":2,"interval":35,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":8,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":42,"enemyKey":"enemy_1006_shield_2","routeIndex":9,"count":3,"interval":20,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":9,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":0,"enemyKey":"enemy_9013_acstmk","routeIndex":10,"count":1,"interval":0,"mods":{"atkMul":2.143589,"speedMul":1,"slot":"N"},"actionIndex":10,"preview":{"gate":"lower","start":[3,10],"fly":false,"elite":true,"boss":true},"tag":"boss"}],"routes":[{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"FLY","start":[5,10],"end":[1,3],"checkpoints":[[5,11],[2,11],[2,13],[5,13],[5,15],[2,15],[2,17],[1,17],[5,9],[2,9],[2,7],[5,7],[5,5],[2,5],[2,3]],"steps":[{"t":"move","p":[5,11]},{"t":"move","p":[2,11]},{"t":"move","p":[2,13]},{"t":"move","p":[5,13]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]},{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,9]},{"t":"move","p":[2,9]},{"t":"move","p":[2,7]},{"t":"move","p":[5,7]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]}]},{"motion":"FLY","start":[2,10],"end":[1,17],"checkpoints":[[2,9],[5,9],[5,8],[2,8],[2,6],[5,6],[5,5],[2,5],[2,3],[1,3],[5,12],[2,12],[2,14],[5,14],[5,15],[2,15],[2,17]],"steps":[{"t":"move","p":[2,9]},{"t":"move","p":[5,9]},{"t":"move","p":[5,8]},{"t":"move","p":[2,8]},{"t":"move","p":[2,6]},{"t":"move","p":[5,6]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]},{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,12]},{"t":"move","p":[2,12]},{"t":"move","p":[2,14]},{"t":"move","p":[5,14]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[3,10],"end":[2,2],"checkpoints":[[2,3]],"steps":[{"t":"wait","s":999},{"t":"move","p":[2,3]}]}],"flags":{"layerGainsEnabled":false,"dpInit":10,"dpPerSec":1,"dpMax":99,"enemyScale":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1}},"enemyOverrides":{},"waveId":"act1autochess_h07_01","bossId":"boss_1","content":"full","boss":{"poolHp":7200000,"poolMax":7200000}},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/hard-hidden-r15.json b/public/dev/perf/hard-hidden-r15.json new file mode 100644 index 00000000..df85455e --- /dev/null +++ b/public/dev/perf/hard-hidden-r15.json @@ -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,"tier":0,"layers":5},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":16},"lateranoShip":{"count":2,"active":false,"tier":0,"layers":4},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":0,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":3,"active":true,"tier":1,"layers":93},"preciShip":{"count":2,"active":true,"tier":1,"layers":83},"swiftShip":{"count":2,"active":true,"tier":1,"layers":86},"skillfulShip":{"count":1,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":2,"active":true,"tier":1,"layers":20},"steadShip":{"count":3,"active":true,"tier":2,"layers":40},"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":3},"investShip":{"count":1,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":0},"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":3,"active":true,"tier":1,"layers":0}},"bandId":"band_kirara","playerEffects":[],"deviceOverrides":{},"lpForBoss":1205,"contentInfo":{"round":15,"kind":"hidden","shopLevel":6,"funds":0,"handUnits":6,"handChess":6,"roundStats":{"refreshes":2,"buys":4,"sells":0,"spent":12,"gainedChess":4,"arts":0},"matchBands":["band_kirara","band_sarkazb","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_0","bandId":"band_sarkazb"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":15,"enemyKey":"enemy_1249_lysdb_2","routeIndex":0,"count":5,"interval":12,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":0,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":false,"boss":false}},{"time":25,"enemyKey":"enemy_1249_lysdb_2","routeIndex":1,"count":5,"interval":12,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":1,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":false,"boss":false}},{"time":11,"enemyKey":"enemy_10027_vtsk","routeIndex":4,"count":3,"interval":30,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":12,"enemyKey":"enemy_10027_vtsk","routeIndex":5,"count":3,"interval":30,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[2,10],"fly":false,"elite":true,"boss":false}},{"time":0,"enemyKey":"enemy_9013_acstmk_2","routeIndex":6,"count":1,"interval":0,"mods":{"atkMul":2.143589,"speedMul":1,"slot":"N"},"actionIndex":6,"preview":{"gate":"lower","start":[3,10],"fly":false,"elite":true,"boss":true},"tag":"boss"},{"time":0,"enemyKey":"enemy_9014_acstma","routeIndex":7,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":7,"preview":{"gate":"lower","start":[3,12],"fly":false,"elite":true,"boss":false},"tag":"part","countInTotal":false},{"time":0,"enemyKey":"enemy_9015_acstmb","routeIndex":8,"count":1,"interval":0,"mods":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1,"slot":"E"},"actionIndex":8,"preview":{"gate":"lower","start":[3,8],"fly":false,"elite":true,"boss":false},"tag":"part","countInTotal":false}],"routes":[{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"FLY","start":[5,10],"end":[1,3],"checkpoints":[[5,11],[2,11],[2,13],[5,13],[5,15],[2,15],[2,17],[1,17],[5,9],[2,9],[2,7],[5,7],[5,5],[2,5],[2,3]],"steps":[{"t":"move","p":[5,11]},{"t":"move","p":[2,11]},{"t":"move","p":[2,13]},{"t":"move","p":[5,13]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]},{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,9]},{"t":"move","p":[2,9]},{"t":"move","p":[2,7]},{"t":"move","p":[5,7]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]}]},{"motion":"FLY","start":[2,10],"end":[1,17],"checkpoints":[[2,9],[5,9],[5,8],[2,8],[2,6],[5,6],[5,5],[2,5],[2,3],[1,3],[5,12],[2,12],[2,14],[5,14],[5,15],[2,15],[2,17]],"steps":[{"t":"move","p":[2,9]},{"t":"move","p":[5,9]},{"t":"move","p":[5,8]},{"t":"move","p":[2,8]},{"t":"move","p":[2,6]},{"t":"move","p":[5,6]},{"t":"move","p":[5,5]},{"t":"move","p":[2,5]},{"t":"move","p":[2,3]},{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":5},{"t":"appear","p":[5,10]},{"t":"move","p":[5,12]},{"t":"move","p":[2,12]},{"t":"move","p":[2,14]},{"t":"move","p":[5,14]},{"t":"move","p":[5,15]},{"t":"move","p":[2,15]},{"t":"move","p":[2,17]}]},{"motion":"WALK","start":[2,10],"end":[1,17],"checkpoints":[[1,3]],"steps":[{"t":"move","p":[1,3]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[2,10],"end":[1,3],"checkpoints":[[1,17]],"steps":[{"t":"move","p":[1,17]},{"t":"disappear"},{"t":"wait","s":3},{"t":"appear","p":[5,10]}]},{"motion":"WALK","start":[3,10],"end":[2,2],"checkpoints":[[2,3]],"steps":[{"t":"wait","s":999},{"t":"move","p":[2,3]}]},{"motion":"WALK","start":[3,12],"end":[1,3],"checkpoints":[]},{"motion":"WALK","start":[3,8],"end":[1,3],"checkpoints":[]}],"flags":{"layerGainsEnabled":false,"dpInit":10,"dpPerSec":1,"dpMax":99,"enemyScale":{"hpMul":4.299817,"atkMul":2.143589,"speedMul":1}},"enemyOverrides":{},"waveId":"act1autochess_h08_01","bossId":"boss_8","content":"full","boss":{"poolHp":14400000,"poolMax":14400000}},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/hard-normal-r13.json b/public/dev/perf/hard-normal-r13.json new file mode 100644 index 00000000..96bfd55f --- /dev/null +++ b/public/dev/perf/hard-normal-r13.json @@ -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],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":55,"enemyKey":"enemy_1169_duphlx_2","routeIndex":6,"count":5,"interval":5.4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":6,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":2,"enemyKey":"enemy_1172_dugago","routeIndex":7,"count":2,"interval":20,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":7,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"},{"time":10,"enemyKey":"enemy_1172_dugago","routeIndex":8,"count":2,"interval":14,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":8,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"},{"time":32,"enemyKey":"enemy_1172_dugago","routeIndex":9,"count":2,"interval":15,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":9,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"},{"time":7,"enemyKey":"enemy_1166_dusbr_2","routeIndex":10,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":10,"preview":{"gate":"upper","start":[12,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":3,"enemyKey":"enemy_1169_duphlx_2","routeIndex":11,"count":4,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":11,"preview":{"gate":"upper","start":[12,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":21,"enemyKey":"enemy_1172_dugago","routeIndex":12,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"actionIndex":12,"preview":{"gate":"upper","start":[12,10],"fly":false,"elite":true,"boss":false},"ownerPlayerId":"p_0"}],"routes":[{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[9,10],"end":[9,2],"checkpoints":[[9,9]]},{"motion":"WALK","start":[12,10],"end":[9,2],"checkpoints":[[12,9]]},{"motion":"WALK","start":[12,10],"end":[9,2],"checkpoints":[[12,9]]},{"motion":"WALK","start":[12,10],"end":[9,2],"checkpoints":[[12,9]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[9,9],[9,7],[12,7],[12,6],[9,6],[9,5],[12,5],[12,4],[9,4]]},{"motion":"FLY","start":[12,10],"end":[9,2],"checkpoints":[[12,9],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[12,8],[12,5],[9,5],[9,4],[12,4],[12,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[12,8],[12,5],[9,5],[9,4],[12,4],[12,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[12,8],[12,5],[9,5],[9,4],[12,4],[12,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]},{"motion":"FLY","start":[9,10],"end":[9,2],"checkpoints":[[9,8],[11,8],[11,6],[10,6],[10,5],[11,5],[11,3],[9,3]]}],"flags":{"layerGainsEnabled":true,"dpInit":10,"dpPerSec":1,"dpMax":99},"enemyOverrides":{},"waveId":"act1autochess_h06","bossId":null,"content":"full","boss":null},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/hard-unite-r13.json b/public/dev/perf/hard-unite-r13.json new file mode 100644 index 00000000..8e4dd44c --- /dev/null +++ b/public/dev/perf/hard-unite-r13.json @@ -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,"carryState":{"hpPct":1,"sp":31.97}},{"uid":82,"kind":"chess","chessId":"chess_char_1_02_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_5_02_e_a","chess_item_1_02_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":46.61}}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":2},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":6,"active":true,"tier":2,"layers":133},"lateranoShip":{"count":0,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":0,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":0,"active":false,"tier":0,"layers":0},"preciShip":{"count":2,"active":true,"tier":1,"layers":20},"swiftShip":{"count":0,"active":false,"tier":0,"layers":0},"skillfulShip":{"count":2,"active":true,"tier":1,"layers":20},"arcaneShip":{"count":0,"active":false,"tier":0,"layers":17},"steadShip":{"count":2,"active":true,"tier":1,"layers":14},"deputShip":{"count":2,"active":true,"tier":1,"layers":20},"visiShip":{"count":1,"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":0},"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":21},"suntShip":{"count":0,"active":false,"tier":0,"layers":0}},"bandId":"band_cathy","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"unite","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":5,"buys":5,"sells":1,"spent":11,"gainedChess":0,"arts":0},"matchBands":["band_cathy","band_sarkazb","band_kirara","band_bldsk"],"teammates":[{"playerId":"p_0","bandId":"band_sarkazb"},{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"}]}}],"spawns":[{"time":3,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":3.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":5.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":7,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":7.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":9,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":9.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":11,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":11.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":13,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":13.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":15,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":15.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":17,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":17.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":19,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":19.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":21,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":21.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":23,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":23.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":25,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"p_1","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":25.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":27.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":29.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":31.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":33.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":35.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":37.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":39.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":41.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":43.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":45.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":47.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":49.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":51.5,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":53.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3},{"time":55.5,"enemyKey":"enemy_1172_dugago","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"S"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":true,"boss":false},"actionIndex":3},{"time":57.5,"enemyKey":"enemy_1169_duphlx_2","routeIndex":3,"count":1,"interval":0,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"sourcePlayerId":"ai_2","preview":{"gate":"lower","start":[9,18],"fly":false,"elite":false,"boss":false},"actionIndex":3}],"routes":[{"motion":"FLY","start":[9,18],"end":[9,2],"checkpoints":[[9,17],[12,17],[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]},{"motion":"FLY","start":[9,18],"end":[9,2],"checkpoints":[[9,17],[12,17],[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]},{"motion":"FLY","start":[12,18],"end":[9,2],"checkpoints":[[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"WALK","start":[9,18],"end":[9,2],"checkpoints":[[9,10]]},{"motion":"FLY","start":[9,18],"end":[9,2],"checkpoints":[[9,17],[12,17],[12,15],[10,15],[10,13],[12,13],[12,11],[10,11],[10,9],[12,9],[12,7],[10,7],[10,5],[12,5],[12,3],[9,3]],"spawnRandom":[0.3,0.3]}],"flags":{"layerGainsEnabled":false,"dpInit":10,"dpPerSec":1,"dpMax":99},"enemyOverrides":{},"waveId":"act1autochess_escaped_multi","bossId":null,"content":"full","boss":null},"authoritative":true,"startAt":0,"serverNow":0,"elapsed":0,"speed":2,"watch":false,"done":false} \ No newline at end of file diff --git a/public/dev/perf/index.json b/public/dev/perf/index.json new file mode 100644 index 00000000..b6626410 --- /dev/null +++ b/public/dev/perf/index.json @@ -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 + } +] diff --git a/test/perfbench.test.js b/test/perfbench.test.js new file mode 100644 index 00000000..7b00d2f6 --- /dev/null +++ b/test/perfbench.test.js @@ -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$/); + }); +}); diff --git a/test/render/battle-perf.browser.test.js b/test/render/battle-perf.browser.test.js new file mode 100644 index 00000000..6f17fbd2 --- /dev/null +++ b/test/render/battle-perf.browser.test.js @@ -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)}`); + }); + } +}); diff --git a/tools/capture-specs.mjs b/tools/capture-specs.mjs new file mode 100644 index 00000000..5bb484f4 --- /dev/null +++ b/tools/capture-specs.mjs @@ -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: +// +// /--r.json the b.start message (elapsed / clocks zeroed, authoritative) +// /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(); diff --git a/tools/perfbench.mjs b/tools/perfbench.mjs new file mode 100644 index 00000000..287499d8 --- /dev/null +++ b/tools/perfbench.mjs @@ -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= 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 /results.json (+ --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; }); +}