diff --git a/.github/ISSUE_TEMPLATE/BugReport.yml b/.github/ISSUE_TEMPLATE/BugReport.yml new file mode 100644 index 00000000..7dd3d7ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BugReport.yml @@ -0,0 +1,79 @@ +name: Bug 反馈 +description: 在使用的过程中遇到了 Bug。 +title: "[Bug]:" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 感谢您进行 Bug 反馈。请在上面的文本框⬆️起一个能够清晰描述您的问题的标题,便于开发者解决您的问题。 + + > [!important] + > 重要:如果要提出**多个 Bug**,请为每一个 Bug 开一个单独的 issue。 + > + > 如果您不知道如何有效、精准地表述,我们建议您先阅读《提问的智慧》([链接](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md))。 + - type: checkboxes + id: checklist + attributes: + label: 检查清单 + description: 在开始反馈这个问题之前,请先检查: + options: + - label: 我已更新到最新版 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?include_prereleases&style=flat-square&label=) 或 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?style=flat-square&label=) 测试,并看过 [最新提交](https://github.com/sganggs/Stronghold-Protocol/commits/master/),确认这一 Bug 还没有修复。我也已在 [Issues](https://github.com/sganggs/Stronghold-Protocol/issues) 中检索,确认这一 Bug 未被提交过。 + required: true + - label: 我已知晓并同意,此处仅用于汇报程序中存在的问题(关于其他非程序本身的问题应当在 Discussion 板块提出)。 + required: true + - type: markdown + attributes: + value: | + ### Bug 信息 + + 描述您遇到的 Bug。您可以附上截图、录屏、堆栈跟踪、日志等材料,便于开发者追踪问题。 + + > [!note] + > 上传附件时**请优先使用 GitHub 的附件系统上传附件**,将需要上传的附件粘贴或拖动到撰写区域即可上传。多个文件可打包为 zip 格式后上传。 + - type: textarea + id: expected + attributes: + label: 期望的行为 + description: 详细的描述你期望发生的行为,突出与目前(可能不正确的)行为的不同。 + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: 实际结果 + description: 实际发生的行为。 + validations: + required: true + - type: textarea + id: reproduce-steps + attributes: + label: 重现步骤 + description: | + 详细描述要怎么操作才能再次触发这个 Bug。 + placeholder: | + 1. 首先…… + 2. 然后…… + 3. …… + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: 截图/录屏(可选,建议) + description: | + 如果可能,请提供可以用来进一步描述您的问题的截图或录屏。 + - type: textarea + id: stacktrace + attributes: + label: 堆栈跟踪(可选) + description: 如果在遇到这个 Bug 时发生了崩溃(弹出崩溃提示),或者产生了错误日志,请将产生的堆栈跟踪信息粘贴到此处,便于开发者定位 Bug。 + render: shell + - type: checkboxes + id: revision + attributes: + label: 最后一步 + description: 回顾您的回答 + options: + - label: 我认为上述的描述已经足以详细,以允许开发人员能复现该问题。如果我的 issue 没有按照上述的要求填写,可能会被无条件关闭。 + required: true diff --git a/.github/ISSUE_TEMPLATE/FeatureRequest.yml b/.github/ISSUE_TEMPLATE/FeatureRequest.yml new file mode 100644 index 00000000..a7846cd0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FeatureRequest.yml @@ -0,0 +1,49 @@ +name: 功能请求 +description: 提出一项新的功能。 +title: "[Feature]:" +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + 感谢您贡献您的想法。 + 请使用简洁明确的标题描述您希望增加或改进的功能。便于开发者了解您的想法。 + + > [!important] + > 重要:如果您想要提出 **多项功能**,请为每一个功能开一个单独的 issue。 + - type: checkboxes + id: checklist + attributes: + label: 检查清单 + description: 在开始反馈这个问题之前,请先检查: + options: + - label: 我已将本应用更新到最新测试版 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?include_prereleases&style=flat-square&label=),并看过 [最新提交](https://github.com/sganggs/Stronghold-Protocol/commits/master/),确认我想要的功能还没有实现。我也已在 [Issues](https://github.com/sganggs/Stronghold-Protocol/issues?q=-label%3Abug)中检索,确认我想要的功能未被提交过。 + required: true + - type: markdown + attributes: + value: | + > [!note] + > 上传附件时**请优先使用 GitHub 的附件系统上传附件**,将需要上传的附件粘贴或拖动到撰写区域即可上传。多个文件可打包为 zip 格式后上传。 + - type: textarea + id: requested-feature + attributes: + label: 想要实现或优化的功能 + description: 详细地描述一下你想要的功能,联系到哪些已经存在的 BUG 或者功能(如有),描述的越具体,采纳的可能性越高。 + validations: + required: true + - type: textarea + id: expected-effect + attributes: + label: 期望效果 + placeholder: | + 请详细描述这个功能实现的效果。 + validations: + required: true + - type: checkboxes + id: revision + attributes: + label: 最后一步 + description: 回顾您的回答 + options: + - label: 我认为上述的描述已经足以详细,以便开发人员理解您想要的功能。如果我的 issue 没有按照上述的要求填写,可能会被无条件关闭。 + required: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe73e468..1f9e694a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,11 @@ npm run golden:update # 有意改变 npm run lint && npm run check:imports && npm run typecheck # 代码检查、导入边界、类型 ``` +提交 PR 前可以一条命令跑一遍 GitHub CI(`.github/workflows/ci.yml`)的全部检查:`npm run ci`(或 `node tools/ci.mjs`)。 +依次是 setup 检查、`node --test`、服务器冒烟测试(在空闲端口启动、请求 `/healthz` 和首页、运行 doctor,然后关掉)、lint、导入边界、 +类型;第一步失败就停下并打印每步结果和耗时。`--keep-going` 失败后继续,`--only lint,test` 只跑指定步骤,`--list` 列出步骤。 +不包含 `npm ci`(依赖请自己装),也不跑 Node 版本 × 系统的矩阵。 + 浏览器测试默认不跑,需要本机 Chrome(路径不标准时设置 `CHROME_PATH`),用环境变量打开: ```bash @@ -99,7 +104,9 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里 - **Setup**: Node.js 22 or 24; `npm install`, `npm run setup` (art and audio — optional for tests), `npm run dev`. The code map is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). - **Tests**: run the targeted files (`node --test `, `--test-name-pattern`), then `node --test` and - `npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`. Browser suites + `npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`. + `npm run ci` (or `node tools/ci.mjs`) runs the checks of `.github/workflows/ci.yml` in one go and stops at the first + failure (`--keep-going`, `--only `, `--list`); it does not run `npm ci`. Browser suites are opt-in: `SP_E2E=1`, `SP_REAL_E2E=1` (needs downloaded art), `RENDER_E2E=1`, `SIM_E2E=1` (a local Chrome, `CHROME_PATH`). A refactor never changes `test/golden/*.json`; an intended gameplay change runs `npm run golden:update` and commits the digests with the change, naming the scenarios that moved. diff --git a/README.md b/README.md index d88a2217..c4390d3c 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ npm start # 启动服务器:http://localhost:3000 | 环境变量 | 默认 | 说明 | |---|---|---| | `PORT` | `3000` | 监听端口 | -| `HOST` | `0.0.0.0` | 监听地址(`127.0.0.1` = 只允许本机,放在反向代理后面时使用) | +| `HOST` | `::` | 监听地址。默认 `::` 是双栈:同一个端口同时接受 IPv6 和 IPv4;`0.0.0.0` = 只 IPv4;`127.0.0.1` = 只允许本机,放在反向代理后面时使用 | | `SP_COMBAT` | `client` | `client`:各玩家浏览器模拟自己的战斗(服务器负载极低);`server`:由服务器模拟并推流 | | `SP_VERIFY` | `off` | 服务器复算客户端上报的战斗结果:`off` / `sample`(约 1/8 抽查)/ `all`(全部复算,更耗 CPU) | | `TRUST_PROXY` | `auto` | 是否信任 `X-Forwarded-For` 等转发头:`auto` 只信任来自本机 / 内网的代理;`1` 总是;`0` 从不 | @@ -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/docs/DATA.md b/docs/DATA.md index abcc2b76..04c1621d 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -12,7 +12,7 @@ downloads failed on this machine unless `--allow-shrink` (or `--prune`) is passe ``` node tools/build-data.mjs # build (downloads missing official files into .cache/gamedata/) node tools/build-data.mjs --offline # never download; fail if a cached file is missing -node tools/build-data.mjs --refresh # re-download every official file +node tools/build-data.mjs --refresh # re-download every official file; fail if any download fails node tools/build-data.mjs --no-research --out /tmp/x # research-free build (fallback defaults), other dir node tools/build-data.mjs --cache --report # other cache dir / report file (tests, CI) node tools/build-data.mjs --force # write data/ even when integrity checks fail (debugging only) @@ -23,8 +23,11 @@ Unknown options or a missing option value are errors (exit code 2); `--refresh` - **Cache.** Official files live under `.cache/gamedata/` (`excel/activity_table.json`, `levels/enemydata/enemy_database.json`, `levels/activities/act1autochess/level_*.json`, …) and are fetched from `https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/` when missing. - Downloads are validated (must parse as JSON) and written atomically. -- **Report.** Every run writes `.cache/build-data-report.json` (counts, byte sizes, warnings (each once), errors, `written`). + Downloads are validated (must parse as JSON) and written atomically. With `--refresh`, a download that still fails + after four attempts aborts the build (exit code 1), even if an older cached file exists. Generated files and the + previous report remain untouched, including with `--force`; successfully downloaded cache entries are not rolled + back. Retry `--refresh` to complete the refresh; cached files are not verified to share an upstream revision. +- **Report.** A run that reaches integrity validation writes `.cache/build-data-report.json` (counts, byte sizes, warnings (each once), errors, `written`). Integrity errors (see §17) make the exit code 1 **and leave the previous output untouched** (unless `--force`); warnings never do. Each output file is written atomically (temp file + rename). - **Determinism.** Same inputs ⇒ byte-identical outputs (stable key order, no timestamps, no randomness). diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ea375c9c..bc7e5ce5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -106,7 +106,7 @@ powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 winget install NSSM.NSSM # 或从 https://nssm.cc 下载 nssm install StrongholdProtocol "C:\Program Files\nodejs\node.exe" server\index.js nssm set StrongholdProtocol AppDirectory C:\Stronghold-Protocol -nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=0.0.0.0 +nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=:: nssm set StrongholdProtocol AppStdout C:\Stronghold-Protocol\logs\server.log nssm set StrongholdProtocol AppStderr C:\Stronghold-Protocol\logs\server.log nssm start StrongholdProtocol @@ -266,7 +266,7 @@ services: [Service] WorkingDirectory=/opt/Stronghold-Protocol ExecStart=/usr/bin/node server/index.js - Environment=PORT=3000 HOST=0.0.0.0 + Environment=PORT=3000 HOST=:: Restart=always RestartSec=5 User=stronghold diff --git a/package.json b/package.json index efd9251e..aa34bb0f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "lint": "eslint .", "typecheck": "tsc --noEmit --checkJs -p jsconfig.json", "check:imports": "node tools/check-imports.mjs", + "ci": "node tools/ci.mjs", "package": "node tools/package.mjs", "package:lite": "node tools/package.mjs --lite" }, 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/public/js/net.js b/public/js/net.js index b921a444..5916d3bd 100644 --- a/public/js/net.js +++ b/public/js/net.js @@ -726,7 +726,7 @@ export function createIdentity(deps = {}) { let initPromise = null; let initialized = false; let current = null; // token this tab uses (null = let the server create a session) - let resolving = null; // { taken: Set } while init() waits for claims + let resolving = null; // { hashes: Set, taken: Set } while init() waits for claims const readRecent = () => { try { @@ -751,7 +751,12 @@ export function createIdentity(deps = {}) { if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from }); } // Another tab is choosing at the same time: the smaller tab id has precedence. - if (resolving && m.from < tabId) for (const h of m.hashes) if (typeof h === 'string') resolving.taken.add(h); + // Answer for pending candidates too: a later channel may have missed our initial query. + if (resolving) for (const h of m.hashes) { + if (typeof h !== 'string') continue; + if (m.from < tabId) resolving.taken.add(h); + else if (resolving.hashes.has(h)) post({ type: 'mine', hash: h, to: m.from }); + } } else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') { resolving.taken.add(m.hash); } @@ -768,8 +773,8 @@ export function createIdentity(deps = {}) { // Without a channel we cannot tell whether a shared token is in use: never adopt one. const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))]; if (!channel || candidates.length === 0) return ownOk; - resolving = { taken: new Set() }; - post({ type: 'who', hashes: candidates.map(tokenHash) }); + resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() }; + post({ type: 'who', hashes: [...resolving.hashes] }); await new Promise((r) => setTimer(r, queryMs)); const { taken } = resolving; resolving = null; diff --git a/public/js/ui/lang.js b/public/js/ui/lang.js index 5f95bfbf..644318b5 100644 --- a/public/js/ui/lang.js +++ b/public/js/ui/lang.js @@ -39,6 +39,8 @@ const uiLoads = new Map(); let indexLoad = null; /** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */ let indexOk = false; +/** Only the latest boot or explicit selection may apply its loaded language. */ +let langRequest = 0; const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`; const defaultFetch = (...a) => globalThis.fetch(...a); @@ -194,20 +196,23 @@ function applyLang(lang) { * @returns {Promise} the language in effect */ export async function initLang() { + const request = ++langRequest; wire(); applyDocument(getLang()); const TIMEOUT = Symbol('timeout'); const boot = (async () => { const listed = await loadLangIndex(); const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed }); - if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); } + if (fromUrl) stripLangParam(); + if (request !== langRequest) return null; + if (fromUrl) savePref(PREF_KEY, lang); if (lang === DEFAULT_LANG) return null; return (await loadLangChain(lang)) ? lang : null; })(); const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]); if (got === TIMEOUT) { - boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); }); - } else if (got) applyLang(got); + boot.then((lang) => { if (request === langRequest && lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); }); + } else if (request === langRequest && got) applyLang(got); return getLang(); } @@ -218,10 +223,12 @@ export async function initLang() { * @returns {Promise} the language in effect */ export async function switchLang(lang) { + const request = ++langRequest; wire(); const want = normalizeLang(lang) || DEFAULT_LANG; savePref(PREF_KEY, want); if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang(); + if (request !== langRequest) return getLang(); applyLang(want); return getLang(); } @@ -290,10 +297,17 @@ export function machineTranslationNote() { export function LangToggle({ class: cls }) { const lang = useLang(); const menu = langMenuModel(useLangs(), lang); - const pick = (code) => { if (code && code !== getLang()) switchLang(code); }; + const pick = async (code, select = null) => { + if (!code) return; + const switched = switchLang(code); + const request = langRequest; + await switched; + // A failed current request keeps the displayed language; stale requests must not reset a newer pending pick. + if (select && request === langRequest) select.value = getLang(); + }; if (menu.kind === 'select') { return html``; diff --git a/scripts/install-service-windows.ps1 b/scripts/install-service-windows.ps1 index 6b11443e..6696a4bd 100644 --- a/scripts/install-service-windows.ps1 +++ b/scripts/install-service-windows.ps1 @@ -13,7 +13,7 @@ #> param( [int]$Port = 3000, - [string]$BindHost = '0.0.0.0', + [string]$BindHost = '::', [ValidateSet('client', 'server')][string]$Combat = 'client', [ValidateSet('off', 'sample', 'all')][string]$Verify = 'off', [string]$TaskName = 'StrongholdProtocol', @@ -162,8 +162,10 @@ try { Start-ScheduledTask -TaskName $TaskName -ErrorAction Stop Start-Sleep -Seconds 4 Show-Status - Write-Host "`n朋友访问地址(局域网):" - & $nodeExe tools\doctor.mjs --port $Port | Select-String -Pattern 'http://\d' | ForEach-Object { Write-Host " $($_.Line.Trim())" } + Write-Host "`n朋友访问地址(局域网 / IPv6,方括号不能少):" + # doctor prints a full URL. An IPv6 literal is bracketed, so a pattern that requires a digit right after http:// + # would drop it. Keep every http:// line; the rest of the report is classification text. + & $nodeExe tools\doctor.mjs --port $Port | Select-String -Pattern 'http://' | ForEach-Object { Write-Host " $($_.Line.Trim())" } Write-Host "`n停止:-Stop 重启:-Restart 状态:-Status 卸载:-Uninstall 日志:$Log" } catch { Write-Host "`n出错:$($_.Exception.Message)" -ForegroundColor Red diff --git a/scripts/launch.mjs b/scripts/launch.mjs index b4eaf1c4..0ceab47c 100755 --- a/scripts/launch.mjs +++ b/scripts/launch.mjs @@ -2,7 +2,7 @@ // scripts/launch.mjs — cross-platform "prepare + start + open the browser", used by scripts/start-windows.bat, // scripts/start-windows.ps1 and scripts/start.sh (docs/DEPLOY.md). // -// node scripts/launch.mjs [--port 3000] [--host 0.0.0.0] [--no-open] [--no-setup] [setup options…] +// node scripts/launch.mjs [--port 3000] [--host ::] [--no-open] [--no-setup] [setup options…] // // 1. If our server already answers on the port, just open the browser (double-clicking twice is harmless). // 2. An update package extracted over the folder (UPDATE.json) is finished (server/update.js; the server does it too, @@ -30,11 +30,11 @@ if (Number(process.versions.node.split('.')[0]) < 22) { } const { c, mark } = await import('../tools/setup.mjs'); -const { probePort, classifyAddresses, KIND_LABEL } = await import('../tools/doctor.mjs'); +const { probePort, classifyAddresses, hostUrl, KIND_LABEL } = await import('../tools/doctor.mjs'); const { applyPendingUpdate, UPDATE_FILE } = await import('../server/update.js'); function parseArgs(argv) { - const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false }; + const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '::', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const [k, v] = a.split('='); @@ -61,9 +61,9 @@ function printShare(port) { console.log(`${mark.ok} ${c.bold('服务器已启动')} 本机打开:${c.cyan(`http://localhost:${port}`)}`); if (addrs.length) { console.log(' 发给朋友(需要能访问这台电脑的网络):'); - for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(`http://${a.address}:${port}`)} ${c.dim(KIND_LABEL[a.kind])}`); + for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(hostUrl(a.address, port))} ${c.dim(KIND_LABEL[a.kind])}`); } else { - console.log(c.warn(' 没有检测到局域网地址:朋友暂时无法连接(检查网线/Wi-Fi)。')); + console.log(c.warn(' 没有检测到可以分享的地址:朋友暂时无法连接(检查网线/Wi-Fi)。')); } console.log(c.dim(' 建房后把 4 位「同盟密钥」或「复制链接」(…/?room=密钥)发给朋友。')); console.log(c.dim(' 朋友打不开?运行 node tools/doctor.mjs 检查防火墙。按 Ctrl+C 停止服务器。')); diff --git a/scripts/run-server.cmd b/scripts/run-server.cmd index 7ae24ba7..ccdc8f77 100644 --- a/scripts/run-server.cmd +++ b/scripts/run-server.cmd @@ -5,6 +5,8 @@ rem Output goes to logs\server.log (rotated at 10 MB to logs\server.old.log); th setlocal EnableExtensions cd /d "%~dp0.." set "NODE_EXE=node" +rem Dual-stack default (IPv6 and IPv4 on one socket). scripts\service.env.cmd may override HOST. +set "HOST=::" if exist "%~dp0service.env.cmd" call "%~dp0service.env.cmd" if not exist "logs" mkdir "logs" diff --git a/server/http/boot.js b/server/http/boot.js index f1d3eb0a..ede2d96f 100644 --- a/server/http/boot.js +++ b/server/http/boot.js @@ -14,18 +14,43 @@ import fs from 'node:fs'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { APP_VERSION, DEV_BUILD } from '../../shared/constants.js'; +import { limitKeyOf } from '../net.js'; import { applyPendingUpdate } from '../update.js'; import { ROOT } from './config.js'; -/** Non-internal IPv4 addresses as http URLs. @param {number} port */ +/** + * Non-internal addresses as http URLs — IPv4 first, then IPv6 (an IPv6 literal needs brackets: `http://[240e:…]:3000`). + * Link-local (`fe80::`) is left out: a URL cannot carry the zone id. Several privacy addresses in one /64 collapse + * to one URL (`limitKeyOf`). + * @param {number} port + */ export function lanUrls(port) { - const out = []; + const v4 = []; + const v6 = []; + const v6Seen = new Set(); for (const addrs of Object.values(os.networkInterfaces())) { for (const a of addrs || []) { - if ((a.family === 'IPv4' || a.family === 4) && !a.internal) out.push(`http://${a.address}:${port}`); + if (a.internal) continue; + if (a.family === 'IPv4' || a.family === 4) { v4.push(`http://${a.address}:${port}`); continue; } + if (a.family !== 'IPv6' && a.family !== 6) continue; + if (/^fe80:/i.test(a.address)) continue; + const prefix = limitKeyOf(a.address); + if (v6Seen.has(prefix)) continue; + v6Seen.add(prefix); + v6.push(`http://[${a.address}]:${port}`); } } - return out; + return [...v4, ...v6]; +} + +/** + * The host as it appears in the local URL. A wildcard bind (`0.0.0.0` or `::`) reads `localhost`. An IPv6 literal + * is bracketed (`http://[2001:db8::1]:3000`); an IPv4 address or a name is kept. + * @param {string} host + */ +export function displayHost(host) { + if (host === '0.0.0.0' || host === '::') return 'localhost'; + return host.includes(':') ? `[${host}]` : host; } /** Is the module whose `import.meta.url` is `metaUrl` the file node was started with? */ diff --git a/server/http/buildTag.js b/server/http/buildTag.js index ebff88c0..ec693893 100644 --- a/server/http/buildTag.js +++ b/server/http/buildTag.js @@ -15,11 +15,12 @@ import { ROOT } from './config.js'; * without this signal a deployed fix could never reach a player who does not reload — a client-only battle fix * shipped exactly that way and stayed invisible on a page that had been opened before the deploy. * - * `server/`, `data/` and `shared/` are deliberately NOT in here: this process read them once at startup, so when they - * change without a restart the server still runs the old simulation and data — a page that reloaded into the new files - * would be out of step with the server that validates its battles (and DEPLOY.md restarts the server for every update). + * The browser also imports `/sim/` from `server/sim/` and `/shared/`, and caches JSON from `/data/`. Include those + * trees so a simulation-only or data-only deploy reaches old pages after the server restarts. `buildTag()` caches + * this fingerprint for the process lifetime: files changing on disk do not advertise a new build before restart. + * The rest of `server/` and downloaded media are not included. */ -export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']); +export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css', 'server/sim', 'shared', 'data']); /** Names the static server never serves: dot files (`.DS_Store`, `.main.js.swp`) and editor backups (`main.js~`). */ const isIgnoredBuildName = (name) => name.startsWith('.') || name.endsWith('~'); diff --git a/server/http/config.js b/server/http/config.js index d512a142..3d1bd123 100644 --- a/server/http/config.js +++ b/server/http/config.js @@ -1,6 +1,6 @@ // server/http/config.js — where the server's settings come from. startServer() options win over the environment: // -// * PORT (default 3000), HOST (default 0.0.0.0); +// * PORT (default 3000), HOST (default '::', one dual-stack socket for IPv6 and IPv4); // * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private // peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy; // * DEBUG → the console logger's debug level; @@ -21,18 +21,36 @@ const NET_OPTION_KEYS = ['reconnectWindowMs', 'heartbeatMs', 'helloTimeoutMs', ' const LOBBY_OPTION_KEYS = ['lobbyGraceMs', 'maxRooms', 'maxRoomsPerAddr', 'maxMatchesPerAddr', 'resyncMinGapMs', 'soloReconnectWindowMs']; /** - * Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on 0.0.0.0. + * Bind address used when neither the `host` option nor `HOST` says otherwise: one dual-stack socket, so the server + * answers IPv6 and IPv4 alike without a second listener (Node keeps `ipv6Only` off for `::`). + * `HOST=0.0.0.0` still means IPv4 only, `HOST=127.0.0.1` still means loopback only (a reverse proxy in front). + */ +export const DEFAULT_BIND_HOST = '::'; + +/** + * Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on DEFAULT_BIND_HOST. + * An empty host is treated as unset. * @param {{ port?: number, host?: string }} opts * @returns {{ port: number, host: string }} * @throws {RangeError} when the port is not an integer in 0…65535 */ export function listenAddress(opts) { const port = opts.port ?? (process.env.PORT != null && process.env.PORT !== '' ? Number(process.env.PORT) : 3000); - const host = opts.host ?? process.env.HOST ?? '0.0.0.0'; + const host = (opts.host || process.env.HOST) || DEFAULT_BIND_HOST; if (!Number.isInteger(port) || port < 0 || port > 65535) throw new RangeError(`invalid PORT ${port}`); return { port, host }; } +/** + * The hosts to try, in order, for a bind. Only the default is retried: a host with IPv6 switched off refuses `::` + * with EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL, and falling back to IPv4 beats not booting. An explicit HOST is literal. + * @param {string} host a host out of listenAddress() + * @returns {string[]} + */ +export function bindCandidates(host) { + return host === DEFAULT_BIND_HOST ? [DEFAULT_BIND_HOST, '0.0.0.0'] : [host]; +} + /** * The directories the static server reads (packsDir: the pack folders, server/packs.js). * @param {{ publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string }} opts diff --git a/server/index.js b/server/index.js index 440d2664..2543c3de 100644 --- a/server/index.js +++ b/server/index.js @@ -1,7 +1,7 @@ // server/index.js — process entry & boot (DESIGN §1, §2). Plain node:http + ws, no framework: startServer() below // wires the modules under server/http/, in this order — // -// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST 0.0.0.0, TRUST_PROXY auto, DEBUG), +// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG), // which startServer() options go to net.js / lobby.js, the console logger // http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB; // refused at upgrade with 404 / 429 per network / 503) @@ -24,7 +24,7 @@ import http from 'node:http'; import { getData, loadData } from './data.js'; -import { ROOT, listenAddress, serveDirs, makeLogger, parseTrustProxy } from './http/config.js'; +import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js'; import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js'; import { DATA_SHIM_JS, createStaticHandler } from './http/static.js'; import { createPackRegistry } from './packs.js'; @@ -32,7 +32,7 @@ import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js'; import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js'; import { createRequestHandler } from './http/routes.js'; import { answerClientError } from './http/common.js'; -import { lanUrls, isProcessEntry, runMain } from './http/boot.js'; +import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js'; // The public API of this module (tests and tools import it from here); the code lives in ./http/. export { @@ -77,14 +77,35 @@ export async function startServer(opts = {}) { server.on('clientError', answerClientError); const wss = attachWebSocket(server, { network, log }); + // The address actually bound. The default may fall back to IPv4; the returned host and url follow that. + let boundHost; try { - await new Promise((resolve, reject) => { - const onError = (e) => { server.off('listening', onListening); reject(e); }; - const onListening = () => { server.off('error', onError); resolve(); }; - server.once('error', onError); - server.once('listening', onListening); - server.listen(port, host); - }); + // A host with IPv6 switched off refuses '::'. Fall back to IPv4 rather than not booting. Only the default is + // retried: an explicit HOST is literal (server/http/config.js bindCandidates). + let bound = null; + let lastError = null; + const candidates = bindCandidates(host); + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + try { + await new Promise((resolve, reject) => { + const onError = (e) => { server.off('listening', onListening); reject(e); }; + const onListening = () => { server.off('error', onError); resolve(); }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, candidate); + }); + bound = candidate; + break; + } catch (e) { + lastError = e; + const retry = ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(e.code) && i < candidates.length - 1; + if (!retry) break; + log.warn(`[boot] cannot bind ${candidate} (${e.code}) — falling back to IPv4 only`); + } + } + if (bound === null) throw lastError; + boundHost = bound; } catch (e) { network.close(); // stop heartbeat/sweep timers of the half-built server throw e; @@ -93,7 +114,7 @@ export async function startServer(opts = {}) { const addr = server.address(); const actualPort = typeof addr === 'object' && addr ? addr.port : port; - const url = `http://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${actualPort}`; + const url = `http://${displayHost(boundHost)}:${actualPort}`; let closing = null; async function close() { @@ -111,7 +132,7 @@ export async function startServer(opts = {}) { return closing; } - return { port: actualPort, host, url, server, wss, lobby, network, registry, packs, close }; + return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close }; } // `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js). diff --git a/server/match/Match.js b/server/match/Match.js index b95e5bb4..65f40713 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -106,7 +106,7 @@ // (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with. // opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode) // opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results -// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the +// ('sample': ~1 in 8, in later slices, mismatches logged; 'all': before accepting — the // server's result wins on a mismatch) // // Engine-only extra options (tests / tools; the lobby never passes them): @@ -121,7 +121,7 @@ // opts.botSliceMs wall-clock ms of rehearsal per scheduler callback (default 8 with a real scheduler, unbounded // with a virtual one); the rest runs in later callbacks (scheduleBotPrep) // opts.headlessSliceMs wall-clock ms per callback of a server-run normal / 联防 field (client-side combat: bots, -// takeovers; default 8 with a real scheduler, at once with a virtual one) +// takeovers, result verification; default 8 with a real scheduler, at once with a virtual one) // Seats may be all bots (tools/matchrun.mjs); the lobby always has ≥ 1 human. // // Diagnostics: m.errors / m.errorCount (engine), m.dispatcher.errors / .errorsByKey (meta handlers), m.simErrors diff --git a/server/match/match/clientCombat.js b/server/match/match/clientCombat.js index 3b78229d..a62b9004 100644 --- a/server/match/match/clientCombat.js +++ b/server/match/match/clientCombat.js @@ -24,8 +24,9 @@ export class MatchClientCombat { _clearFieldTimers(f) { if (!f || !f.cc) return; - for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; } + for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer', 'verifyTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; } f.job = null; + f.verifyJob = null; } /** A client-combat field record: the JSON BattleSpec of its Battle options plus the authority / result state. */ @@ -84,7 +85,7 @@ export class MatchClientCombat { return { t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind, spec: this.spectators.has(pid) ? this._spectatorSpec(f) : f.spec, - authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch), + authoritative: !!(!f.done && !f.verifyJob && f.mode === 'client' && f.authority === pid && !watch), startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000, speed: this.gameSpeed, watch: !!watch, done: !!f.done, }; @@ -121,6 +122,7 @@ export class MatchClientCombat { /** A client field's result deadline: its time limit on the field clock + RESULT_GRACE_MS (then the server takes over). */ _armDeadline(f) { if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; } + if (f.verifyJob) return; const lim = f.spec.timeLimit > 0 ? f.spec.timeLimit : 60; const at = f.startAt + Math.round((lim / this.gameSpeed) * 1000) + RESULT_GRACE_MS; f.deadlineTimer = this.later(Math.max(0, at - this.sched.now()), () => { @@ -225,7 +227,7 @@ export class MatchClientCombat { /** An authoritative human disconnected / left: normal & 联防 fields → server takeover; boss → the partner or the server. */ _authorityLost(ps, why) { for (const f of this.fields) { - if (!f.cc || f.done || f.mode !== 'client' || f.authority !== ps.playerId) continue; + if (!f.cc || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) continue; if (f.kind === 'boss' || f.kind === 'hidden') this._bossHandover(f, why); else this._runOnServer(f, why); } diff --git a/server/match/match/reports.js b/server/match/match/reports.js index 1ee18482..065ede77 100644 --- a/server/match/match/reports.js +++ b/server/match/match/reports.js @@ -5,7 +5,7 @@ // Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). import { ERR } from '../../../shared/constants.js'; -import { runHeadless, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js'; +import { HeadlessJob, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js'; import { resultDigest, compactResult as compactForVerify } from '../../sim/spec.js'; import { OK, fail } from './common.js'; @@ -18,7 +18,7 @@ export class MatchReports { _onProgress(ps, msg) { if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat'); const f = this._fieldByBattle(msg.battleId); - if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK; + if (!f || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK; const p = f.progress; const maxTotal = Math.max(p.total, (f.spec.spawns.length + 1) * 400); p.gt = Math.max(p.gt, Math.min(Number(msg.gt) || 0, HARD_CAP_SECONDS)); @@ -46,7 +46,7 @@ export class MatchReports { _onResult(ps, msg) { if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat'); const f = this._fieldByBattle(msg.battleId); - if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK; + if (!f || f.done || f.heldResult || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK; const bossLike = f.kind === 'boss' || f.kind === 'hidden'; const v = validateClientResult(f.spec, msg.result, { gd: this.gd }); if (!v.ok) { @@ -57,7 +57,7 @@ export class MatchReports { else this._runOnServer(f, 'invalid'); return OK; } - let result = v.result; + const result = v.result; if (bossLike) { // the final b.progress normally carried everything; the result's per-player damage is a lower bound const by = {}; @@ -91,13 +91,15 @@ export class MatchReports { return OK; } this._bossResultDamage(f, result); - } else { - result = this._verifyResult(f, result); } - f.result = result; - f.resultSource = 'client'; - this._fieldDone(f); - if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); } + const accept = (verified) => { + f.result = verified; + f.resultSource = 'client'; + this._fieldDone(f); + if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); } + }; + if (bossLike) accept(result); + else this._verifyResult(f, result, accept); return OK; } @@ -107,27 +109,59 @@ export class MatchReports { } /** - * SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample': - * ~1 battle in 8 in a later callback, mismatches are only logged). + * SP_VERIFY: re-simulate in headless slices. 'all' waits before accepting (the server wins on a mismatch); + * 'sample' accepts immediately and only logs differences for ~1 battle in 8, even after the field's phase ends. */ - _verifyResult(f, result) { - if (this.verifyMode === 'off') return result; - const check = () => { - const run = runHeadless(this._specBattle(f.spec), { players: f.players }); - const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd }); - const server = mine.ok ? mine.result : run.result; - this.verifyStats.checked++; - if (resultDigest(server).hash !== resultDigest(result).hash) { - this.verifyStats.mismatches++; - this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`); - return server; + _verifyResult(f, result, accept) { + if (this.verifyMode === 'off') { accept(result); return; } + const required = this.verifyMode === 'all'; + const start = () => { + const job = new HeadlessJob(this._specBattle(f.spec), { players: f.players, onError: (e) => this.reportError('verify', e) }); + if (required) { + // The result has arrived: duplicate reports, disconnects and the old deadline must not start another run. + f.verifyJob = job; + this.cancel(f.deadlineTimer); + f.deadlineTimer = null; + f.rearmDeadline = false; } - return null; + const schedule = () => { + const timer = this.later(0, slice); + if (required) f.verifyTimer = timer; + }; + const slice = () => { + if (required) f.verifyTimer = null; + if (this.disposed || this.ended) return; + if (required && (f.verifyJob !== job || f.done || f.mode !== 'client' || !this.fields.includes(f))) return; + let verified = result; + try { + if (!job.run(this.headlessSliceMs)) { schedule(); return; } + const run = job.output(); + const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd }); + const server = mine.ok ? mine.result : run.result; + this.verifyStats.checked++; + if (resultDigest(server).hash !== resultDigest(result).hash) { + this.verifyStats.mismatches++; + this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`); + verified = server; + } + } catch (e) { + this.reportError('verify', e); + if (required) { + this._clearFieldTimers(f); + this._runOnServer(f, 'verify-error'); + } + return; + } + if (required) { f.verifyJob = null; accept(verified); } + }; + if (Number.isFinite(this.headlessSliceMs)) schedule(); + else slice(); // Virtual schedulers retain their synchronous fast path. }; - if (this.verifyMode === 'all') return check() || result; + if (required) { start(); return; } let h = 0; for (let i = 0; i < f.battleId.length; i++) h = (h * 31 + f.battleId.charCodeAt(i)) >>> 0; - if (h % 8 === 0) this.later(0, () => { try { check(); } catch (e) { this.reportError('verify', e); } }); - return result; + // Sample jobs use only match-level timers: field completion must not cancel the diagnostic work. + if (h % 8 === 0) this.later(0, start); + accept(result); } } diff --git a/test/assets-env-proxy.test.js b/test/assets-env-proxy.test.js new file mode 100644 index 00000000..93a3c38d --- /dev/null +++ b/test/assets-env-proxy.test.js @@ -0,0 +1,305 @@ +// HTTP(S)_PROXY for the asset downloader. Node's built-in fetch honours it only when +// the process was started with NODE_USE_ENV_PROXY=1 (Node >=22.21 and >=24). The +// npm undici dispatcher does not reach that fetch, so fetch-assets restarts once +// and every other default fetch fails closed. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { connect as netConnect } from 'node:net'; +import { Downloader } from '../tools/assets/downloader.mjs'; +import { cachedJson } from '../tools/assets/cache.mjs'; +import { + PROXY_ENV_KEYS, REEXEC_MARKER, envProxyPlan, guardDefaultFetch, nodeHonoursEnvProxy, + proxyConfigured, restartForEnvProxy, startedWithEnvProxy, +} from '../tools/assets/env-proxy.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SCRIPT = join(ROOT, 'tools', 'fetch-assets.mjs'); +const PROXY = 'http://127.0.0.1:9'; +const STRIP = [...PROXY_ENV_KEYS, 'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy', 'NODE_USE_ENV_PROXY', REEXEC_MARKER]; + +function cleanEnv(extra = {}) { + const env = { ...process.env }; + for (const key of STRIP) delete env[key]; + return Object.assign(env, extra); +} + +test('version gate: fetch() env proxy is Node >=22.21 and >=24, not 23 or early 22', () => { + assert.equal(nodeHonoursEnvProxy('22.20.0'), false); + assert.equal(nodeHonoursEnvProxy('22.21.0'), true); + assert.equal(nodeHonoursEnvProxy('v22.22.0'), true); + assert.equal(nodeHonoursEnvProxy('23.11.0'), false); + assert.equal(nodeHonoursEnvProxy('24.0.0'), true); + assert.equal(nodeHonoursEnvProxy('24.5.0'), true); + assert.equal(nodeHonoursEnvProxy('25.1.0'), true); + assert.equal(nodeHonoursEnvProxy(''), false); + assert.equal(nodeHonoursEnvProxy('nope'), false); +}); + +test('startup switch: the variable, the flag, and NODE_OPTIONS; a late or lookalike value does not count', () => { + assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '1' }, []), true); + assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: 'true' }, []), false); + assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '' }, []), false); + assert.equal(startedWithEnvProxy({}, ['--use-env-proxy']), true); + assert.equal(startedWithEnvProxy({}, ['--use_env_proxy']), true); + assert.equal(startedWithEnvProxy({}, ['--no-warnings']), false); + assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--no-warnings --use-env-proxy' }, []), true); + assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--use-env-proxy-extra' }, []), false); + assert.equal(startedWithEnvProxy({}, []), false); +}); + +test('plan: unset proxy is off; a proxy restarts, is already on, or is refused', () => { + assert.equal(proxyConfigured({}), false); + assert.equal(proxyConfigured({ HTTP_PROXY: ' ', NO_PROXY: 'localhost' }), false); + assert.equal(envProxyPlan({}).action, 'off'); + assert.equal(envProxyPlan({ NO_PROXY: '127.0.0.1' }).action, 'off'); + assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '22.20.0', false).action, 'unsupported'); + assert.match(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '23.11.0', true).reason, /Node 23\.11\.0/); + assert.equal(envProxyPlan({ http_proxy: 'http://proxy.example:8080' }, '22.21.0', false).action, 'reexec'); + assert.match(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '24.0.0', false).reason, /Refusing to fetch directly/); + // The flag on the object is not the startup snapshot: the third argument is. + assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '22.22.0', false).action, 'reexec'); + assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '24.5.0', true).action, 'already'); + assert.equal(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '25.0.0', true).action, 'already'); +}); + +test('an explicit fetch is returned unchanged', () => { + const fetchImpl = async () => new Response('ok'); + assert.equal(guardDefaultFetch(fetchImpl), fetchImpl); +}); + +test('downloader and index cache fail closed under HTTP_PROXY, and an injected fetch still runs', async (t) => { + // The only test that mutates process.env. Node runs tests in a file concurrently; + // the plan tests above pass their own env object and do not read process.env. + const dir = await mkdtemp(join(tmpdir(), 'sp-env-proxy-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const saved = STRIP.map((key) => [key, process.env[key]]); + for (const key of STRIP) delete process.env[key]; + process.env.HTTP_PROXY = 'http://127.0.0.1:9'; + process.env.HTTPS_PROXY = process.env.HTTP_PROXY; + try { + const plan = envProxyPlan(); + const bare = new Downloader({ + root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {}, + }); + if (plan.action !== 'already') { + const refused = await bare.fetchWithRetries('https://example.invalid/a.json', 'json'); + assert.match(refused.error, /Refusing to fetch directly/); + await assert.rejects(guardDefaultFetch()('https://example.invalid/a.json'), /Refusing to fetch directly/); + // Set after startup. Node has already decided not to proxy, so this must still refuse. + process.env.NODE_USE_ENV_PROXY = '1'; + const late = await bare.fetchWithRetries('https://example.invalid/b.json', 'json'); + assert.match(late.error, /not started with NODE_USE_ENV_PROXY=1/); + delete process.env.NODE_USE_ENV_PROXY; + await assert.rejects( + cachedJson({ + cacheFile: join(dir, 'missing.json'), url: 'https://example.invalid/index.json', + log() {}, backoffMs: 0, timeoutMs: 1000, + }), + /Refusing to fetch directly/, + ); + } + let called = 0; + const injected = new Downloader({ + root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {}, + fetchImpl: async () => { called += 1; return Response.json({ ok: true }); }, + }); + const got = await injected.fetchWithRetries('https://example.invalid/a.json', 'json'); + assert.equal(called, 1); + assert.equal(got.buf.toString(), '{"ok":true}'); + assert.deepEqual(await cachedJson({ + cacheFile: join(dir, 'ok.json'), url: 'https://example.invalid/index.json', log() {}, backoffMs: 0, + fetchImpl: async () => Response.json({ ok: true }), + }), { ok: true }); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test('restart decision: nothing to do, one spawn, or a hard stop — and the marker cannot loop', () => { + const prev = process.exitCode; + const spawned = []; + const logs = []; + const child = new EventEmitter(); + const spawnImpl = (...args) => { spawned.push(args); return child; }; + try { + assert.equal(restartForEnvProxy({ + env: cleanEnv(), spawnImpl, error: (line) => logs.push(line), + }), false); + assert.equal(spawned.length, 0); + + const proxyEnv = cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', KEEP: 'yes' }); + const stopped = restartForEnvProxy({ + env: proxyEnv, + execPath: process.execPath, + execArgv: ['--no-warnings'], + argv: ['node', SCRIPT, '--help'], + spawnImpl, + error: (line) => logs.push(line), + }); + assert.equal(stopped, true); + if (!nodeHonoursEnvProxy(process.versions.node)) { + assert.equal(spawned.length, 0); + assert.match(logs.join('\n'), /does not route fetch/); + return; + } + assert.equal(spawned.length, 1); + const [bin, args, opts] = spawned[0]; + assert.equal(bin, process.execPath); + assert.deepEqual(args, ['--no-warnings', SCRIPT, '--help']); + assert.equal(opts.stdio, 'inherit'); + assert.equal(opts.env.NODE_USE_ENV_PROXY, '1'); + assert.equal(opts.env[REEXEC_MARKER], '1'); + assert.equal(opts.env.KEEP, 'yes'); + assert.equal(opts.env.HTTP_PROXY, 'http://proxy.example:8080'); + assert.match(logs.join('\n'), /restarting once with NODE_USE_ENV_PROXY=1/); + child.emit('close', 0); + + spawned.length = 0; + logs.length = 0; + assert.equal(restartForEnvProxy({ + env: cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', [REEXEC_MARKER]: '1' }), + spawnImpl, + error: (line) => logs.push(line), + }), true); + assert.equal(spawned.length, 0); + assert.match(logs.join('\n'), /refused to restart again/); + } finally { + process.exitCode = prev; + } +}); + +function runHelp(extra) { + return spawnSync(process.execPath, [SCRIPT, '--help'], { + encoding: 'utf8', env: cleanEnv(extra), timeout: 20000, + }); +} + +test('fetch-assets --help restarts once when HTTP(S)_PROXY is set, and a second restart is refused', () => { + const supported = nodeHonoursEnvProxy(process.versions.node); + const first = runHelp({ HTTP_PROXY: PROXY, HTTPS_PROXY: PROXY }); + if (!supported) { + assert.equal(first.status, 1, first.stderr); + assert.match(first.stderr, /does not route fetch/); + assert.equal(first.stdout, ''); + return; + } + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /--allow-shrink/); + assert.equal((first.stderr.match(/restarting once with NODE_USE_ENV_PROXY=1/g) || []).length, 1, first.stderr); + + const already = runHelp({ HTTP_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' }); + assert.equal(already.status, 0, already.stderr); + assert.match(already.stdout, /--allow-shrink/); + assert.doesNotMatch(already.stderr, /restarting once/); + + const loop = runHelp({ HTTP_PROXY: PROXY, [REEXEC_MARKER]: '1' }); + assert.equal(loop.status, 1, loop.stderr); + assert.match(loop.stderr, /refused to restart again/); + assert.equal(loop.stdout, ''); +}); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +test('built-in fetch tunnels through HTTP_PROXY only when NODE_USE_ENV_PROXY was set at startup', { timeout: 30000 }, async (t) => { + if (!nodeHonoursEnvProxy(process.versions.node)) return; + const hits = []; + const sockets = new Set(); + const proxy = createServer((req, res) => { + hits.push(`REQ ${req.method}`); + res.writeHead(500); + res.end(); + }); + proxy.on('connect', (req, client, head) => { + hits.push(`CONNECT ${req.url}`); + const cut = req.url.lastIndexOf(':'); + const upstream = netConnect(Number(req.url.slice(cut + 1)), req.url.slice(0, cut), () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstream.write(head); + upstream.pipe(client); + client.pipe(upstream); + }); + sockets.add(upstream); + upstream.on('error', () => client.destroy()); + client.on('error', () => upstream.destroy()); + }); + proxy.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + const target = createServer((req, res) => { + hits.push(`TARGET ${req.url}`); + res.end('from-target'); + }); + target.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + const proxyPort = await listen(proxy); + const targetPort = await listen(target); + t.after(() => { + for (const socket of sockets) socket.destroy(); + proxy.close(); + target.close(); + }); + const targetUrl = `http://127.0.0.1:${targetPort}/x`; + const proxyUrl = `http://127.0.0.1:${proxyPort}`; + const snippet = ` + const res = await fetch(process.env.TARGET, { signal: AbortSignal.timeout(2500) }); + console.log(JSON.stringify({ status: res.status, body: await res.text() })); + `; + // spawn, not spawnSync: a synchronous child would freeze this server, and fetch would time out. + const run = (extra) => new Promise((resolve, reject) => { + hits.length = 0; + const child = spawn(process.execPath, ['--input-type=module', '-e', snippet], { + env: cleanEnv({ TARGET: targetUrl, ...extra }), stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { child.kill(); reject(new Error(`fetch child hung\n${stderr}`)); }, 8000); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (e) => { clearTimeout(timer); reject(e); }); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { reject(new Error(stderr || `exit ${code}`)); return; } + const body = JSON.parse(stdout.trim().split('\n').pop()); + resolve({ body, hits: [...hits] }); + }); + }); + + const direct = await run({}); + assert.deepEqual(direct.body, { status: 200, body: 'from-target' }); + assert.deepEqual(direct.hits, ['TARGET /x']); + + const ignored = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl }); + assert.deepEqual(ignored.body, { status: 200, body: 'from-target' }); + assert.deepEqual(ignored.hits, ['TARGET /x'], 'a proxy set without the startup switch is ignored by fetch()'); + + const tunneled = await run({ http_proxy: proxyUrl, NODE_USE_ENV_PROXY: '1' }); + assert.deepEqual(tunneled.body, { status: 200, body: 'from-target' }); + assert.deepEqual(tunneled.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']); + + const both = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NODE_USE_ENV_PROXY: '1' }); + assert.equal(both.body.body, 'from-target'); + assert.deepEqual(both.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']); + + const bypass = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: '127.0.0.1', NODE_USE_ENV_PROXY: '1' }); + assert.equal(bypass.body.body, 'from-target'); + assert.deepEqual(bypass.hits, ['TARGET /x']); +}); diff --git a/test/build-data-refresh.test.js b/test/build-data-refresh.test.js new file mode 100644 index 00000000..3556beb1 --- /dev/null +++ b/test/build-data-refresh.test.js @@ -0,0 +1,153 @@ +// Issue #344: a failed --refresh must not feed an unverified stale table into the build. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const SCRIPT = fileURLToPath(new URL('../tools/build-data.mjs', import.meta.url)); +const source = await readFile(SCRIPT, 'utf8'); +// Exercise the real downloader without running the CLI or needing full official game tables. +const begin = source.indexOf('async function ensureGamedata('); +const end = source.indexOf('/** Load (and cache in memory)', begin); +assert.ok(begin >= 0 && end > begin, 'locate the official-data downloader'); +const downloaderSource = source.slice(begin, end); +const TABLES = ['excel/character_table.json', 'excel/skill_table.json']; +const OLD = '{"revision":"old"}'; +const NEW = '{"revision":"new"}'; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), 'sp-data-refresh-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const cache = join(dir, 'cache'); + await mkdir(join(cache, 'excel'), { recursive: true }); + for (const rel of TABLES) await writeFile(join(cache, rel), OLD); + return { dir, cache }; +} + +function downloader(cache, fetchImpl, opts = { refresh: true }) { + const requests = [], warnings = []; + const env = { + CACHE_DIR: cache, OPTS: { refresh: false, offline: false, ...opts }, + GAMEDATA_URL: 'https://example.invalid/gamedata/', + existsSync, mkdir, dirname, join, writeFile, rename, process, AbortSignal, + log: () => {}, warn: (message) => warnings.push(message), + // Skip only retry delays; all attempts, JSON validation and file writes are real. + setTimeout: (callback) => callback(), + fetch: async (url) => { requests.push(url); return fetchImpl(url); }, + }; + const ensure = new Function(...Object.keys(env), `${downloaderSource}\nreturn ensureGamedata;`)(...Object.values(env)); + return { ensure, requests, warnings }; +} + +test('refresh rejects mixed fresh/stale inputs after one table exhausts its retries', async (t) => { + const { cache } = await fixture(t); + const { ensure, requests, warnings } = downloader(cache, async (url) => { + if (url.endsWith('skill_table.json')) throw new Error('simulated outage'); + return new Response(NEW); + }); + const results = await Promise.allSettled(TABLES.map(ensure)); + assert.equal(results[0].status, 'fulfilled'); + assert.equal(results[1].status, 'rejected', 'an old cache cannot satisfy a refresh'); + assert.match(results[1].reason.message, /cannot obtain excel\/skill_table.json: simulated outage/); + assert.equal(requests.filter((url) => url.endsWith('skill_table.json')).length, 4); + assert.equal(await readFile(join(cache, TABLES[0]), 'utf8'), NEW); + assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD); + assert.deepEqual(warnings, [], 'download failure is fatal, not a stale-cache warning'); +}); + +for (const [name, response] of [ + ['HTTP failure', () => new Response('', { status: 503 })], + ['invalid JSON', () => new Response('{truncated')], +]) { + test(`refresh rejects ${name} even with an existing cache`, async (t) => { + const { cache } = await fixture(t); + const { ensure, requests } = downloader(cache, response); + await assert.rejects(ensure(TABLES[1]), /cannot obtain excel\/skill_table.json:/); + assert.equal(requests.length, 4); + assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD); + }); +} + +test('refresh without a cached table reports the download failure', async (t) => { + const { cache } = await fixture(t); + await rm(join(cache, TABLES[1])); + const { ensure, requests } = downloader(cache, () => { throw new Error('simulated outage'); }); + await assert.rejects(ensure(TABLES[1]), /cannot obtain excel\/skill_table.json: simulated outage/); + assert.equal(requests.length, 4); + assert.equal(existsSync(join(cache, TABLES[1])), false); +}); + +test('successful refresh replaces every cached table', async (t) => { + const { cache } = await fixture(t); + const { ensure, requests, warnings } = downloader(cache, () => new Response(NEW)); + const files = await Promise.all(TABLES.map(ensure)); + assert.deepEqual(await Promise.all(files.map((file) => readFile(file, 'utf8'))), [NEW, NEW]); + assert.equal(requests.length, 2); + assert.deepEqual(warnings, []); +}); + +test('refresh can recover on the final retry', async (t) => { + const { cache } = await fixture(t); + let attempts = 0; + const { ensure, warnings } = downloader(cache, () => { + if (++attempts < 4) throw new Error('temporary outage'); + return new Response(NEW); + }); + assert.equal(await readFile(await ensure(TABLES[1]), 'utf8'), NEW); + assert.equal(attempts, 4); + assert.deepEqual(warnings, []); +}); + +for (const offline of [false, true]) { + test(`${offline ? 'offline' : 'normal'} mode reuses cached tables without a request`, async (t) => { + const { cache } = await fixture(t); + const { ensure, requests } = downloader(cache, () => { throw new Error('unexpected request'); }, { offline }); + assert.equal(await readFile(await ensure(TABLES[1]), 'utf8'), OLD); + assert.deepEqual(requests, []); + }); +} + +test('offline mode rejects a missing table without a request', async (t) => { + const { cache } = await fixture(t); + await rm(join(cache, TABLES[1])); + const { ensure, requests } = downloader(cache, () => { throw new Error('unexpected request'); }, { offline: true }); + await assert.rejects(ensure(TABLES[1]), /missing cached file excel\/skill_table.json \(offline mode\)/); + assert.deepEqual(requests, []); +}); + +for (const force of [false, true]) { + test(`CLI refresh failure leaves generated files untouched${force ? ' even with --force' : ''}`, async (t) => { + const { dir, cache } = await fixture(t); + const out = join(dir, 'data'); + const report = join(dir, 'report.json'); + await mkdir(out); + for (const name of ['chess.json', 'config.json']) await writeFile(join(out, name), OLD); + await writeFile(report, OLD); + const preload = join(dir, 'mock-fetch.mjs'); + await writeFile(preload, ` + globalThis.fetch = async (url) => { + if (url.endsWith('/skill_table.json')) throw new Error('simulated outage'); + return new Response(${JSON.stringify(NEW)}); + }; + `); + const result = spawnSync(process.execPath, [ + '--import', pathToFileURL(preload).href, SCRIPT, + '--refresh', '--cache', cache, '--out', out, '--report', report, + ...(force ? ['--force'] : []), + ], { encoding: 'utf8', timeout: 30_000 }); + assert.equal(result.error, undefined); + assert.equal(result.status, 1, result.stderr); + // A schema error from these tiny fixtures would also exit 1: require the actual download error. + assert.match(result.stderr, /cannot obtain excel\/skill_table.json: simulated outage/); + assert.doesNotMatch(result.stdout + result.stderr, /using stale cache|building…/); + assert.equal(await readFile(join(cache, TABLES[0]), 'utf8'), NEW, 'another download succeeded'); + assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD); + assert.deepEqual((await readdir(out)).sort(), ['chess.json', 'config.json']); + for (const name of ['chess.json', 'config.json']) assert.equal(await readFile(join(out, name), 'utf8'), OLD); + assert.equal(await readFile(report, 'utf8'), OLD); + }); +} diff --git a/test/build.test.js b/test/build.test.js index a3979c2f..44d1a82b 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -6,26 +6,56 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { computeBuildTag, buildTag, resetBuildTag, BUILD_INPUTS } from '../server/index.js'; +import { computeBuildTag, buildTag, resetBuildTag } from '../server/index.js'; +import { checkBuildOnce } from '../public/js/ui/buildGuard.js'; -/** A throwaway root with the browser runtime layout (one file per BUILD_INPUTS entry). */ +/** A throwaway browser runtime, independent of the build tag's input list. */ function fixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-')); - for (const rel of BUILD_INPUTS) { + for (const rel of [ + 'public/index.html', 'public/js/file.js', 'public/css/file.css', + 'server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json', + ]) { const abs = path.join(root, rel); - if (path.extname(rel)) { - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, 'x'); - } else { - fs.mkdirSync(abs, { recursive: true }); - fs.writeFileSync(path.join(abs, 'file.js'), 'x'); - } + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'x'); } return root; } -test('BUILD_INPUTS: only the runtime the browser loads (server/, data/ and shared/ are not part of it)', () => { - assert.deepEqual([...BUILD_INPUTS], ['public/index.html', 'public/js', 'public/css']); +for (const rel of ['server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json']) { + test(`buildTag: an update to ${rel} is visible to an old page after restart only`, async () => { + const root = fixture(); + try { + resetBuildTag(); + const known = buildTag(root); + const fetchFn = async () => ({ ok: true, json: async () => ({ build: buildTag(root) }) }); + fs.writeFileSync(path.join(root, rel), 'updated runtime'); + assert.notEqual(computeBuildTag(root), known, 'the updated file changes the startup fingerprint'); + assert.equal(buildTag(root), known, 'the running process keeps its startup tag'); + assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'current'); + resetBuildTag(); // The next process computes its tag from the deployed files. + assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'new'); + } finally { + resetBuildTag(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('computeBuildTag: server-only code and downloaded media do not change the browser build', () => { + const root = fixture(); + try { + const known = computeBuildTag(root); + for (const rel of ['server/index.js', 'server/http/routes.js', 'server/data.js', 'public/assets/sprite.png']) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'changed'); + } + assert.equal(computeBuildTag(root), known); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); test('computeBuildTag: stable for one tree, different when a runtime file changes (size or mtime)', () => { @@ -39,7 +69,7 @@ test('computeBuildTag: stable for one tree, different when a runtime file change const b = computeBuildTag(root); assert.notEqual(b, a, 'a changed runtime file is a new build'); // …and so is a rewrite with the same size but a new mtime (a deploy of identical bytes keeps its timestamp) - const target = path.join(root, 'public/css/file.js'); + const target = path.join(root, 'public/css/file.css'); const st = fs.statSync(target); fs.utimesSync(target, st.atime, new Date(st.mtimeMs + 5000)); assert.notEqual(computeBuildTag(root), b); @@ -52,10 +82,12 @@ test('computeBuildTag: dot files and editor backups are ignored (the static serv const root = fixture(); try { const a = computeBuildTag(root); - fs.writeFileSync(path.join(root, 'public/js/.DS_Store'), 'x'); - fs.writeFileSync(path.join(root, 'public/css/main.css~'), 'x'); - fs.mkdirSync(path.join(root, 'public/js/.cache'), { recursive: true }); - fs.writeFileSync(path.join(root, 'public/js/.cache/leftover.js'), 'x'); + for (const rel of ['public/js', 'server/sim', 'shared', 'data']) { + fs.writeFileSync(path.join(root, rel, '.DS_Store'), 'x'); + fs.writeFileSync(path.join(root, rel, 'file.js~'), 'x'); + fs.mkdirSync(path.join(root, rel, '.cache'), { recursive: true }); + fs.writeFileSync(path.join(root, rel, '.cache/leftover.js'), 'x'); + } assert.equal(computeBuildTag(root), a, 'a .DS_Store / backup / dot directory in the tree is not a new build'); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/test/ci.test.js b/test/ci.test.js new file mode 100644 index 00000000..e45379b2 --- /dev/null +++ b/test/ci.test.js @@ -0,0 +1,149 @@ +// test/ci.test.js — tools/ci.mjs (the local run of .github/workflows/ci.yml): its step list is ci.yml's, the reader of +// the workflow, the arguments, the env and the summary. Nothing here boots a server or runs a step. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + CI_TEST_ENV, ROOT, SKIPPED_CI_STEPS, SMOKE_PATHS, STEPS, binPath, formatDuration, formatSummary, isSkippedCiStep, + parseArgs, parseWorkflow, selectSteps, stepEnv, +} from '../tools/ci.mjs'; + +const workflow = parseWorkflow(fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8')); +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + +/** ci.yml's smoke run block as smoke() in tools/ci.mjs mirrors it (lines trimmed, comments dropped). */ +const SMOKE_CI_RUN = [ + 'node server/index.js > server.log 2>&1 &', + 'pid=$!', + 'ok=', + 'for i in $(seq 1 30); do', + 'if curl -fsS http://127.0.0.1:3000/healthz; then ok=1; break; fi', + 'sleep 1', + 'done', + 'echo', + '[ -n "$ok" ] || { cat server.log; exit 1; }', + 'curl -fsS -o index.out http://127.0.0.1:3000/', + "grep -qi ' { + const ciSteps = Object.entries(workflow.jobs).flatMap(([job, j]) => j.steps.map((s) => ({ job, ...s }))); + assert.ok(ciSteps.length >= 6, 'ci.yml was read'); + const unknown = ciSteps.filter((s) => !isSkippedCiStep(s) && !STEPS.some((l) => l.ci.job === s.job && l.ci.name === s.name)); + assert.deepEqual(unknown.map((s) => `${s.job}: ${s.name || s.uses}`), [], 'ci.yml steps tools/ci.mjs does not run'); + for (const l of STEPS) { + assert.ok(ciSteps.some((s) => s.job === l.ci.job && s.name === l.ci.name), `${l.id} mirrors no ci.yml step (${l.ci.job}: ${l.ci.name})`); + } + for (const k of SKIPPED_CI_STEPS) assert.ok(ciSteps.some((s) => isSkippedCiStep(s) && (k.uses ? s.uses?.startsWith(k.uses) : s.name === k.name)), `stale skip ${k.uses || k.name}`); + // the local order is the workflow's (job test, then job tooling) + const order = ciSteps.filter((s) => !isSkippedCiStep(s)).map((s) => STEPS.find((l) => l.ci.job === s.job && l.ci.name === s.name).id); + assert.deepEqual(order, STEPS.map((s) => s.id)); +}); + +test('the commands match ci.yml: the run lines, the npm scripts ci.yml calls, the smoke script, the job env, no step config', () => { + const run = (job, name) => workflow.jobs[job].steps.find((s) => s.name === name).run; + for (const l of STEPS) { + const r = run(l.ci.job, l.ci.name); + if (l.npm) { + assert.equal(r, `npm run ${l.npm}`, l.id); + assert.equal(pkg.scripts[l.npm], l.describe, `package.json script ${l.npm} is what ${l.id} runs`); + } else if (l.id !== 'smoke') assert.equal(r, l.describe, l.id); + } + const smokeRun = run('test', STEPS.find((s) => s.id === 'smoke').ci.name); + const urls = [...smokeRun.matchAll(/http:\/\/127\.0\.0\.1:3000([^\s;]*)/g)].map((m) => m[1] || '/'); + assert.deepEqual([...new Set(urls)].sort(), [...SMOKE_PATHS].sort()); + // The whole smoke script, comments and indentation aside: any edit to it (the wait loop, the page check, …) fails + // here, so that smoke() in tools/ci.mjs is looked at and this copy updated with it. + const normalized = smokeRun.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); + assert.deepEqual(normalized, SMOKE_CI_RUN, "ci.yml's smoke step changed: update smoke() in tools/ci.mjs and SMOKE_CI_RUN"); + // job env: ci.yml's job test env is CI_TEST_ENV; any other job has none (stepEnv gives the tooling steps nothing) + for (const [job, j] of Object.entries(workflow.jobs)) { + assert.deepEqual(j.env, job === 'test' ? CI_TEST_ENV : {}, `env of job ${job}`); + } + // per-step config (env, if, continue-on-error, working-directory, with, …) on a mirrored step is not mirrored + for (const l of STEPS) { + const s = workflow.jobs[l.ci.job].steps.find((x) => x.name === l.ci.name); + assert.deepEqual(s.other || [], [], `${l.id}: ci.yml step keys tools/ci.mjs does not mirror`); + assert.ok(!s.shell || l.id === 'smoke', `${l.id}: shell: ${s.shell}`); + } + assert.equal(pkg.scripts.ci, 'node tools/ci.mjs'); +}); + +test('the workflow reader: jobs, flat env, named and uses steps, run blocks; comments and nested blocks skipped, other step keys named', () => { + const yml = [ + 'name: X', 'on:', ' push:', 'jobs:', ' a:', ' runs-on: x', ' env:', " K: '1'", ' L: "two"', ' steps:', + ' - uses: actions/checkout@v7', ' - uses: actions/setup-node@v7', ' with:', ' node-version: 22', + ' # a comment', ' - name: One', ' if: always()', ' run: echo 1', ' env:', ' X: 1', + ' - name: Block', ' shell: bash', ' run: |', + ' a', '', ' b', ' c', ' b:', ' steps:', ' - run: x', 'other: 1', ' ignored: 2', + ].join('\r\n'); + assert.deepEqual(parseWorkflow(yml), { + jobs: { + a: { env: { K: '1', L: 'two' }, steps: [ + { uses: 'actions/checkout@v7' }, { uses: 'actions/setup-node@v7', other: ['with'] }, { name: 'One', run: 'echo 1', other: ['if', 'env'] }, + { name: 'Block', shell: 'bash', run: 'a\n\n b\nc' }, + ] }, + b: { env: {}, steps: [{ run: 'x' }] }, + }, + }); + assert.deepEqual(parseWorkflow('name: no jobs\n'), { jobs: {} }); +}); + +test('arguments: --keep-going, --only (comma list, repeated, = form) in workflow order, --list, --help; bad ones throw', () => { + assert.deepEqual(parseArgs([]), { keepGoing: false, only: null, list: false, help: false }); + assert.deepEqual(parseArgs(['-k', '--only', 'lint,test', '--only=smoke']), { keepGoing: true, only: ['lint', 'test', 'smoke'], list: false, help: false }); + assert.deepEqual(selectSteps(['lint', 'test', 'smoke']).map((s) => s.id), ['test', 'smoke', 'lint']); + assert.equal(selectSteps(null).length, STEPS.length); + assert.equal(parseArgs(['--list']).list, true); + assert.equal(parseArgs(['-h']).help, true); + assert.throws(() => parseArgs(['--only', 'golden']), /unknown step "golden"/); + assert.throws(() => parseArgs(['--only']), /needs step names/); + assert.throws(() => parseArgs(['--fast']), /unknown option --fast/); +}); + +test("env: the job test steps get ci.yml's env for unset keys only; the tooling steps get the caller's env as is", () => { + const test_ = STEPS.find((s) => s.id === 'test'); + const lint = STEPS.find((s) => s.id === 'lint'); + assert.deepEqual(stepEnv(test_, { PATH: 'p', SP_E2E: '1' }), { PATH: 'p', SP_E2E: '1', SP_REAL_E2E: '0', RENDER_E2E: '0', NO_COLOR: '1' }); + assert.deepEqual(stepEnv(lint, { PATH: 'p' }), { PATH: 'p' }); +}); + +test('summary: one row per step (status, name, duration, note), the failed steps named; durations', () => { + assert.equal(formatDuration(42.4), '42ms'); + assert.equal(formatDuration(1234), '1.2s'); + assert.equal(formatDuration(125_000), '2m05s'); + const s = formatSummary([ + { id: 'setup', status: 'ok', ms: 400 }, + { id: 'test', status: 'fail', ms: 61_000, note: 'exit 1' }, + { id: 'typecheck', status: 'not run' }, + ]); + assert.equal(s, [ + '', '── ci summary ──', + ' ok setup 400ms', + ' FAIL test 1m01s exit 1', + ' - typecheck', + 'FAILED: test', '', + ].join('\n')); + assert.match(formatSummary([{ id: 'lint', status: 'ok', ms: 1 }]), /all steps passed/); +}); + +test('the eslint and tsc entry points come from their package.json bin; a missing package says to run npm ci', () => { + assert.ok(fs.existsSync(binPath('eslint', 'eslint'))); + assert.ok(fs.existsSync(binPath('typescript', 'tsc'))); + assert.throws(() => binPath('no-such-package-sp', 'x'), /npm ci/); +}); + +test('node tools/ci.mjs --list and a bad option run without npm (exit 0 / 2)', () => { + const list = spawnSync(process.execPath, ['tools/ci.mjs', '--list'], { cwd: ROOT, encoding: 'utf8' }); + assert.equal(list.status, 0, list.stderr); + for (const s of STEPS) assert.match(list.stdout, new RegExp(`^${s.id} `, 'm')); + const bad = spawnSync(process.execPath, ['tools/ci.mjs', '--only', 'nope'], { cwd: ROOT, encoding: 'utf8' }); + assert.equal(bad.status, 2); + assert.match(bad.stderr, /unknown step "nope"/); +}); diff --git a/test/client-identity-session.test.js b/test/client-identity-session.test.js new file mode 100644 index 00000000..18a97e1f --- /dev/null +++ b/test/client-identity-session.test.js @@ -0,0 +1,143 @@ +// Real BroadcastChannel election followed by real client/server session handshakes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { BroadcastChannel } from 'node:worker_threads'; +import WebSocket from 'ws'; +import { startServer } from '../server/index.js'; +import { CLAIM_QUERY_MS, createIdentity, Net } from '../public/js/net.js'; + +function storage(seed = {}) { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + }; +} + +function nextEvent(net, type) { + return new Promise((resolve, reject) => { + const off = net.on(type, (value) => { clearTimeout(timer); off(); resolve(value); }); + const timer = setTimeout(() => { off(); reject(new Error(`Timed out waiting for ${type}`)); }, 2000); + }); +} + +async function harness(t) { + const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + const clients = [], channels = [], replaced = []; + const local = storage(); + const channelName = `sp.identity.test.${process.pid}.${srv.port}`; + t.after(async () => { + clients.forEach((net) => net.close()); + channels.forEach((channel) => channel.close()); + await srv.close(); + }); + return { + local, replaced, + tab(tabId, session = storage(), setTimeout) { + const channel = new BroadcastChannel(channelName); + channels.push(channel); + const id = createIdentity({ local, session, channel, tabId, setTimeout }); + return { id, session, channel }; + }, + async connect(name, tab) { + const net = new Net({ url: `ws://127.0.0.1:${srv.port}/ws`, WebSocket, getToken: () => tab?.id.getToken() ?? null }); + clients.push(net); + net.on('replaced', (error) => replaced.push({ name, code: error.code })); + net.on('welcome', (welcome) => tab?.id.saveToken(welcome.token)); + const welcome = nextEvent(net, 'welcome'); + net.setName(name); + return { net, welcome: await welcome }; + }, + }; +} + +async function closeClient(net) { + const closed = once(net.ws, 'close'); + net.close(); + await closed; +} + +async function assertOnline(h, clients) { + // A round trip from each socket also lets a queued replacement close reach the client. + const replies = await Promise.allSettled(clients.map(({ net }) => net.request('ping', { c: Date.now() }))); + assert.deepEqual(h.replaced, [], 'no client is replaced by another tab'); + for (const [index, client] of clients.entries()) { + assert.equal(client.net.status, 'online'); + assert.equal(client.net.lastError, null); + assert.equal(replies[index].status, 'fulfilled'); + assert.equal(replies[index].value.t, 'pong'); + } +} + +for (const [firstId, secondId] of [['a-tab', 'b-tab'], ['b-tab', 'a-tab']]) { + test(`staggered tabs ${firstId} then ${secondId} keep separate server sessions`, { timeout: 5000 }, async (t) => { + const h = await harness(t); + const seed = await h.connect('Seed'); + await closeClient(seed.net); + h.local.setItem('sp.tokens', JSON.stringify([seed.welcome.token])); + + const deadlines = []; + const holdDeadline = (fn, ms) => { assert.equal(ms, CLAIM_QUERY_MS); deadlines.push(fn); }; + const first = h.tab(firstId, storage(), holdDeadline); + const firstInit = first.id.init(); + // The first who has already been sent when the second channel is created. + const second = h.tab(secondId, storage(), holdDeadline); + const delivered = new Promise((resolve) => { + second.channel.addEventListener('message', (event) => { + if (event.data.type === 'test.barrier') resolve(); + }); + }); + first.channel.addEventListener('message', (event) => { + if (event.data.type === 'who') first.channel.postMessage({ type: 'test.barrier' }); + }); + const secondInit = second.id.init(); + // FIFO delivery puts this barrier after any real reply to the later who. Both default + // 150 ms deadlines remain pending, without relying on sleeps or a fake channel hub. + await delivered; + assert.equal(deadlines.length, 2); + deadlines[0](); + await firstInit; + deadlines[1](); + await secondInit; + + const a = await h.connect('First', first); + const b = await h.connect('Second', second); + await assertOnline(h, [a, b]); + assert.notEqual(a.welcome.token, b.welcome.token); + assert.notEqual(a.welcome.playerId, b.welcome.playerId); + const resumed = firstId < secondId ? a : b; + assert.equal(resumed.welcome.token, seed.welcome.token); + assert.equal(resumed.welcome.playerId, seed.welcome.playerId); + }); +} + +test('live token ownership survives a duplicated tab, reload and reconnect', { timeout: 5000 }, async (t) => { + const h = await harness(t); + const owner = h.tab('z-owner'); + await owner.id.init(); + const a = await h.connect('Owner', owner); + + // A duplicated tab copies sessionStorage, but its smaller tab ID cannot evict a live owner. + const duplicate = h.tab('a-duplicate', storage({ 'sp.token': a.welcome.token })); + await duplicate.id.init(); + const b = await h.connect('Duplicate', duplicate); + assert.notEqual(a.welcome.token, b.welcome.token); + assert.notEqual(a.welcome.playerId, b.welcome.playerId); + await assertOnline(h, [a, b]); + + await closeClient(a.net); + owner.channel.close(); + const reloaded = h.tab('z-reloaded', owner.session); + await reloaded.id.init(); + const a2 = await h.connect('Owner', reloaded); + assert.equal(a2.welcome.token, a.welcome.token); + assert.equal(a2.welcome.playerId, a.welcome.playerId); + const reconnected = nextEvent(a2.net, 'welcome'); + a2.net.reconnectNow(); + const welcome = await reconnected; + assert.equal(welcome.token, a.welcome.token); + assert.equal(welcome.playerId, a.welcome.playerId); + await assertOnline(h, [a2, b]); +}); diff --git a/test/client-static.test.js b/test/client-static.test.js index af58833d..db8c8437 100644 --- a/test/client-static.test.js +++ b/test/client-static.test.js @@ -790,6 +790,58 @@ describe('identity (reconnect-token selection across tabs)', () => { assert.equal(ty, null); }); + for (const [firstId, secondId, expected] of [ + ['a-tab', 'b-tab', ['tokShared', null]], + ['b-tab', 'a-tab', [null, 'tokShared']], + ]) { + test(`staggered channels: ${firstId} starts before ${secondId}`, async () => { + const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js'); + const hub = channelHub(), timers = fakeTimers(), local = memStorage(); + local.setItem('sp.tokens', JSON.stringify(['tokShared'])); + const firstSession = memStorage(), secondSession = memStorage(); + const first = createIdentity({ local, session: firstSession, tabId: firstId, + channel: hub.create(), setTimeout: timers.setTimeout }); + const firstPick = first.init(); + timers.advance(30); + // This channel did not exist when the first tab broadcast its query. + const second = createIdentity({ local, session: secondSession, tabId: secondId, + channel: hub.create(), setTimeout: timers.setTimeout }); + const secondPick = second.init(); + await new Promise((r) => setImmediate(r)); + timers.advance(CLAIM_QUERY_MS - 30); + assert.equal(await firstPick, expected[0]); + timers.advance(30); + assert.equal(await secondPick, expected[1]); + assert.deepEqual([first.getToken(), second.getToken()], expected); + assert.deepEqual([firstSession.getItem('sp.token'), secondSession.getItem('sp.token')], expected); + }); + } + + test('staggered elections reserve fallback candidates while a live owner keeps its token', async () => { + const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js'); + const hub = channelHub(), timers = fakeTimers(), local = memStorage(); + const owner = createIdentity({ local, session: memStorage(), tabId: 'z-owner', channel: hub.create() }); + await owner.init(); + owner.saveToken('tokOwned'); + local.setItem('sp.tokens', JSON.stringify(['tokOwned', 'tokFree'])); + const first = createIdentity({ local, session: memStorage(), tabId: 'a-tab', + channel: hub.create(), setTimeout: timers.setTimeout }); + const firstPick = first.init(); + timers.advance(30); + const duplicateSession = memStorage(); + duplicateSession.setItem('sp.token', 'tokOwned'); + const second = createIdentity({ local, session: duplicateSession, tabId: 'b-tab', + channel: hub.create(), setTimeout: timers.setTimeout }); + const secondPick = second.init(); + await new Promise((r) => setImmediate(r)); + timers.advance(CLAIM_QUERY_MS - 30); + assert.equal(await firstPick, 'tokFree'); + timers.advance(30); + assert.equal(await secondPick, null); + assert.equal(duplicateSession.getItem('sp.token'), null, 'the copied live token is discarded'); + assert.equal(owner.getToken(), 'tokOwned', 'a live owner outranks even a smaller tab id'); + }); + test('without BroadcastChannel only the own token is used; init is idempotent; welcome during init wins', async () => { const { createIdentity } = await mod('net.js'); const local = memStorage(); diff --git a/test/ipv6-bind.test.js b/test/ipv6-bind.test.js new file mode 100644 index 00000000..a3b0c434 --- /dev/null +++ b/test/ipv6-bind.test.js @@ -0,0 +1,208 @@ +// test/ipv6-bind.test.js — dual-stack listen (accepted as #188, ported from PR #291 without /dev/grant, +// the settings migration, the firewall scripts, or a Dockerfile HOST change). +// +// With HOST unset the server binds '::' (Node leaves ipv6Only off, so one socket answers IPv6 and IPv4). +// That default is the only host retried, and only on EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL. An explicit HOST, +// including 0.0.0.0 and 127.0.0.1, is tried once. The local URL brackets an IPv6 literal. The image stays on +// 0.0.0.0 until an alpine boot of the fallback has actually been run. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { classifyAddresses, hostUrl } from '../tools/doctor.mjs'; +import { DEFAULT_BIND_HOST, listenAddress, bindCandidates } from '../server/http/config.js'; +import { displayHost } from '../server/http/boot.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const doc = (p) => readFileSync(join(ROOT, p), 'utf8'); + +function restoreHost(saved) { + if (saved === undefined) delete process.env.HOST; + else process.env.HOST = saved; +} + +test('the listen entry points default to ::, and the image stays on IPv4', () => { + assert.match(doc('server/http/config.js'), /export const DEFAULT_BIND_HOST = '::';/); + assert.match(doc('server/http/config.js'), /opts\.host \|\| process\.env\.HOST\) \|\| DEFAULT_BIND_HOST/); + assert.match(doc('server/http/boot.js'), /for \(const u of lanUrls\(srv\.port\)\)/); + assert.match(doc('scripts/run-server.cmd'), /^\s*set "HOST=::"$/m); + assert.match(doc('scripts/install-service-windows.ps1'), /\[string\]\$BindHost = '::',/); + assert.match(doc('scripts/install-service-windows.ps1'), /Select-String -Pattern 'http:\/\/'/); + assert.doesNotMatch(doc('scripts/install-service-windows.ps1'), /http:\/\/\\d/); + assert.match(doc('scripts/launch.mjs'), /process\.env\.HOST \|\| '::'/); + assert.match(doc('tools/doctor.mjs'), /process\.env\.HOST \|\| '::'/); + assert.match(doc('README.md'), /\| `HOST` \| `::` \|/); + assert.match(doc('Dockerfile'), /HOST=0\.0\.0\.0/); + assert.doesNotMatch(doc('Dockerfile'), /HOST=::/); +}); + +test('listenAddress: the option wins over the environment, the environment over the default', () => { + const saved = process.env.HOST; + try { + delete process.env.HOST; + assert.deepEqual(listenAddress({}), { port: 3000, host: DEFAULT_BIND_HOST }); + assert.equal(listenAddress({ host: '' }).host, DEFAULT_BIND_HOST, 'an empty host is no host'); + process.env.HOST = '127.0.0.1'; + assert.equal(listenAddress({}).host, '127.0.0.1', 'HOST is kept when it is set'); + assert.equal(listenAddress({ host: '0.0.0.0' }).host, '0.0.0.0', 'the option wins over HOST'); + } finally { + restoreHost(saved); + } + assert.equal(listenAddress({ port: 0 }).port, 0, 'port 0 is a real port (an ephemeral one)'); + assert.equal(listenAddress({ host: '127.0.0.1' }).host, '127.0.0.1'); + assert.throws(() => listenAddress({ port: 70000 }), RangeError); +}); + +test('bindCandidates: only the default is retried, an explicit host is literal', () => { + assert.deepEqual(bindCandidates(DEFAULT_BIND_HOST), ['::', '0.0.0.0']); + assert.deepEqual(bindCandidates('0.0.0.0'), ['0.0.0.0']); + assert.deepEqual(bindCandidates('127.0.0.1'), ['127.0.0.1']); + assert.deepEqual(bindCandidates('192.168.1.7'), ['192.168.1.7']); + assert.deepEqual(bindCandidates('2001:db8::1'), ['2001:db8::1']); +}); + +test('an IPv6 literal is bracketed in a URL, a wildcard bind reads localhost', () => { + assert.equal(hostUrl('192.168.1.7', 3000), 'http://192.168.1.7:3000'); + assert.equal(hostUrl('100.64.0.9', 3000), 'http://100.64.0.9:3000'); + assert.equal(hostUrl('240e:3b7:8c4:40f0::1000', 3000), 'http://[240e:3b7:8c4:40f0::1000]:3000'); + assert.equal(hostUrl('fe80::320d:9eff:fe07:e79a', 8080), 'http://[fe80::320d:9eff:fe07:e79a]:8080'); + assert.equal(displayHost('0.0.0.0'), 'localhost'); + assert.equal(displayHost('::'), 'localhost'); + assert.equal(displayHost('::1'), '[::1]'); + assert.equal(displayHost('2001:db8::1'), '[2001:db8::1]'); + assert.equal(displayHost('127.0.0.1'), '127.0.0.1'); +}); + +test('classifyAddresses: IPv6 kinds, and one entry per /64', () => { + const v6 = (name, ...addresses) => [name, addresses.map((address) => ({ family: 'IPv6', address, internal: false }))]; + const list = classifyAddresses(Object.fromEntries([ + v6('以太网', 'fe80::1', '240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d', '240e:3b7:8c4:40f0:7c4a:8d15:f95:fc16', 'fd00::5'), + v6('vEthernet (Default Switch)', '2001:db8::1', '2002:c0a8:101::1'), + v6('Tailscale', 'fd7a:115c:a1e0::1'), + ['Loopback Pseudo-Interface 1', [{ family: 'IPv6', address: '::1', internal: true }]], + ])); + const kindOf = (address) => list.find((a) => a.address === address)?.kind; + assert.equal(kindOf('240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d'), 'public'); + assert.equal(kindOf('fe80::1'), 'linklocal'); + assert.equal(kindOf('fd00::5'), 'lan'); + assert.equal(kindOf('fd7a:115c:a1e0::1'), 'vpn'); + assert.equal(kindOf('2001:db8::1'), 'virtual'); + assert.equal(kindOf('2002:c0a8:101::1'), 'virtual'); + assert.equal(list.filter((a) => a.kind === 'public').length, 1, 'the two addresses of one /64 collapse into one'); + assert.ok(!list.some((a) => a.address === '::1'), 'loopback is internal'); +}); + +test('lanUrls lists IPv4 first, brackets IPv6, and drops link-local', async () => { + const { lanUrls } = await import('../server/index.js'); + const urls = lanUrls(3000); + for (const u of urls) assert.match(u, /^http:\/\/(\[[0-9a-fA-F:]+\]|\d{1,3}(?:\.\d{1,3}){3}):3000$/, u); + assert.ok(!urls.some((u) => u.includes('[fe80:')), 'no link-local URLs'); + const firstV6 = urls.findIndex((u) => u.includes('[')); + if (firstV6 > 0) assert.ok(urls.slice(0, firstV6).every((u) => !u.includes('[')), 'IPv4 comes before IPv6'); +}); + +test('the default bind is one dual-stack socket, and an explicit host is not rewritten', async (t) => { + const hasV6 = await new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', () => resolve(false)); + probe.listen(0, '::', () => probe.close(() => resolve(true))); + }); + if (!hasV6) { t.skip('this host has no IPv6'); return; } + + const saved = process.env.HOST; + delete process.env.HOST; + const { startServer } = await import('../server/index.js'); + const get = (srv, host) => new Promise((resolve) => { + const req = http.get({ host, port: srv.port, path: '/healthz' }, (res) => { res.resume(); resolve(res.statusCode); }); + req.on('error', (e) => resolve(e.code || 0)); + }); + const dual = await startServer({ port: 0, quiet: true }); + try { + assert.equal(dual.host, '::'); + assert.equal(dual.server.address().family, 'IPv6'); + assert.match(dual.url, /^http:\/\/localhost:\d+$/); + assert.equal(await get(dual, '::1'), 200); + assert.equal(await get(dual, '127.0.0.1'), 200); + } finally { + await dual.close(); + } + + const loop = await startServer({ port: 0, host: '::1', quiet: true }); + try { + assert.equal(loop.host, '::1'); + assert.match(loop.url, /^http:\/\/\[::1\]:\d+$/); + assert.equal(await get(loop, '::1'), 200); + } finally { + await loop.close(); + } + + const v4 = await startServer({ port: 0, host: '0.0.0.0', quiet: true }); + try { + assert.equal(v4.host, '0.0.0.0'); + assert.match(v4.url, /^http:\/\/localhost:\d+$/); + assert.equal(await get(v4, '127.0.0.1'), 200); + assert.notEqual(await get(v4, '::1'), 200, 'HOST=0.0.0.0 stays IPv4 only'); + } finally { + await v4.close(); + restoreHost(saved); + } +}); + +test('when :: cannot be bound, the default falls back to 0.0.0.0; EADDRINUSE does not', async () => { + const proto = http.Server.prototype; + const orig = proto.listen; + const saved = process.env.HOST; + delete process.env.HOST; + const { startServer } = await import('../server/index.js'); + + const stub = (failCode) => { + const tried = []; + proto.listen = function (port, host, ...rest) { + tried.push(host); + if (host === '::') { + const err = Object.assign(new Error(failCode), { code: failCode }); + process.nextTick(() => this.emit('error', err)); + return this; + } + return orig.call(this, port, host, ...rest); + }; + return tried; + }; + + try { + const tried = stub('EAFNOSUPPORT'); + const srv = await startServer({ port: 0, quiet: true }); + try { + assert.deepEqual(tried, ['::', '0.0.0.0']); + assert.equal(srv.host, '0.0.0.0'); + assert.match(srv.url, /^http:\/\/localhost:\d+$/); + assert.equal(srv.server.address().family, 'IPv4'); + } finally { + await srv.close(); + } + + const refused = stub('EADDRINUSE'); + await assert.rejects(() => startServer({ port: 0, quiet: true }), (e) => e && e.code === 'EADDRINUSE'); + assert.deepEqual(refused, ['::'], 'a port conflict is not an IPv6-missing error'); + + const explicit = []; + proto.listen = function (port, host, ...rest) { + explicit.push(host); + const err = Object.assign(new Error('EAFNOSUPPORT'), { code: 'EAFNOSUPPORT' }); + process.nextTick(() => this.emit('error', err)); + return this; + }; + await assert.rejects( + () => startServer({ port: 0, host: '::1', quiet: true }), + (e) => e && e.code === 'EAFNOSUPPORT', + ); + assert.deepEqual(explicit, ['::1'], 'an explicit host is not retried'); + } finally { + proto.listen = orig; + restoreHost(saved); + } +}); diff --git a/test/match/verification-slices.test.js b/test/match/verification-slices.test.js new file mode 100644 index 00000000..8939bdf5 --- /dev/null +++ b/test/match/verification-slices.test.js @@ -0,0 +1,249 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout as pause } from 'node:timers/promises'; +import { PHASE } from '../../shared/constants.js'; +import { runHeadless, validateClientResult, RESULT_GRACE_MS } from '../../server/match/fields.js'; +import { RealScheduler } from '../../server/match/scheduler.js'; +import { compactResult, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js'; +import { makeMatch } from './harness.js'; + +const SLICE_MS = 0.000001; + +function combat(t, verify = 'all', options = {}) { + const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true, + clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0, ...options }); + t.after(() => h.m.dispose()); + h.start(); + assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT)); + return h; +} + +function results(h, f) { + const run = runHeadless(createBattleFromSpec(f.spec, h.m.ds, { recordEvents: false, quiet: true }), { players: f.players }); + const checked = validateClientResult(f.spec, compactResult(run.result), { gd: h.m.gd }); + assert.ok(checked.ok, checked.reason); + const server = checked.result; + assert.ok(server.perPlayer.p_0.leaked.length > 0, 'the empty board leaks real enemies'); + const forged = structuredClone(server); + forged.killed = forged.total; + Object.assign(forged.perPlayer.p_0, { leaked: [], perfect: true, killed: forged.perPlayer.p_0.total }); + const plausible = validateClientResult(f.spec, forged, { gd: h.m.gd }); + assert.ok(plausible.ok, plausible.reason); + return { server, forged: plausible.result }; +} + +function report(h, f, result) { + assert.deepEqual(h.m.handle('p_0', { t: 'b.result', battleId: f.battleId, result }), { ok: true }); +} + +function observeSimulation(m, onStep = () => {}) { + const seen = { battles: 0, steps: 0 }; + const create = m._specBattle.bind(m); + m._specBattle = (...args) => { + const battle = create(...args); + seen.battles++; + const step = battle.step.bind(battle); + battle.step = (...stepArgs) => { + const result = step(...stepArgs); + seen.steps++; + onStep(battle, seen); + return result; + }; + return battle; + }; + return seen; +} + +test('all verification yields between slices and settles with the synchronous server digest', (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { server, forged } = results(h, f); + let between = null; + const seen = observeSimulation(m, (battle, state) => { + if (state.steps === 1) h.sched.setTimeout(() => { + between = { steps: state.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done }; + }, 0); + }); + report(h, f, forged); + assert.equal(seen.steps, 0, 'report handling schedules simulation without stepping it'); + assert.equal(f.done, false, 'the result awaits verification'); + assert.equal(m.lastResults.has('p_0'), false, 'settlement cannot consume the unverified report'); + assert.ok(h.run(() => m.verifyStats.checked === 1)); + assert.ok(between && between.steps > 0, 'an unrelated timer runs after simulation starts'); + assert.deepEqual([between.finished, between.checked, between.done], [false, 0, false], 'the timer runs between unfinished slices'); + assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'sliced verification matches synchronous simulation'); + assert.equal(m.verifyStats.mismatches, 1); + assert.equal(m.verifyStats.rejected, 0, 'the forgery passes plausibility validation'); + h.runToPhase(PHASE.SETTLE); + assert.deepEqual(m.lastResults.get('p_0').leaked, server.perPlayer.p_0.leaked); + assert.equal(m.errorCount, 0); +}); + +for (const verify of ['all', 'sample']) { + test(`${verify} verification lets an unrelated real timer run between battle slices`, async (t) => { + const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true, + clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0 }); + const m = h.m; + // Install the real scheduler before start, while the harness has no queued timers. + const scheduler = new RealScheduler(); + m.sched = scheduler; + t.after(() => { m.dispose(); scheduler.dispose(); }); + const waitFor = async (predicate) => { + const deadline = Date.now() + 10000; + while (!predicate() && Date.now() < deadline) await pause(1); + assert.ok(predicate(), `timed out in phase ${m.phase}`); + }; + h.autoHumans().start(); + await waitFor(() => m.phase === PHASE.COMBAT); + const f = m.fields[0]; + const result = compactResult(runHeadless(createBattleFromSpec(f.spec, m.ds, { recordEvents: false, quiet: true }), { players: f.players }).result); + let between = null; + observeSimulation(m, (battle, seen) => { + if (seen.steps === 1) setTimeout(() => { + between = { steps: seen.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done }; + }, 0); + }); + report(h, f, result); + await waitFor(() => between !== null); + assert.ok(between.steps > 0, 'the timer was scheduled by the first actual battle step'); + assert.deepEqual([between.finished, between.checked, between.done], [false, 0, verify === 'sample'], 'Node executes the timer while verification is unfinished'); + await waitFor(() => m.verifyStats.checked === 1); + assert.equal(resultDigest(f.result).hash, resultDigest(result).hash); + assert.equal(m.verifyStats.mismatches, 0); + assert.equal(m.errorCount, 0); + }); +} + +test('duplicate reports during all verification start one simulation and settle once', (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + let settlements = 0; + const settle = m.settle.bind(m); + m.settle = (...args) => { settlements++; return settle(...args); }; + report(h, f, forged); + assert.equal(f.done, false, 'the duplicate arrives while verification is pending'); + report(h, f, forged); + assert.ok(h.run(() => seen.steps > 0)); + report(h, f, forged); + h.runToPhase(PHASE.SETTLE); + report(h, f, forged); + assert.equal(seen.battles, 1); + assert.equal(m.verifyStats.checked, 1); + assert.equal(m.verifyStats.mismatches, 1); + assert.equal(settlements, 1); +}); + +test('sample verification keeps the accepted result and finishes after the phase changes', (t) => { + // Seed 345 selects the first field for sampling; zero phase delays let settlement overtake verification. + const h = combat(t, 'sample'); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + report(h, f, forged); + assert.equal(f.done, true, 'sampling accepts the report immediately'); + assert.equal(seen.steps, 0); + h.runToPhase(PHASE.SETTLE); + assert.equal(m.verifyStats.checked, 0, 'the sampled battle is still running after settlement'); + assert.ok(h.run(() => m.verifyStats.checked === 1)); + assert.equal(m.verifyStats.mismatches, 1); + assert.equal(resultDigest(f.result).hash, resultDigest(forged).hash); + assert.deepEqual(m.lastResults.get('p_0').leaked, [], 'sampling never replaces the accepted result'); + assert.ok(h.logs.warn.some((line) => line.includes('client result differs'))); + assert.equal(m.errorCount, 0); +}); + +for (const event of ['disconnect and reconnect', 'expired deadline']) { + test(`pending all verification does not restart simulation after ${event}`, (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + // Leave an already-due deadline callback queued when the result arrives. + if (event === 'expired deadline') h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1; + report(h, f, forged); + assert.equal(f.done, false); + if (event === 'disconnect and reconnect') { + m.onDisconnect('p_0'); + m.onReconnect('p_0'); + assert.equal(h.lastTo('p_0', 'b.start').authoritative, false, 'reconnect does not request a second result'); + } + h.runToPhase(PHASE.SETTLE); + assert.equal(seen.battles, 1); + assert.equal(m.verifyStats.takeovers, 0, 'a received result no longer needs a client authority'); + assert.equal(m.verifyStats.checked, 1); + assert.equal(m.verifyStats.mismatches, 1); + }); +} + +test('resuming a solo pause does not rearm a result deadline while verification is pending', (t) => { + const h = combat(t, 'all', { mode: 'solo' }); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true }); + report(h, f, forged); + assert.equal(f.done, false); + assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true }); + h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1; + h.runToPhase(PHASE.SETTLE); + assert.equal(seen.battles, 1); + assert.equal(m.verifyStats.takeovers, 0); + assert.equal(m.verifyStats.checked, 1); +}); + +test('a verification comparison error falls back to a server result and completes the field', (t) => { + const h = combat(t); + const m = h.m; + const f = m.fields[0]; + const { server, forged } = results(h, f); + const seen = observeSimulation(m); + const create = m._specBattle.bind(m); + m._specBattle = (...args) => { + const battle = create(...args); + if (seen.battles === 1) { + const result = battle.result.bind(battle); + battle.result = () => { + const out = result(); + Object.defineProperty(out.perPlayer.p_0, 'unitStats', { get() { throw new Error('verification result cannot be normalized'); } }); + return out; + }; + } + return battle; + }; + report(h, f, forged); + h.runToPhase(PHASE.SETTLE); + assert.equal(seen.battles, 2, 'the failed comparison is followed by one independent server run'); + assert.equal(f.resultSource, 'server'); + assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'fallback never accepts the forged result'); + assert.equal(m.errorCount, 1); + assert.ok(h.logs.error.some((line) => line.includes('verification result cannot be normalized'))); + assert.deepEqual(h.sched.errors, []); +}); + +for (const verify of ['all', 'sample']) { + test(`dispose stops pending ${verify} verification without later completion`, (t) => { + const h = combat(t, verify, { timerScale: 1 }); + const m = h.m; + const f = m.fields[0]; + const { forged } = results(h, f); + const seen = observeSimulation(m); + report(h, f, forged); + assert.ok(h.run(() => seen.steps > 0)); + assert.equal(m.verifyStats.checked, 0, 'dispose interrupts an unfinished verification'); + m.dispose(); + const steps = seen.steps; + h.sched.runAll({ maxSteps: 10000 }); + assert.equal(seen.steps, steps, 'no further battle steps execute'); + assert.equal(m.verifyStats.checked, 0); + assert.equal(m.verifyStats.mismatches, 0); + assert.equal(f.done, verify === 'sample'); + assert.equal(m.errorCount, 0); + }); +} 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/test/ui/build-runtime.browser.test.js b/test/ui/build-runtime.browser.test.js new file mode 100644 index 00000000..ec038991 --- /dev/null +++ b/test/ui/build-runtime.browser.test.js @@ -0,0 +1,197 @@ +// Issue #343: a page keeps the browser simulator in its module map across a server restart. +// Exercise the real server, WebSocket client, simulator loader, route selector and build guard in Chrome. +// Only a throwaway install changes; no game assets or tracked runtime files are modified. +// Run: SP_E2E=1 node --test test/ui/build-runtime.browser.test.js (CHROME_PATH overrides system Chrome). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const enabled = process.env.SP_E2E === '1' && fs.existsSync(CHROME); + +const PAGE = `Build runtime regression +

Build runtime regression

Loading simulator

+ +`; + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-browser-')); + for (const rel of ['server', 'shared', 'data', 'public/js', 'public/css', 'public/index.html', + 'public/vendor/hooks.module.js', 'public/vendor/preact.module.js', 'package.json']) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.cpSync(path.join(ROOT, rel), path.join(root, rel), { recursive: true }); + } + fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(root, 'node_modules'), 'junction'); + fs.mkdirSync(path.join(root, 'public/dev'), { recursive: true }); + fs.writeFileSync(path.join(root, 'public/dev/build-runtime.html'), PAGE); + fs.writeFileSync(path.join(root, 'start.mjs'), ` +import { startServer } from './server/index.js'; +const srv = await startServer({ port: Number(process.env.PORT), host: '127.0.0.1', quiet: true }); +process.send({ port: srv.port }); +process.on('SIGTERM', async () => { await srv.close(); process.exit(0); }); +`); + return root; +} + +async function start(root, port = 0) { + const child = fork(path.join(root, 'start.mjs'), [], { + cwd: root, env: { ...process.env, PORT: String(port) }, silent: true, + }); + let stderr = ''; + child.stderr.on('data', (buf) => { stderr += buf; }); + try { + const [ready] = await once(child, 'message', { signal: AbortSignal.timeout(15000) }); + return { port: ready.port, stop: async () => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.kill('SIGTERM'); + await exited; + } }; + } catch (error) { + child.kill('SIGKILL'); + throw new Error(`Fixture server failed to start: ${stderr}`, { cause: error }); + } +} + +test('an old simulator page reconnects safely, then reloads before the next match after a runtime-only deploy', { + skip: enabled ? false : 'set SP_E2E=1 (needs Chrome)', timeout: 120000, +}, async () => { + const root = fixture(); + let server, browser; + try { + server = await start(root); + const port = server.port; + const puppeteer = (await import('puppeteer-core')).default; + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, protocolTimeout: 30000, + args: ['--no-first-run', '--no-sandbox'] }); + const page = await browser.newPage(); + const problems = []; + let navigations = 0, simRequests = 0; + page.on('pageerror', (error) => problems.push(error.message)); + page.on('console', (message) => { + // The two deliberate server outages produce browser transport errors, not application errors. + if (message.type() === 'error' && !/net::ERR_CONNECTION_REFUSED|WebSocket connection to .* failed/.test(message.text())) { + problems.push(message.text()); + } + }); + page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) navigations++; }); + page.on('request', (request) => { if (new URL(request.url()).pathname === '/sim/rng.js') simRequests++; }); + await page.goto(`http://127.0.0.1:${port}/dev/build-runtime.html`); + const ready = async () => { + try { await page.waitForFunction(() => window.fixture?.guard.known() && window.fixture.net.status === 'online'); } + catch (error) { throw new Error(`Fixture page failed: ${JSON.stringify(problems)}`, { cause: error }); } + }; + const state = () => page.evaluate(() => window.fixture.state()); + const check = () => page.evaluate(() => window.fixture.guard.check()); + await ready(); + assert.equal(await page.title(), 'Build runtime regression'); + await page.click('#battle'); + const old = await state(); + assert.equal(old.route, 'game'); + assert.equal(simRequests, 1, 'the first match imported the actual simulator'); + + await server.stop(); + await page.waitForFunction(() => window.fixture.net.status === 'reconnecting'); + assert.equal((await check()).status, 'unknown', 'a stopped server never causes a mid-match reload'); + await page.click('#lobby'); + assert.equal((await check()).status, 'unknown', 'a stopped server never causes an outside-match reload either'); + assert.equal(navigations, 1); + await page.click('#battle'); + + // Change an actual simulator dependency, retaining its size to exercise the mtime portion of the build tag. + const rngPath = path.join(root, 'server/sim/rng.js'); + const source = fs.readFileSync(rngPath, 'utf8'); + assert.match(source, /0x6d2b79f5/); + const stat = fs.statSync(rngPath); + fs.writeFileSync(rngPath, source.replace('0x6d2b79f5', '0x6d2b79f4')); + fs.utimesSync(rngPath, stat.atime, new Date(stat.mtimeMs + 5000)); + const { createRng } = await import(pathToFileURL(rngPath).href); + const freshFingerprint = createRng(91)(); + assert.notEqual(freshFingerprint, old.fingerprint); + + server = await start(root, port); + await ready(); + assert.equal((await state()).boots, 1, 'the original page actually reconnected'); + assert.equal((await state()).fingerprint, old.fingerprint, 'the already loaded simulator still has old code'); + assert.equal(simRequests, 1, 'reconnect does not refetch an imported ES module'); + assert.equal((await check()).status, 'new', 'the restarted server advertises the simulator-only deploy'); + assert.equal((await state()).stale, false, 'one new tag is not confirmation'); + assert.equal((await check()).status, 'stale'); + assert.equal((await state()).stale, true, 'confirmed update is exposed to the in-match banner'); + assert.equal(navigations, 1, 'a confirmed update never interrupts combat'); + await page.click('#result'); + assert.equal((await check()).status, 'stale'); + assert.equal(navigations, 1, 'settlement also remains on screen'); + + // Even an already-confirmed update must wait while the server is unavailable. + await server.stop(); + await page.click('#lobby'); + assert.equal((await check()).status, 'unknown'); + assert.equal(navigations, 1); + server = await start(root, port); + await ready(); + assert.equal((await check()).status, 'new', 'the failed check resets consecutive confirmations'); + assert.equal(navigations, 1, 'one successful check after downtime is insufficient'); + await Promise.all([ + page.waitForNavigation(), + page.evaluate(() => { window.fixture.guard.check(); }), + ]); + await ready(); + assert.equal(navigations, 2, 'the confirmed healthy build reloads exactly once in the lobby'); + assert.equal((await state()).boots, 2); + assert.notEqual((await state()).known, old.known); + await page.click('#battle'); + assert.equal((await state()).fingerprint, freshFingerprint, 'the next battle uses the updated simulator'); + assert.equal(simRequests, 2, 'the real reload creates a fresh module map'); + assert.equal((await check()).status, 'current', 'the refreshed page does not enter a reload loop'); + assert.equal(navigations, 2); + assert.deepEqual(problems, []); + if (process.env.SP_BUILD_SCREENSHOT) await page.screenshot({ path: process.env.SP_BUILD_SCREENSHOT }); + } finally { + await browser?.close(); + await server?.stop(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/ui/i18n.e2e.test.js b/test/ui/i18n.e2e.test.js index dfd76425..eb955237 100644 --- a/test/ui/i18n.e2e.test.js +++ b/test/ui/i18n.e2e.test.js @@ -179,4 +179,125 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E= assert.deepEqual(problems, []); await page.close(); }); + + // Hold only UI pack fetches; the real loader, cache, Preact menu and document updates still run. + const holdPacks = (page, codes) => page.evaluate((held) => { + const fetch = globalThis.fetch.bind(globalThis); + globalThis.languageRequests = {}; + const gates = Object.fromEntries(held.map((code) => { + let release; + const ready = new Promise((resolve) => { release = resolve; }); + return [code, { ready, release }]; + })); + globalThis.releaseLanguage = async (code, ok) => { + const { loadLangChain } = await import('/js/ui/lang.js'); + const settled = loadLangChain(code); + gates[code].release(ok); + await settled; + await new Promise(requestAnimationFrame); + }; + globalThis.fetch = (url, ...args) => { + const code = held.find((c) => url === `/i18n/${c}.json`); + if (!code) return fetch(url, ...args); + globalThis.languageRequests[code] = (globalThis.languageRequests[code] || 0) + 1; + return gates[code].ready.then((ok) => ok ? fetch(url, ...args) : new Response('', { status: 503 })); + }; + }, codes); + const waitForPack = (page, code) => page.waitForFunction((c) => globalThis.languageRequests[c] > 0, {}, code); + const releasePack = (page, code, ok = true) => page.evaluate((c, success) => globalThis.releaseLanguage(c, success), code, ok); + const languageState = (page) => page.evaluate(async () => ({ + current: (await import('/shared/i18n.js')).getLang(), + document: document.documentElement.lang, + dataLang: document.documentElement.dataset.lang, + script: document.documentElement.dataset.script, + title: document.title, + start: document.querySelector('.title-login .btn--primary').textContent.trim(), + selected: document.querySelector('[data-testid="lang-toggle"] select').value, + saved: JSON.parse(localStorage.getItem('sp.pref.lang')), + })); + const expectedLanguage = { + zh: { current: 'zh', document: 'zh-CN', dataLang: 'zh', script: 'cjk', title: '卫戍协议:盟约 · STRONGHOLD PROTOCOL', start: '开始', selected: 'zh', saved: 'zh' }, + en: { current: 'en', document: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance · Web Simulation', start: 'Start', selected: 'en', saved: 'en' }, + 'zh-TW': { current: 'zh-TW', document: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約 · STRONGHOLD PROTOCOL', start: '開始', selected: 'zh-TW', saved: 'zh-TW' }, + }; + + test('a late English pack cannot overwrite a newer Traditional Chinese selection; its cache remains reusable', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await page.waitForFunction(() => document.documentElement.lang === 'zh-TW'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']); + await pick(page, 'en'); + await page.waitForFunction(() => document.documentElement.lang === 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1, 'the stale pack was cached'); + }); + + test('selecting the displayed default language cancels a pending pack', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.value), 'en', 'the native menu can still change back to Chinese'); + assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.disabled), false); + await pick(page, 'zh'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.zh); + }); + + test('repeated selections reuse a pending pack and still supersede an intervening selection', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await pick(page, 'en'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + await releasePack(page, 'zh-TW'); + assert.deepEqual(await languageState(page), expectedLanguage.en); + assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1); + }); + + test('a failed latest selection keeps the last successful UI and menu; an older pack stays stale', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await releasePack(page, 'zh-TW', false); + const unchanged = { ...expectedLanguage.zh, saved: 'zh-TW' }; + assert.deepEqual(await languageState(page), unchanged, 'failed preference stays saved, as before'); + await releasePack(page, 'en'); + assert.deepEqual(await languageState(page), unchanged); + }); + + test('a stale failure leaves a newer pending dropdown selection intact', async (t) => { + const page = await browser.newPage(); + t.after(() => page.close()); + await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' }); + await holdPacks(page, ['en', 'zh-TW']); + await pick(page, 'en'); + await waitForPack(page, 'en'); + await pick(page, 'zh-TW'); + await waitForPack(page, 'zh-TW'); + await releasePack(page, 'en', false); + assert.deepEqual(await languageState(page), { ...expectedLanguage.zh, saved: 'zh-TW', selected: 'zh-TW' }); + await releasePack(page, 'zh-TW'); + assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']); + }); }); diff --git a/test/ui/lang.test.js b/test/ui/lang.test.js new file mode 100644 index 00000000..ebbfd7b8 --- /dev/null +++ b/test/ui/lang.test.js @@ -0,0 +1,222 @@ +// Controlled UI-pack completions exercise real language switching without network timing or a browser. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getLang, onLangChange, registerLangs, setLang, setMessages, setI18nWarn, t } from '../../shared/i18n.js'; + +const TITLE = '卫戍协议:盟约 · STRONGHOLD PROTOCOL'; +const PACKS = { + en: { 开始: 'Start', 卫戍协议: 'Stronghold Protocol', [TITLE]: 'Stronghold Protocol: Alliance' }, + 'zh-TW': { 开始: '開始', 卫戍协议: '衛戍協議', [TITLE]: '衛戍協議:盟約' }, +}; +const STATES = { + zh: { lang: 'zh', text: '开始', htmlLang: 'zh-CN', dataLang: 'zh', script: 'cjk', title: TITLE }, + en: { lang: 'en', text: 'Start', htmlLang: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance' }, + 'zh-TW': { lang: 'zh-TW', text: '開始', htmlLang: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約' }, +}; +const INDEX = { version: 1, packs: Object.keys(PACKS).map((code) => ({ + id: code, type: 'lang', lang: code, name: code, files: { ui: `/i18n/${code}.json` }, +})) }; +const turn = () => new Promise((resolve) => setImmediate(resolve)); +let imports = 0; + +async function setup(ctx, preference = null, address = 'https://example.test/') { + const previous = new Map(['document', 'localStorage', 'fetch', 'location', 'history'].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const stored = new Map(preference ? [['sp.pref.lang', JSON.stringify(preference)]] : []); + const doc = { documentElement: { lang: 'zh-CN', dataset: { lang: 'zh', script: 'cjk' } }, title: TITLE }; + const pending = new Map(); + const calls = []; + globalThis.document = doc; + globalThis.localStorage = { getItem: (key) => stored.get(key) ?? null, setItem: (key, value) => stored.set(key, value) }; + globalThis.location = new URL(address); + globalThis.history = { + state: null, + replaceState(state, _title, relative) { + this.state = state; + globalThis.location.href = new URL(relative, globalThis.location.href).href; + }, + }; + globalThis.fetch = (url) => { + calls.push(url); + const request = Promise.withResolvers(); + if (!pending.has(url)) pending.set(url, []); + pending.get(url).push(request); + return request.promise; + }; + const warnings = []; + ctx.mock.method(console, 'warn', (message) => warnings.push(message)); + registerLangs(Object.keys(PACKS).map((code) => ({ code, fallback: [], base: null, data: false, ui: `/i18n/${code}.json` }))); + setLang('zh'); + for (const code of Object.keys(PACKS)) setMessages(code, {}); + const url = new URL('../../public/js/ui/lang.js', import.meta.url); + url.searchParams.set('test', String(++imports)); + const lang = await import(url.href); + ctx.after(() => { + setLang('zh'); + setI18nWarn(null); + for (const code of Object.keys(PACKS)) setMessages(code, {}); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + const finish = (path, body, status = 200) => { + const request = pending.get(path)?.shift(); + assert.ok(request, `a request is pending for ${path}`); + request.resolve({ ok: status === 200, status, json: async () => body }); + }; + const state = (code, saved = code) => assert.deepEqual({ + lang: getLang(), text: t('开始'), htmlLang: doc.documentElement.lang, dataLang: doc.documentElement.dataset.lang, + script: doc.documentElement.dataset.script, title: doc.title, saved: stored.get('sp.pref.lang') ?? null, + }, { ...STATES[code], saved: saved == null ? null : JSON.stringify(saved) }); + return { + ...lang, calls, warnings, state, + finish: (code, status = 200) => finish(`/i18n/${code}.json`, PACKS[code], status), + finishIndex: () => finish('/packs/index.json', INDEX), + }; +} + +test('a delayed language cannot overwrite the later successful selection', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + assert.equal(await latest, 'zh-TW'); + h.state('zh-TW'); + h.finish('en'); + assert.equal(await first, 'zh-TW'); + h.state('zh-TW'); +}); + +test('selecting the default language invalidates a pending pack', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + assert.equal(await h.switchLang('zh'), 'zh'); + h.state('zh'); + h.finish('en'); + assert.equal(await first, 'zh'); + h.state('zh'); +}); + +test('reselecting the current language cancels a different pending selection and uses its cached pack', async (ctx) => { + const h = await setup(ctx); + const initial = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await initial; + const pending = h.switchLang('en'); + await h.switchLang('zh-TW'); + h.finish('en'); + await pending; + h.state('zh-TW'); + assert.deepEqual(h.calls, ['/i18n/zh-TW.json', '/i18n/en.json']); +}); + +test('A, B, A shares the in-flight A pack and applies only the latest selection', async (ctx) => { + const h = await setup(ctx); + const changes = []; + ctx.after(onLangChange((code) => changes.push(code))); + const first = h.switchLang('en'); + const middle = h.switchLang('zh-TW'); + const latest = h.switchLang('en'); + h.finish('zh-TW'); + await middle; + h.state('zh', 'en'); + h.finish('en'); + await Promise.all([first, latest]); + h.state('en'); + assert.deepEqual(changes, ['en']); + await h.switchLang('en'); + assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json']); + assert.deepEqual(changes, ['en'], 'repeated successful selections do not notify a language change'); +}); + +test('a failed latest request retains the successful interface, rejects stale success, and can retry', async (ctx) => { + const h = await setup(ctx); + const first = h.switchLang('en'); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW', 503); + assert.equal(await latest, 'zh'); + h.state('zh', 'zh-TW'); + assert.equal(h.warnings.length, 1); + assert.match(h.warnings[0], /HTTP 503.*interface stays as it is/); + h.finish('en'); + await first; + h.state('zh', 'zh-TW'); + const retry = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await retry; + h.state('zh-TW'); + await h.switchLang('en'); + h.state('en'); + assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json', '/i18n/zh-TW.json'], 'failed loads retry; stale successful packs remain cached'); +}); + +test('startup applies the stored language after its pack is ready', async (ctx) => { + const h = await setup(ctx, 'en'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + h.finish('en'); + assert.equal(await boot, 'en'); + h.state('en'); +}); + +test('startup cannot overwrite a newer switch before the boot timeout', async (ctx) => { + const h = await setup(ctx, 'en'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await latest; + h.finish('en'); + await boot; + h.state('zh-TW'); +}); + +test('a delayed startup index cannot restore the URL choice after a newer selection', async (ctx) => { + const h = await setup(ctx, null, 'https://example.test/play?room=alliance&lang=en#settings'); + ctx.mock.method(globalThis, 'setTimeout', () => 0); + const boot = h.initLang(); + const latest = h.switchLang('zh-TW'); + h.finish('zh-TW'); + await latest; + h.finishIndex(); + await turn(); + h.state('zh-TW'); + assert.equal(await boot, 'zh-TW'); + assert.deepEqual(h.calls, ['/packs/index.json', '/i18n/zh-TW.json']); + assert.equal(globalThis.location.href, 'https://example.test/play?room=alliance#settings'); + assert.deepEqual(h.initialLang(), { lang: 'zh-TW', fromUrl: false }, 'reload uses the newer saved choice'); +}); + +test('timed-out startup applies its pack later when the selection has not changed', async (ctx) => { + const h = await setup(ctx, 'en'); + let timeout; + ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; }); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + timeout(); + assert.equal(await boot, 'zh'); + h.state('zh', 'en'); + h.finish('en'); + await turn(); + h.state('en'); +}); + +test('timed-out startup cannot overwrite a newer default selection', async (ctx) => { + const h = await setup(ctx, 'en'); + let timeout; + ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; }); + const boot = h.initLang(); + h.finishIndex(); + await turn(); + timeout(); + await boot; + await h.switchLang('zh'); + h.finish('en'); + await turn(); + h.state('zh'); +}); diff --git a/tools/assets/cache.mjs b/tools/assets/cache.mjs index db8fc325..3ad6f5f0 100644 --- a/tools/assets/cache.mjs +++ b/tools/assets/cache.mjs @@ -8,6 +8,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { RAW } from './sources.mjs'; import { MirrorPolicy } from './network.mjs'; +import { guardDefaultFetch } from './env-proxy.mjs'; /** * Read a cached JSON file, downloading it first when missing or unparsable. @@ -26,6 +27,8 @@ import { MirrorPolicy } from './network.mjs'; * @returns {Promise} parsed JSON */ export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log, source = 'direct', proxyPrefix, fetchImpl = globalThis.fetch, mirrorPolicy, timeoutMs = 180000, backoffMs = 500 }) { + // Same rule as the file downloader: a configured proxy must not be skipped. + const fetchFn = guardDefaultFetch(fetchImpl); if (!refresh || offline) { try { return JSON.parse(await readFile(cacheFile, 'utf8')); } catch (e) { if (offline) throw new Error(`--offline: cached index ${cacheFile} is missing or corrupt (${e.message}); run once online`); @@ -40,7 +43,7 @@ export async function cachedJson({ cacheFile, url, refresh = false, offline = fa let text, json; try { log(`[cache] downloading ${src}`); - const res = await network.request(src, fetchImpl, {}, timeoutMs); + const res = await network.request(src, fetchFn, {}, timeoutMs); if (!res.ok) { await res.body?.cancel(); if (res.status === 404 || res.status === 410) { diff --git a/tools/assets/downloader.mjs b/tools/assets/downloader.mjs index 510821b4..375511e1 100644 --- a/tools/assets/downloader.mjs +++ b/tools/assets/downloader.mjs @@ -8,6 +8,9 @@ // a 404/410 moves on immediately. Each candidate uses its opt-in prefix proxy // first (when enabled), then the original URL and jsDelivr fallback. The proxy gets one short attempt // per file and shares a run-wide circuit breaker with the index/font fetchers. +// - HTTP(S)_PROXY: the default fetch uses it only when this process was started +// with NODE_USE_ENV_PROXY=1 (Node >=22.21 or >=24). Otherwise it fails closed +// (tools/assets/env-proxy.mjs). Pass fetchImpl to bypass that, as tests do. // - Idempotent: an existing file is kept when its size matches the ledger entry // of a previous download or the expected byte count from research, or (when // neither is known) when it passes format validation. The ledger lives in @@ -18,6 +21,7 @@ import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promis import { dirname, join } from 'node:path'; import { MirrorPolicy } from './network.mjs'; import { validate } from './formats.mjs'; +import { guardDefaultFetch } from './env-proxy.mjs'; /** * @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job @@ -60,7 +64,7 @@ export class Downloader { /** @type {Set} the files (paths under root) this run wrote */ this.written = new Set(); this.log = log; - this.fetch = fetchImpl; + this.fetch = guardDefaultFetch(fetchImpl); this.network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log }); this.backoffMs = Math.max(0, Number(backoffMs) || 0); this.ledger = { files: {} }; diff --git a/tools/assets/env-proxy.mjs b/tools/assets/env-proxy.mjs new file mode 100644 index 00000000..c46b162b --- /dev/null +++ b/tools/assets/env-proxy.mjs @@ -0,0 +1,145 @@ +// HTTP(S)_PROXY for the asset downloader, without a new dependency. +// +// Node's built-in fetch reads HTTP_PROXY / HTTPS_PROXY / NO_PROXY only at process +// startup, and only when that startup had NODE_USE_ENV_PROXY=1 or --use-env-proxy. +// fetch() honours this on Node >=22.21.0 and >=24.0.0. The CLI flag on the 24 +// line is >=24.5.0, so the restart sets the variable, which 24.0.0 already honours. +// Assigning the variable later does nothing. Node 22.0–22.20 and 23 have no such +// switch; engines stay ">=22", and a proxy there is an error rather than a direct +// download. +// +// Nothing else reaches the built-in fetch: `node:undici` is not a public builtin, +// and setGlobalDispatcher on the npm `undici` package does not affect it. The +// fetch-assets script therefore restarts itself once. Any other caller of the +// default fetch is rejected here while a proxy is configured. + +import { spawn } from 'node:child_process'; + +/** Names Node's env proxy actually reads (both cases). NO_PROXY alone is not a proxy. */ +export const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']; + +/** Set on the one restart, so a failed hand-off cannot spawn forever. */ +export const REEXEC_MARKER = 'SP_ASSETS_ENV_PROXY_REEXEC'; + +/** + * @param {NodeJS.ProcessEnv | Record} env + * @returns {boolean} + */ +export function proxyConfigured(env) { + return PROXY_ENV_KEYS.some((key) => typeof env[key] === 'string' && env[key].trim() !== ''); +} + +/** + * fetch() routes through HTTP(S)_PROXY when NODE_USE_ENV_PROXY is on at startup. + * @param {string} version process.versions.node + * @returns {boolean} + */ +export function nodeHonoursEnvProxy(version) { + const match = /^v?(\d+)\.(\d+)/.exec(String(version ?? '')); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2]); + if (major === 22) return minor >= 21; + return major >= 24; +} + +/** + * True only for a process that was started with the switch. A value written onto + * `process.env` afterwards does not count: Node has already decided. + * @param {NodeJS.ProcessEnv | Record} env + * @param {string[]} [execArgv] + * @returns {boolean} + */ +export function startedWithEnvProxy(env, execArgv = []) { + if (env.NODE_USE_ENV_PROXY === '1') return true; + if (execArgv.some((arg) => arg === '--use-env-proxy' || arg === '--use_env_proxy')) return true; + const options = env.NODE_OPTIONS; + return typeof options === 'string' && /(?:^|\s)--use[-_]env[-_]proxy(?=\s|$)/.test(options); +} + +// Captured at load. Reading process.env.NODE_USE_ENV_PROXY at fetch time would +// treat a late assignment as if Node had seen it. +const STARTED_WITH_ENV_PROXY = startedWithEnvProxy(process.env, process.execArgv); + +/** + * @param {NodeJS.ProcessEnv | Record} [env] + * @param {string} [version] + * @param {boolean} [startedWithFlag] defaults to this process's startup snapshot, not to `env` + * @returns {{ action: 'off' | 'already' | 'reexec' | 'unsupported', reason: string }} + */ +export function envProxyPlan(env = process.env, version = process.versions.node, startedWithFlag = STARTED_WITH_ENV_PROXY) { + if (!proxyConfigured(env)) return { action: 'off', reason: '' }; + if (!nodeHonoursEnvProxy(version)) { + return { + action: 'unsupported', + reason: `HTTP(S)_PROXY is set, but Node ${version} does not route fetch() through it. ` + + 'Need Node >=22.21.0 or >=24.0.0, started with NODE_USE_ENV_PROXY=1. Refusing to fetch directly.', + }; + } + if (startedWithFlag) return { action: 'already', reason: '' }; + return { + action: 'reexec', + reason: 'HTTP(S)_PROXY is set, but this process was not started with NODE_USE_ENV_PROXY=1. ' + + 'Node reads that proxy only at startup (Node >=22.21.0 or >=24.0.0). Refusing to fetch directly.', + }; +} + +/** + * The built-in fetch, or the caller's own implementation unchanged. + * When a proxy is configured and this process cannot use it, the default fetch + * rejects instead of connecting to the origin. + * @param {typeof fetch} [fetchImpl] + * @returns {typeof fetch} + */ +export function guardDefaultFetch(fetchImpl = globalThis.fetch) { + if (fetchImpl !== globalThis.fetch) return fetchImpl; + return (input, init) => { + const plan = envProxyPlan(); + if (plan.action === 'reexec' || plan.action === 'unsupported') return Promise.reject(new Error(plan.reason)); + return fetchImpl(input, init); + }; +} + +/** + * Restart the current script once so Node enables env proxy before any fetch. + * @param {object} [io] + * @param {typeof import('node:child_process').spawn} [io.spawnImpl] + * @param {string} [io.execPath] + * @param {string[]} [io.execArgv] + * @param {string[]} [io.argv] + * @param {NodeJS.ProcessEnv} [io.env] + * @param {(line: string) => void} [io.error] + * @returns {boolean} true when the caller must not continue (restarted, or refused) + */ +export function restartForEnvProxy({ + spawnImpl = spawn, + execPath = process.execPath, + execArgv = process.execArgv, + argv = process.argv, + env = process.env, + error = (line) => console.error(line), +} = {}) { + const plan = envProxyPlan(env); + if (plan.action === 'off' || plan.action === 'already') return false; + if (plan.action === 'unsupported') { + error(`[assets] ${plan.reason}`); + process.exitCode = 1; + return true; + } + if (env[REEXEC_MARKER] === '1') { + error('[assets] refused to restart again for HTTP(S)_PROXY'); + process.exitCode = 1; + return true; + } + error('[assets] HTTP(S)_PROXY is set; restarting once with NODE_USE_ENV_PROXY=1 so fetch() uses it.'); + const child = spawnImpl(execPath, [...execArgv, ...argv.slice(1)], { + env: { ...env, NODE_USE_ENV_PROXY: '1', [REEXEC_MARKER]: '1' }, + stdio: 'inherit', + }); + child.on('error', (e) => { + error(`[assets] could not restart with NODE_USE_ENV_PROXY: ${e.message}`); + process.exitCode = 1; + }); + child.on('close', (code) => { process.exitCode = code ?? 1; }); + return true; +} diff --git a/tools/build-data.mjs b/tools/build-data.mjs index b7be4107..4d4a14ff 100644 --- a/tools/build-data.mjs +++ b/tools/build-data.mjs @@ -8,7 +8,7 @@ // // Usage: node tools/build-data.mjs [--refresh | --offline] [--out ] [--cache ] // [--report ] [--quiet] [--no-research] [--force] -// --refresh re-download every official file even if cached +// --refresh re-download every official file; fail on download errors even if cached // --offline never download; fail when a file is missing from the cache // --out output directory (default: /data) // --cache official-data cache directory (default: /.cache/gamedata) @@ -127,7 +127,8 @@ async function ensureGamedata(rel) { if (attempt < 4) await new Promise((r) => setTimeout(r, 500 * attempt)); } } - if (existsSync(abs)) { warn(`download failed for ${rel}, using stale cache: ${lastErr.message}`); return abs; } + // A refresh cannot mix new downloads with an unverified cache from an earlier run. + if (!OPTS.refresh && existsSync(abs)) { warn(`download failed for ${rel}, using stale cache: ${lastErr.message}`); return abs; } throw new Error(`cannot obtain ${rel}: ${lastErr && lastErr.message}`); } 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/ci.mjs b/tools/ci.mjs new file mode 100644 index 00000000..a5dc8068 --- /dev/null +++ b/tools/ci.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +// tools/ci.mjs — run the checks of .github/workflows/ci.yml locally, one after another (CONTRIBUTING.md §2). +// +// node tools/ci.mjs every step; stops at the first failing one (exit 1) +// node tools/ci.mjs --keep-going run every step even after a failure (exit 1 when any failed) +// node tools/ci.mjs --only lint,test only the named steps (in the workflow's order) +// node tools/ci.mjs --list print the steps and the ci.yml step each one mirrors +// npm run ci the same as the first line +// +// Steps, in the order of ci.yml (job `test`, then job `tooling`): +// setup node tools/setup.mjs --check --no-local +// test node --test +// smoke the server on a free port on 127.0.0.1: GET /healthz, / (must contain /package.json `bin`), +// never npm or a shell, so the run does not depend on PATH or on the shell npm uses for scripts. +// test/ci.test.js keeps this list and ci.yml in step: a ci.yml step this file does not know fails it. + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** The env of ci.yml's job `test` (test/ci.test.js compares it with the workflow). */ +export const CI_TEST_ENV = { SP_E2E: '0', SP_REAL_E2E: '0', RENDER_E2E: '0', NO_COLOR: '1' }; + +/** The paths the smoke step requests (the curl URLs of ci.yml's smoke step; test/ci.test.js compares them). */ +export const SMOKE_PATHS = ['/healthz', '/', '/vendor/pixi.min.js']; + +/** ci.yml steps that only make sense on a GitHub runner, by `uses:` action or by step name. */ +export const SKIPPED_CI_STEPS = [ + { uses: 'actions/checkout', why: 'GitHub runner: the checkout is this folder' }, + { uses: 'actions/setup-node', why: 'GitHub runner: the Node.js of this machine is used' }, + { name: 'Install (npm ci → postinstall copies public/vendor)', why: 'run `npm ci` yourself; it is not a check' }, +]; + +/** + * The local steps (runStep runs them). `ci` names the ci.yml job and step it mirrors; `npm` the package.json script + * ci.yml runs there, and `describe` is that script's command (test/ci.test.js checks both). + * @typedef {{ id: string, ci: { job: string, name: string }, npm?: string, describe: string }} Step + * @type {Step[]} + */ +export const STEPS = [ + { id: 'setup', ci: { job: 'test', name: 'Setup check (no downloads)' }, describe: 'node tools/setup.mjs --check --no-local' }, + { id: 'test', ci: { job: 'test', name: 'Unit & integration tests' }, describe: 'node --test' }, + { id: 'smoke', ci: { job: 'test', name: 'Server smoke test (boot, /healthz, index.html, doctor)' }, + describe: `server on a free port: GET ${SMOKE_PATHS.join(', ')}; node tools/doctor.mjs` }, + { id: 'lint', ci: { job: 'tooling', name: 'Lint (warnings allowed)' }, npm: 'lint', describe: 'eslint .' }, + { id: 'imports', ci: { job: 'tooling', name: 'Import boundaries' }, npm: 'check:imports', describe: 'node tools/check-imports.mjs' }, + { id: 'typecheck', ci: { job: 'tooling', name: 'Typecheck' }, npm: 'typecheck', describe: 'tsc --noEmit --checkJs -p jsconfig.json' }, +]; + +// --------------------------------------------------------------------------------------------------------------------- +// ci.yml, read just far enough: jobs, each job's env (flat KEY: value) and steps (name, uses, run, shell; the names +// of any other step keys in `other`, so test/ci.test.js can refuse per-step config this script does not mirror). + +const unquote = (s) => { + const v = s.trim(); + return /^(['"]).*\1$/.test(v) ? v.slice(1, -1) : v; +}; + +/** + * A line-based reader for the shape of .github/workflows/ci.yml (block mappings, `- ` step lists, `run: |` blocks). + * Not a YAML parser: flow mappings, anchors and multi-document files are not handled. + * @param {string} text + * @returns {{ jobs: Record, steps: { name?: string, uses?: string, run?: string, shell?: string, other?: string[] }[] }> }} + */ +export function parseWorkflow(text) { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + const indentOf = (l) => l.length - l.trimStart().length; + const blank = (l) => l.trim() === '' || l.trim().startsWith('#'); + /** @type {Record, steps: any[] }>} */ + const jobs = {}; + let i = lines.findIndex((l) => /^jobs:\s*$/.test(l)); + if (i < 0) return { jobs }; + let jobIndent = -1; + let job = null; + let section = null; // 'env' | 'steps' | null + let sectionIndent = -1; + let step = null; + let stepIndent = -1; + for (i += 1; i < lines.length; i++) { + const line = lines[i]; + if (blank(line)) continue; + const ind = indentOf(line); + if (ind === 0) break; // the next top-level key + const t = line.trim(); + if (jobIndent < 0) jobIndent = ind; + if (ind === jobIndent) { + const m = /^([\w-]+):\s*$/.exec(t); + job = m ? (jobs[m[1]] = { env: {}, steps: [] }) : null; + section = null; step = null; + continue; + } + if (!job) continue; + if (section && ind <= sectionIndent) { section = null; step = null; } + if (!section) { + const m = /^(env|steps):\s*$/.exec(t); + if (m) { section = m[1]; sectionIndent = ind; } + continue; + } + if (section === 'env') { + const m = /^([\w-]+):\s*(.*)$/.exec(t); + if (m) job.env[m[1]] = unquote(m[2]); + continue; + } + // steps + let body = t; + if (t.startsWith('- ')) { + step = /** @type {{ name?: string, uses?: string, run?: string, shell?: string, other?: string[] }} */ ({}); + job.steps.push(step); + stepIndent = ind + 2; + body = t.slice(2).trim(); + } else if (!step || ind < stepIndent) continue; + else if (ind > stepIndent) continue; // nested (with: …) + const m = /^(name|uses|run|shell):\s*(.*)$/.exec(body); + if (!m) { + const k = /^([\w-]+):/.exec(body); // env, if, with, continue-on-error, working-directory, …: the key name only + if (k) (step.other ||= []).push(k[1]); + continue; + } + if (m[1] === 'run' && /^[|>][-+]?$/.test(m[2].trim())) { + const block = []; + let j = i + 1; + let blockIndent = -1; + for (; j < lines.length; j++) { + const l = lines[j]; + if (l.trim() === '') { block.push(''); continue; } + const li = indentOf(l); + if (li < stepIndent || (blockIndent < 0 && li <= stepIndent)) break; + if (blockIndent < 0) blockIndent = li; + if (li < blockIndent) break; + block.push(l.slice(blockIndent)); + } + i = j - 1; + step.run = block.join('\n').replace(/\n+$/, ''); + } else { + step[m[1]] = unquote(m[2]); + } + } + return { jobs }; +} + +/** Is a ci.yml step one of SKIPPED_CI_STEPS? @param {{ name?: string, uses?: string }} s */ +export function isSkippedCiStep(s) { + return SKIPPED_CI_STEPS.some((k) => (k.uses ? (s.uses || '').split('@')[0] === k.uses : s.name === k.name)); +} + +// --------------------------------------------------------------------------------------------------------------------- +// Arguments, env, summary. + +/** + * @param {string[]} argv + * @returns {{ keepGoing: boolean, only: string[] | null, list: boolean, help: boolean }} + */ +export function parseArgs(argv) { + const o = { keepGoing: false, only: /** @type {string[] | null} */ (null), list: false, help: false }; + const ids = new Set(STEPS.map((s) => s.id)); + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--keep-going' || a === '-k') o.keepGoing = true; + else if (a === '--list') o.list = true; + else if (a === '--help' || a === '-h') o.help = true; + else if (a === '--only' || a.startsWith('--only=')) { + const v = a === '--only' ? argv[++i] : a.slice('--only='.length); + if (!v) throw new Error('--only needs step names: ' + [...ids].join(',')); + for (const id of v.split(',').map((x) => x.trim()).filter(Boolean)) { + if (!ids.has(id)) throw new Error(`unknown step "${id}" (steps: ${[...ids].join(', ')})`); + (o.only ??= []).push(id); + } + } else throw new Error(`unknown option ${a} (node tools/ci.mjs --help)`); + } + return o; +} + +/** The steps to run, in workflow order. @param {string[] | null} only */ +export function selectSteps(only) { + return only ? STEPS.filter((s) => only.includes(s.id)) : STEPS.slice(); +} + +/** A child's env: `base` plus ci.yml's job env for the keys `base` does not set. */ +export function stepEnv(step, base = process.env) { + const env = { ...base }; + if (step.ci.job === 'test') for (const [k, v] of Object.entries(CI_TEST_ENV)) if (env[k] === undefined) env[k] = v; + return env; +} + +/** @param {number} ms */ +export function formatDuration(ms) { + if (ms < 1000) return `${Math.round(ms)}ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`; +} + +/** + * The summary table. `status`: ok | fail | not run. + * @param {{ id: string, status: 'ok' | 'fail' | 'not run', ms?: number, note?: string }[]} results + */ +export function formatSummary(results) { + const w = Math.max(...results.map((r) => r.id.length), 4); + const label = { ok: 'ok', fail: 'FAIL', 'not run': '-' }; + const rows = results.map((r) => { + const time = r.ms == null ? '' : formatDuration(r.ms); + return ` ${label[r.status].padEnd(5)} ${r.id.padEnd(w)} ${time.padStart(7)}${r.note ? ' ' + r.note : ''}`.trimEnd(); + }); + const failed = results.filter((r) => r.status === 'fail').map((r) => r.id); + const tail = failed.length ? `FAILED: ${failed.join(', ')}` : results.some((r) => r.status === 'ok') ? 'all steps passed' : 'nothing ran'; + return ['', '── ci summary ──', ...rows, tail, ''].join('\n'); +} + +// --------------------------------------------------------------------------------------------------------------------- +// Running. + +/** The JS entry of a dependency's bin, e.g. ('eslint', 'eslint') → node_modules/eslint/bin/eslint.js. */ +export function binPath(pkg, name, root = ROOT) { + const dir = path.join(root, 'node_modules', pkg); + let manifest; + try { manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { + throw new Error(`${pkg} is not installed (node_modules/${pkg}) — run npm ci first`); + } + const rel = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[name]; + if (!rel) throw new Error(`node_modules/${pkg}/package.json has no bin "${name}"`); + return path.join(dir, rel); +} + +/** Run `node ` in ROOT with inherited stdio; resolves to the exit code (1 when killed by a signal). */ +function node(args, env) { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { cwd: ROOT, env, stdio: 'inherit' }); + child.on('error', (e) => { console.error(e.message); resolve(1); }); + child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); +} + +/** A free TCP port on 127.0.0.1. */ +export function freePort() { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const { port } = /** @type {net.AddressInfo} */ (srv.address()); + srv.close(() => resolve(port)); + }); + }); +} + +/** Live server children, killed if this process exits early. @type {Set} */ +const servers = new Set(); +process.on('exit', () => { for (const c of servers) { try { c.kill('SIGKILL'); } catch { /* gone */ } } }); + +/** Stop a server child: SIGTERM (graceful on POSIX; on Windows kill() terminates), SIGKILL after 8 s. */ +function stopServer(child) { + if (child.exitCode !== null || child.signalCode !== null) { servers.delete(child); return Promise.resolve(); } + return new Promise((resolve) => { + const force = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 8000); + child.once('exit', () => { clearTimeout(force); servers.delete(child); resolve(); }); + try { child.kill('SIGTERM'); } catch { clearTimeout(force); servers.delete(child); resolve(); } + }); +} + +/** The smoke step of ci.yml's job `test`, on a free port instead of 3000 (a running game server must not clash). */ +async function smoke(env) { + const port = await freePort(); + // [ASSUMED] HOST=127.0.0.1 instead of the unset-HOST default (::): the requests go to 127.0.0.1 either way, and + // listening only on loopback spares a firewall prompt on Windows / macOS. + const child = spawn(process.execPath, ['server/index.js'], { + cwd: ROOT, env: { ...env, PORT: String(port), HOST: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'pipe'], + }); + servers.add(child); + let log = ''; + child.stdout.on('data', (d) => { log += d; }); + child.stderr.on('data', (d) => { log += d; }); + const base = `http://127.0.0.1:${port}`; + const fail = async (msg) => { + await stopServer(child); + console.error(`smoke: ${msg}\n── server log ──\n${log}`); + return 1; + }; + try { + let health = null; + for (let i = 0; i < 30 && !health; i++) { + if (child.exitCode !== null) return await fail(`the server exited (code ${child.exitCode})`); + try { + const r = await fetch(base + '/healthz', { signal: AbortSignal.timeout(2000) }); + if (r.ok) health = await r.text(); + } catch { /* not up yet */ } + if (!health) await new Promise((r) => setTimeout(r, 1000)); + } + if (!health) return await fail(`no 200 from ${base}/healthz within 30 s`); + console.log(`GET /healthz → ${health.trim()}`); + const index = await fetch(base + '/', { signal: AbortSignal.timeout(10000) }); + const html = await index.text(); + if (!index.ok) return await fail(`GET / → HTTP ${index.status}`); + if (!/} env */ +function runStep(step, env) { + switch (step.id) { + case 'setup': return node(['tools/setup.mjs', '--check', '--no-local'], env); + case 'test': return node(['--test'], env); + case 'smoke': return smoke(env); + case 'lint': return node([binPath('eslint', 'eslint'), '.'], env); + case 'imports': return node(['tools/check-imports.mjs'], env); + case 'typecheck': return node([binPath('typescript', 'tsc'), '--noEmit', '--checkJs', '-p', 'jsconfig.json'], env); + default: throw new Error(`no runner for step ${step.id}`); + } +} + +export async function main(argv = process.argv.slice(2)) { + let opts; + try { opts = parseArgs(argv); } catch (e) { console.error(e.message); return 2; } + if (opts.help) { + console.log('node tools/ci.mjs [--keep-going] [--only ] [--list] — the checks of .github/workflows/ci.yml'); + console.log('steps: ' + STEPS.map((s) => s.id).join(', ')); + return 0; + } + if (opts.list) { + for (const s of STEPS) console.log(`${s.id.padEnd(10)} ${s.describe}\n${''.padEnd(10)} ← ci.yml ${s.ci.job}: ${s.ci.name}`); + for (const s of SKIPPED_CI_STEPS) console.log(`(skipped) ${s.uses || s.name} — ${s.why}`); + return 0; + } + const steps = selectSteps(opts.only); + const results = steps.map((s) => ({ id: s.id, status: /** @type {'ok' | 'fail' | 'not run'} */ ('not run'), ms: undefined, note: undefined })); + let stopped = false; + for (let i = 0; i < steps.length; i++) { + if (stopped) break; + const step = steps[i]; + console.log(`\n▶ ${step.id} — ${step.describe}`); + const t0 = performance.now(); + let code; + try { code = await runStep(step, stepEnv(step)); } catch (e) { console.error(e?.message || e); code = 1; } + results[i].ms = performance.now() - t0; + results[i].status = code === 0 ? 'ok' : 'fail'; + if (code !== 0) { + results[i].note = `exit ${code}`; + if (!opts.keepGoing) stopped = true; + } + } + console.log(formatSummary(results)); + return results.some((r) => r.status === 'fail') ? 1 : 0; +} + +function isMain() { + try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } +} + +if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; }); diff --git a/tools/doctor.mjs b/tools/doctor.mjs index 7c8aa45f..e7f59e7e 100755 --- a/tools/doctor.mjs +++ b/tools/doctor.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // tools/doctor.mjs — diagnose an install (docs/DEPLOY.md「排错」). Read-only: changes nothing. // -// node tools/doctor.mjs [--port 3000] [--host 0.0.0.0] +// node tools/doctor.mjs [--port 3000] [--host ::] // // Checks: Node/npm versions, dependencies, public/vendor, data/*.json, the shipped files against the release's // MANIFEST.json (server/update.js checkInstall; a source checkout has none) and an update package not applied yet, @@ -38,28 +38,69 @@ const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap|radmin|hamachi function ipv4ToInt(ip) { return ip.split('.').reduce((n, x) => (n << 8) + Number(x), 0) >>> 0; } function inCidr(ip, base, bits) { const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask); } +/** The first two hextets of an IPv6 address (a zone id is dropped); null when it cannot be parsed. @param {string} ip */ +function ipv6Head(ip) { + const [a = '', b = ''] = String(ip).split('%')[0].split(':'); + const h1 = parseInt(a, 16); + if (!Number.isInteger(h1)) return null; + const h2 = parseInt(b, 16); + return [h1, Number.isInteger(h2) ? h2 : 0]; +} + +/** An http URL for one address. An IPv6 literal needs brackets (`http://[240e:…]:3000`). Also used by scripts/launch.mjs. */ +export function hostUrl(address, port) { + return `http://${String(address).includes(':') ? `[${address}]` : address}:${port}`; +} + +/** IPv6 counterpart of the IPv4 chain (same order, same kinds). @param {string} name @param {string} ip */ +function classifyV6(name, ip) { + const head = ipv6Head(ip); + if (!head) return 'virtual'; + const [h1, h2] = head; + // fe80::/10 needs a zone id (%12 / %eth0) that a URL cannot carry, so it is no use to a friend. + if ((h1 & 0xffc0) === 0xfe80) return 'linklocal'; + // 2002::/16 (6to4) and 2001:db8::/32 (documentation) are never an address to hand out. + if (h1 === 0x2002 || (h1 === 0x2001 && h2 === 0x0db8)) return 'virtual'; + if (VPN_IF.test(name)) return 'vpn'; + if (VIRTUAL_IF.test(name)) return 'virtual'; + if ((h1 & 0xfe00) === 0xfc00) return 'lan'; // fc00::/7 ULA + if ((h1 & 0xe000) === 0x2000) return 'public'; // 2000::/3 global unicast + return 'virtual'; +} + /** - * Classify every non-internal IPv4 address: 'lan' (RFC 1918, what friends at home use), 'vpn' (Tailscale / ZeroTier / - * Radmin / Hamachi / CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, VirtualBox, a Clash/Mihomo TUN adapter … — - * not reachable from other machines, sharing them only confuses people), 'public' (a public address directly on this - * machine), 'linklocal' (169.254 — no DHCP, useless). + * Classify every non-internal address — IPv4 and IPv6 — as 'lan' (RFC 1918 / ULA fc00::/7), 'vpn' (Tailscale / + * ZeroTier / Radmin / Hamachi / CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, a Clash/Mihomo TUN adapter), + * 'public' (an address on this machine that is not one of those; an IPv6 global unicast is the usual home-server + * way in) or 'linklocal' (169.254 / fe80::). * @returns {{ name: string, address: string, kind: string }[]} best first */ export function classifyAddresses(ifaces = os.networkInterfaces()) { const out = []; + const v6Seen = new Set(); // privacy extensions put several addresses of one /64 on a machine — one entry is enough for (const [name, addrs] of Object.entries(ifaces)) { for (const a of addrs || []) { - if (!(a.family === 'IPv4' || a.family === 4) || a.internal) continue; + if (a.internal) continue; const ip = a.address; - let kind; - if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal'; - // 198.18.0.0/15 是 RFC 2544 的基准测试段:代理软件(Clash / Mihomo 的 fake-ip 池)拿它做本地 TUN 地址, - // 绝对不是能发给朋友的「公网 IP」。 - else if (inCidr(ip, '198.18.0.0', 15)) kind = 'virtual'; - else if (VPN_IF.test(name) || inCidr(ip, '100.64.0.0', 10)) kind = 'vpn'; - else if (VIRTUAL_IF.test(name)) kind = 'virtual'; - else if (inCidr(ip, '10.0.0.0', 8) || inCidr(ip, '172.16.0.0', 12) || inCidr(ip, '192.168.0.0', 16)) kind = 'lan'; - else kind = 'public'; + if (a.family === 'IPv4' || a.family === 4) { + let kind; + if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal'; + // 198.18.0.0/15 是 RFC 2544 的基准测试段:代理软件(Clash / Mihomo 的 fake-ip 池)拿它做本地 TUN 地址, + // 绝对不是能发给朋友的「公网 IP」。 + else if (inCidr(ip, '198.18.0.0', 15)) kind = 'virtual'; + else if (VPN_IF.test(name) || inCidr(ip, '100.64.0.0', 10)) kind = 'vpn'; + else if (VIRTUAL_IF.test(name)) kind = 'virtual'; + else if (inCidr(ip, '10.0.0.0', 8) || inCidr(ip, '172.16.0.0', 12) || inCidr(ip, '192.168.0.0', 16)) kind = 'lan'; + else kind = 'public'; + out.push({ name, address: ip, kind }); + continue; + } + if (a.family !== 'IPv6' && a.family !== 6) continue; + const kind = classifyV6(name, ip); + if (kind === 'linklocal' || kind === 'virtual') { out.push({ name, address: ip, kind }); continue; } + const prefix = ip.split('%')[0].split(':').slice(0, 4).join(':'); + if (v6Seen.has(`${name}|${prefix}`)) continue; + v6Seen.add(`${name}|${prefix}`); out.push({ name, address: ip, kind }); } } @@ -95,10 +136,12 @@ function canListen(port, host) { } /** 'ours' (our server answers /healthz), 'free', 'busy' (another program) or 'denied'. */ -export async function probePort(port, host = '0.0.0.0') { +export async function probePort(port, host = '::') { const r = await getJson(`http://127.0.0.1:${port}/healthz`); if (r.json && r.json.ok === true && 'uptimeSec' in r.json) return { state: 'ours', health: r.json }; - const l = await canListen(port, host); + let l = await canListen(port, host); + // The same three errors startServer treats as "this host has no IPv6": don't report the port busy for that. + if (!l.ok && host === '::' && ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(l.code)) l = await canListen(port, '0.0.0.0'); if (l.ok) return { state: 'free' }; if (l.code === 'EACCES') return { state: 'denied', code: l.code }; return { state: 'busy', code: l.code, http: r.status }; @@ -151,7 +194,7 @@ function tool(cmd, args) { // --------------------------------------------------------------------------------------------------- function parseArgs(argv) { - const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', help: false }; + const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '::', help: false }; for (let i = 0; i < argv.length; i++) { const [k, v] = argv[i].split('='); const val = () => (v !== undefined ? v : argv[++i]); @@ -166,7 +209,7 @@ function parseArgs(argv) { async function main() { let opts; try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); return 2; } - if (opts.help) { console.log('node tools/doctor.mjs [--port 3000] [--host 0.0.0.0] — 只读诊断,不修改任何文件'); return 0; } + if (opts.help) { console.log('node tools/doctor.mjs [--port 3000] [--host ::] — 只读诊断,不修改任何文件'); return 0; } const rows = []; let bad = false; const row = (state, label, detail = '') => { rows.push([state, label, detail]); if (state === 'err') bad = true; }; @@ -224,16 +267,17 @@ async function main() { else if (port.state === 'denied') row('err', `端口 ${opts.port}`, '没有权限监听(Linux 上 < 1024 的端口需要 root)→ 换一个 PORT'); else row('err', `端口 ${opts.port}`, `被其他程序占用(${port.code})→ 关闭它或换端口:${IS_WIN ? '$env:PORT=3001; npm start' : 'PORT=3001 npm start'}`); const env = ['PORT', 'HOST', 'SP_COMBAT', 'SP_VERIFY', 'TRUST_PROXY', 'DEBUG'].filter((k) => process.env[k] != null && process.env[k] !== ''); - row('skip', '环境变量', env.length ? env.map((k) => `${k}=${process.env[k]}`).join(' ') : '全部默认(PORT=3000 HOST=0.0.0.0 SP_COMBAT=client SP_VERIFY=off)'); + row('skip', '环境变量', env.length ? env.map((k) => `${k}=${process.env[k]}`).join(' ') : '全部默认(PORT=3000 HOST=:: SP_COMBAT=client SP_VERIFY=off)'); section('朋友如何访问'); const addrs = classifyAddresses(); - if (!addrs.length) row('warn', '网络', '没有可用的 IPv4 地址(未联网?)'); + if (!addrs.length) row('warn', '网络', '没有可用的地址(未联网?)'); for (const a of addrs) { const usable = a.kind === 'lan' || a.kind === 'vpn' || a.kind === 'public'; - row(usable ? 'ok' : 'skip', `http://${a.address}:${opts.port}`, `${KIND_LABEL[a.kind]} · ${a.name}`); + // An IPv6 literal needs the brackets of hostUrl(): `http://240e:…:3000` is not a URL anyone can open. + row(usable ? 'ok' : 'skip', hostUrl(a.address, opts.port), `${KIND_LABEL[a.kind]} · ${a.name}`); } - if (opts.host !== '0.0.0.0' && opts.host !== '::') row('warn', 'HOST', `HOST=${opts.host}:只监听这个地址,其他电脑可能连不上(默认 0.0.0.0)`); + if (opts.host !== '0.0.0.0' && opts.host !== '::') row('warn', 'HOST', `HOST=${opts.host}:只监听这个地址,其他电脑可能连不上(默认 :: 双栈)`); section('防火墙'); for (const [m, text] of firewallHints(opts.port)) rows.push([m === '' ? 'raw' : 'mark', text, '', m]); diff --git a/tools/fetch-assets.mjs b/tools/fetch-assets.mjs index 4624cba9..3c07d85c 100644 --- a/tools/fetch-assets.mjs +++ b/tools/fetch-assets.mjs @@ -18,6 +18,8 @@ // Idempotent: existing files with the right size are skipped, so re-running is // cheap. Downloads use ~16 parallel connections, 3 retries per direct source, // a jsDelivr fallback and an opt-in GitHub proxy (one short attempt per URL). +// HTTP(S)_PROXY is picked up by restarting once with NODE_USE_ENV_PROXY=1 +// (Node >=22.21 or >=24). An older Node fails instead of fetching directly. // Spine atlases get `size:` (and `pma: true` for enemies); every skeleton is // parsed to resolve animation roles. // @@ -49,6 +51,7 @@ import { existsSync, realpathSync } from 'node:fs'; import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { Downloader } from './assets/downloader.mjs'; +import { restartForEnvProxy } from './assets/env-proxy.mjs'; import { MirrorPolicy, selectDownloadSource, validateSource } from './assets/network.mjs'; import { normalizeProxyPrefix } from './assets/sources.mjs'; import { loadIndexes } from './assets/cache.mjs'; @@ -463,7 +466,7 @@ async function main() { // run only as a script (tests import parseArgs / shrinkGuard) const invoked = (() => { try { return pathToFileURL(realpathSync(process.argv[1] || '')).href; } catch { return null; } })(); -if (invoked === import.meta.url) { +if (invoked === import.meta.url && !restartForEnvProxy()) { main().then((code) => { process.exitCode = code; }, (e) => { console.error(`[assets] FAILED: ${process.env.DEBUG ? e?.stack || e : e?.message || e}`); process.exitCode = 1; 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; }); +}