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