feedback5: merge W5T — docs: ARCHITECTURE.md (a map of the code, every path checked by test/docs-paths.test.js), CONTRIBUTING.md (中文 + English summary), DESIGN §25 "0.2.0" assembled from the stream drafts (after §24.9); the DESIGN split plan and the CHANGELOG 0.2.0 draft stay in the integrator's notes until the last merges

This commit is contained in:
sganggs committed 2026-10-06 18:14:11 +08:00
commit e01bba6c0a
5 files changed
+969 -1

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
# 参与贡献
欢迎提 Issue 和 Pull Request:bug、和官方规则不一致的地方、新干员的战斗逻辑、翻译、文档都可以。动手之前先看一眼
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)(代码地图:哪个规则在哪个文件)。English summary at the end.
## 1. 准备环境
需要 Node.js 22 或 24(CI 在这两个版本上跑)。
```bash
git clone https://github.com/sganggs/Stronghold-Protocol.git
cd Stronghold-Protocol
npm install # postinstall 把 pixi / preact / three 复制到 public/vendor
npm run setup # 下载美术 / 音频(只跑测试可以不下载,缺素材的用例会自动跳过)
npm run dev # 启动服务器(node --watch,改了服务器代码自动重启):http://localhost:3000
```
`npm run doctor` 检查 Node 版本、素材、端口。官方数据表由 `tools/build-data.mjs` 下载到 `.cache/gamedata/`,改数据时才需要。
## 2. 运行测试
只跑和改动相关的测试,提交 PR 前再跑一次全部:
```bash
node --test test/content/op_siege.test.js # 一个文件
node --test --test-name-pattern="S3" test/content/op_siege.test.js # 文件里的部分用例
node --test # 全部(需要浏览器 / 素材的用例自动跳过)
npm run golden # 黄金结果:整套固定种子的战斗和对局
GOLDEN_FULL=1 node --test test/golden.test.js # 同上,node:test 形式
npm run golden:update # 有意改变玩法时重新生成
npm run lint && npm run check:imports && npm run typecheck # 代码检查、导入边界、类型
```
浏览器测试默认不跑,需要本机 Chrome(路径不标准时设置 `CHROME_PATH`),用环境变量打开:
```bash
SP_E2E=1 node --test test/ui/mock.e2e.test.js # 界面端到端(不需要素材)
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 真实对局(需要已下载的素材)
RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染
SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里的战斗模拟
```
**黄金结果**([test/golden/README.md](test/golden/README.md)):只重构、不改玩法的提交不能改变
`test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,把新文件和改动放在同一个提交里,并在提交说明里写清
哪些场景变了、为什么。
## 3. 忠实原则
这是官方玩法的复刻,规则以官方为准:
- **出处**:官方数据表(`.cache/gamedata/excel/`:character_table、skill_table、uniequip_table / battle_equip_table、
activity_table 的 act2autochess、enemy_database、关卡文件)和 [PRTS](https://prts.wiki/)(干员页、技能和天赋的「备注」、
卫戍协议相关页面)。数值尽量取 blackboard,不要凭记忆写数字。官方实测的截图或录像也很有价值,请附在 Issue / PR 里。
- **查不到出处**的细节按最简单、最接近官方的方式实现,在代码注释和 PR 描述里标 `[ASSUMED]`。维护者拍板的写成
“the owner's decision of YYYY-MM-DD”。有意和官方不同的规则只能由维护者决定,并写进 docs/DESIGN.md。
- **版权**:不要把技能描述、剧情等受版权保护的文本大段复制进代码、文档或 PR,引一小句并注明出处即可。不要提交任何游戏
素材文件(`public/assets/` 已被 `.gitignore` 排除)。
- **数据**:不要手改 `data/*.json`。改 `tools/build-data.mjs`,用 `node tools/build-data.mjs --offline` 重新生成,再比较
JSON,确认只变了想改的部分。
## 4. 提交与 PR
- 一个 PR 只做一件事;干员的 kit 一个 PR 只放一名,便于审阅。
- 提交信息一行写清改了什么(中文或英文都可以),相关的 Issue 写上编号,例如 `余 S2 casts with an enemy on its x-1 (#32)`。
只写改动本身,不加 `Co-Authored-By:`、`Generated with …` 这类署名尾行(维护者的约定)。合并的 PR 会在 CHANGELOG
里写明并致谢作者。
- PR 描述写:改了什么、为什么、出处(数据表字段、PRTS 链接)、`[ASSUMED]` 的地方、跑了哪些测试、黄金结果里哪些场景变了。
界面改动附截图。
- 规则改了,相关文档一起改:docs/DESIGN.md(规则与契约)、docs/SIM.md、docs/META.md、docs/DATA.md 等。
`test/docs-consistency.test.js` 固定了文档里的一些句子,改文档后跑一下。
- 代码和注释用英文;玩家文档(README、PLAYING、DEPLOY)用简体中文,技术文档用英文。界面上的文字写中文并用 `t('…')`
包起来,英文放进 `public/i18n/en.json`([docs/I18N.md](docs/I18N.md))。
- 提交的代码以 GPL-3.0-or-later 发布。本项目坚持非商业,请不要加入广告、付费、打赏等任何变现功能。
## 5. 添加干员(自选编队)
自选编队里的每名 6★ 干员都需要一个自己的战斗文件(kit),写法见
[server/sim/content/kits/README.md](server/sim/content/kits/README.md) 的「How to add an operator (自选)」。简要步骤:
1. 数据已经生成好:`data/backups.json` 的 `units[charId]`(各形态的数值、技能、天赋、模组)和 `tokens`(召唤物)。
先对照官方数据表和 PRTS 核对。
2. 新建 `server/sim/content/kits/ops/op-<codename>.js`(codename 是 charId 去掉 `char_<n>_`),默认导出只有一个键:charId。
3. 把文件名加进 `server/sim/content/kits/index.js` 的 `OPERATOR_KIT_FILES`,这名干员就能在自选编队里选了。
4. 写测试 `test/content/op_<codename>.test.js`(照 `test/content/op_siege.test.js` 的写法:每个形态、每个模组、每个技能),
跑 `node --test test/content/kits_layout.test.js`,再 `npm run golden:update`(只应新增 `test/golden/diy.json` 的场景)。
5. PR 里逐项勾选 README 里的「Fidelity checklist」。
目前池子里的 71 名干员都已经有 kit;改进现有的 kit 也按同样的清单来。相关讨论见 GitHub issue #136。
---
## English summary
- **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
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.
- **Fidelity**: the official tables (`.cache/gamedata/excel/`) and PRTS (including the 备注 notes) are the sources;
numbers come from the blackboards. Anything no source settles is marked `[ASSUMED]` in the code and the pull
request; owner decisions are cited with their date; deliberate deviations are the owner's call and go into
docs/DESIGN.md. Do not paste copyrighted game text at length (quote briefly, cite) and never commit game assets.
Never hand-edit `data/*.json`: change `tools/build-data.mjs`, rebuild with `--offline`, compare the JSON.
- **Commits and pull requests**: one change per pull request, one operator per kit pull request. A one-line commit
message saying what changed, with the issue number; no attribution trailers such as `Co-Authored-By:` or
`Generated with …` (the maintainers' convention) — merged pull requests are credited in the CHANGELOG. Describe the
sources, the `[ASSUMED]` items, the tests and the golden scenarios that moved; update the docs a rule change makes
wrong. Code and comments in English; player-facing text in Chinese through `t('…')` with the English in
`public/i18n/en.json`. Code is GPL-3.0-or-later; no monetisation features.
- **Adding an operator (自选)**: follow "How to add an operator (自选)" in
[server/sim/content/kits/README.md](server/sim/content/kits/README.md): `op-<codename>.js` keyed by the charId,
registered in `OPERATOR_KIT_FILES`, a test `test/content/op_<codename>.test.js`, the fidelity checklist in the pull
request. All 71 operators of the current pool have kits; improvements follow the same checklist (GitHub issue #136).
+5
View File
@@ -174,6 +174,8 @@ npm start # 启动服务器:http://localhost:3000
| [docs/DEPLOY.md](docs/DEPLOY.md) | 部署指南:Windows 开服与开机自启、防火墙、组网 / 隧道、反向代理与 HTTPS、Docker、systemd、排错 |
| [docs/WINDOWS.md](docs/WINDOWS.md) | Windows 便携包:怎么打一份「零安装」包(`scripts/make-windows-bundle.mjs`)、包里放了什么、授权注意事项 |
| [docs/DESIGN.md](docs/DESIGN.md) | 架构与契约(英文):技术栈、目录分工、网络协议、渲染与 UI、各次试玩后的规则修订 |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 代码地图(英文):服务器、联机协议和前后端共用的战斗模拟,0.2.0 重构后的目录,数据流,黄金结果与导入边界,常见改动从哪里入手 |
| [CONTRIBUTING.md](CONTRIBUTING.md) | 参与贡献:准备环境、运行测试、忠实原则、提交与 PR 约定、怎样添加自选干员(中文,末尾附英文摘要) |
| [docs/SIM.md](docs/SIM.md) | 战斗模拟引擎参考(英文):钩子、技能描述格式、职业默认行为 |
| [docs/META.md](docs/META.md) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 |
| [docs/DATA.md](docs/DATA.md) | 由官方数据表生成的游戏数据(英文) |
@@ -195,6 +197,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
- 游戏数据由 `npm run build-data`(`tools/build-data.mjs`)从官方数据表生成,不要手工修改 `data/*.json`。
- 只重构、不改玩法的提交不能改变 `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)。
## 项目结构
@@ -229,6 +232,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
欢迎提 Issue 反馈 bug、与官方规则不一致的地方或改进建议,也欢迎提交 Pull Request:
- 准备环境、运行测试、忠实原则和提交约定见 [CONTRIBUTING.md](CONTRIBUTING.md)。
- 提交前请运行 `node --test`,并同步更新相关文档;文档使用简体中文,代码与注释使用英文。
- 提交的代码将以 GPL-3.0-or-later 发布。
- 请不要提交任何游戏素材文件(`public/assets/` 等目录已被 `.gitignore` 排除)。
@@ -244,3 +248,4 @@ An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess t
- **Play with friends:** create a co-op room and share the 4-letter key or the `?room=KEY` link. On a LAN, use the address printed at start; otherwise use a virtual-LAN tool, a tunnel or a VPS — see [docs/DEPLOY.md](docs/DEPLOY.md).
- **Disclaimer:** not affiliated with or endorsed by Hypergryph or Yostar. All Arknights names, art, audio, text and data are © their respective owners and are **not** covered by this project's GPL licence. For study and personal non-commercial use only — no selling, paid distribution, paid servers or monetisation of any kind. Content will be removed on request of the rights holders. Provided "as is", without warranty.
- **License:** code GPL-3.0-or-later ([LICENSE](LICENSE)); game assets excluded.
- **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) (an English summary at its end); the code map is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
+184
View File
@@ -0,0 +1,184 @@
# ARCHITECTURE.md — a map of the code
For a newcomer who wants to find their way before changing something. This file only maps the code. The contracts are
in [DESIGN.md](DESIGN.md) (§5 the battle engine, §6 the match, §8 the protocol, §9 rendering, §14 client-side combat);
the details in [SIM.md](SIM.md) (battle engine, hooks, SkillSpec), [META.md](META.md) (match engine, prep-phase
effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and audio) and [I18N.md](I18N.md)
(languages). How to set up, test and send a change: [CONTRIBUTING.md](../CONTRIBUTING.md).
## 1. What runs where
```
browser (public/) one Node process (server/index.js, Node ≥ 22)
┌──────────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
│ screens/, ui/ Preact + htm │─ HTTP ▶│ server/http/ static files: / /data/ /shared/ │
│ render/ PixiJS, pixi-spine, │ │ /sim/ /media/, GET /healthz │
│ three.js │ │ │
│ battle/runner.js runs the sim │◀ /sim/ │ server/sim/ the deterministic battle sim │
│ net.js the socket │◀ /ws ▶ │ server/net.js → lobby.js → match/Match.js │
│ │ │ (rooms) (rounds, economy, views) │
└──────────────────────────────────────┘ └─────────────────────────────────────────────────────┘
```
- **The server** is one Node process: plain `node:http` plus `ws`, no framework (`server/index.js` wires
`server/http/`). It serves the static client, the generated data and the sim's source files, and runs every room and
match in memory — rounds, shop, economy, bots, validation. Nothing is written to disk.
- **The browser** loads native ES modules with no bundler and no build step; the libraries are vendored into
`public/vendor/` by `tools/vendor.mjs` on `npm install`.
- **The battle simulation** (`server/sim/`) is pure ESM without any Node API, served read-only at `/sim/`. Both sides
run it from the same JSON battle description (`server/sim/spec.js`, the BattleSpec): each player's browser simulates
its own battle (`public/js/battle/runner.js`) and reports progress and the result; the server checks the result
against the spec (`server/match/fields.js` `validateClientResult`), runs the battles no connected human owns (bots, a
dropped player, an authority that missed its deadline) and can re-simulate results (`SP_VERIFY`). `SP_COMBAT=server`
is the older mode in which the server runs every battle and streams snapshots. DESIGN §14.
- **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock and
no `Math.random` in the sim, so the same spec and data give the same battle on every machine, bit for bit. The golden
results (§5) rely on it.
## 2. The WebSocket protocol
`shared/protocol.js` is normative: `C2S` holds one validator per client message and `S2C` lists the server messages.
Every frame is JSON text, `{ t, rid?, …fields }`.
| direction | messages | handled in |
|---|---|---|
| client → server | `hello` (name, reconnect token) → `welcome`; `ping` → `pong` | `server/net.js` |
| | `room.*`: create, join, ready, difficulty, AI seats, kick, start, the 干员调配 loadout, 干员持有 ownership, 自选编队 picks, spectating | `server/lobby.js` |
| | `g.*`: match intents — buy, refresh, freeze, level up, sell, move, equip, Arts, rewards, 机变 choices, ready, emotes, watching, pause … | `server/match/match/intents.js` → `server/match/player/` |
| | `b.progress`, `b.result`: the battle reports of the authoritative browser | `server/match/match/reports.js` |
| server → client | `room.state`, `room.closed` | `server/lobby.js` |
| | `m.public` (what every player sees), `m.private` (one player's shop, hand, funds …), `m.field`, `m.toast`, `m.ticker`, `m.emote`, `m.unitStats`, `m.result` | `server/match/match/views.js`, `server/match/player/views.js` |
| | `b.start` (a BattleSpec), `b.pool` (the shared leader HP), `b.end`; `b.snap` / `b.ev` only in the server-run mode | `server/match/match/clientCombat.js` |
- A request that carries `rid` is answered with `ok` or `error` echoing it. `server/net.js` rate-limits each socket,
validates every message against `C2S` and refuses anything unknown; the handlers never trust the client.
- Messages meant for people (`m.toast`, `m.ticker`) carry a message id and its parameters, so each client shows them in
its own language (`shared/i18n.js` `wireMessage`).
- The exact view shapes: DESIGN §8.3 and META §5.
## 3. The directory layout
0.2.0 split the big files without changing behaviour. Three classes keep their constructor in one file and their
methods in a folder, installed on the prototype in a fixed order (a method name defined twice throws):
`server/sim/Battle.js` with `server/sim/battle/`, `server/match/Match.js` with `server/match/match/`, and
`server/match/PlayerState.js` with `server/match/player/`. Two client modules became barrels that re-export a folder,
so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogic/`) and `public/js/render/fx.js`
(`public/js/render/fx/`). DESIGN §2 lists every file.
### Server and shared code
| path | what |
|---|---|
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`), `static.js` (the mounts), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `common.js`, `boot.js` (banner, shutdown) |
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `server/data.js` | loads `data/*.json` once (frozen) |
| `server/match/Match.js` | one match: the phase machine, timers, the round loop, co-op, the views; its methods are in `server/match/match/` (`phases.js`, `prep.js`, `combat.js`, `clientCombat.js`, `reports.js`, `unitePhase.js`, `bossRounds.js`, `settle.js`, `views.js`, `intents.js` …) |
| `server/match/PlayerState.js` | one player's shop, hand, board, items, bonds and LP, and every prep intent; its methods are in `server/match/player/` (`economy.js`, `acquire.js`, `placement.js`, `items.js`, `pieces.js`, `prep.js`, `round.js`, `diy.js`, `views.js` …) |
| `server/match/` (the rest) | `pool.js` (the shared chess pool), `board.js` (placement), `bondsMeta.js`, `effectsMeta.js` and `builtinMeta.js` (prep-phase effects), `choices.js` (机变), `waves.js`, `unite.js` (联防), `finalAssault.js`, `results.js`, `bot.js` (AI players and AI 托管), `fields.js` (the battles of a combat phase), `scheduler.js`, `gamedata.js` |
| `server/sim/Battle.js` | one battle field; its methods are in `server/sim/battle/` (`lifecycle.js`, `spawns.js`, `deploy.js`, `blocking.js`, `combat.js`, `status.js`, `summons.js`, `displacement.js`, `events.js` …) |
| `server/sim/` (the rest) | `skills.js`, `ai.js`, `damage.js`, `buffs.js`, `targeting.js`, `grid.js` (pathing), `units.js`, `professions.js`, `snapshot.js`, `spec.js` (the BattleSpec), `simdata.js` (data records → engine defs), `nodeData.js` (the Node-only data loader, never served) |
| `server/sim/content/` | everything game-specific, installed into a battle by `server/sim/content/index.js` |
| `server/sim/content/kits/` | the operator kits: `ops/` holds one file per chess (`<chessId>-<codename>.js`, 129), per 补位 stand-in (`standin-<codename>.js`, 9) and per 自选 operator (`op-<codename>.js`, 71); `shared/` the helpers several kits use; `index.js` the registry; `README.md` the guide |
| `server/sim/content/enemies/` | the enemy kits by special type (`invisible.js`, `times.js`, `element.js`, `dot.js`, `reflection.js`, `fly.js`, `special.js`) and `leaders.js`; `server/sim/content/enemies.js` dispatches them, `server/sim/content/bosses.js` scripts the leaders |
| `server/sim/content/garrisons/`, `items/`, `bands/` | 特质, equipment and strategies: `battle.js` is the battle side, `meta.js` the prep side (`registerMeta`, META §2) |
| `server/sim/content/bonds/` | the 23 bonds: `core.js` the 8 core bonds (both sides), `server/sim/content/bonds/addon/` the 15 add-on bonds (`battle.js`, `meta.js`) |
| `server/sim/content/` (the rest) | `tokens.js` (summons), `devices.js` (terrain and stage devices), `generic.js` (the kit built from a skill's data when a chess has none), `choices.js` (机变 cards in battle) |
| `shared/` | imported by the server and the browser: `protocol.js`, `constants.js`, `i18n.js` and `i18nData.js`, `standIn.js` (补位), `diy.js` (自选编队), `highGround.js`, `loadoutRecord.js` |
### Client (`public/`)
| path | what |
|---|---|
| `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) |
| `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) |
| `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) |
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
| `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
| `public/js/render/` | the field view: `app.js` with `public/js/render/app/`, `units.js` and `spine.js` (models), `tiles.js`, `projection.js`, `interp.js`, `pick.js`, `drag.js`, `public/js/render/fx/` (effects; `kinds.js` maps the fx kinds), `public/js/render/board3d/` (the official 3D board) |
| `public/css/`, `public/i18n/en.json` | the styles; the English UI strings |
### Data, tools, tests
| path | what |
|---|---|
| `data/*.json` | generated by `tools/build-data.mjs` and committed — never edited by hand (DATA.md); `data/backups.json` holds the 补位 and 自选 data, `data/i18n/en.json` the English game texts, `data/assets.json` the art manifest |
| `tools/` | `build-data.mjs`, `build-i18n.mjs`, `i18n.mjs` (UI strings), `setup.mjs` / `fetch-assets.mjs` / `tools/assets/` (art and audio), `tools/local-extract/` (art from a local game client), `vendor.mjs`, `golden.mjs`, `check-imports.mjs`, `package.mjs` (the release zip), `doctor.mjs`; sweeps: `matchrun.mjs`, `simrun.mjs`, `botbench.mjs`, `balance.mjs` |
| `test/` | `node:test` suites by area: `test/content/` (kits, enemies, bonds, items), `test/sim/` (the engine), `test/match/` (match engine, bots), `test/ui/`, `test/render/`, `test/golden/` (the stored digests), `test/helpers/` (`battleHarness.js`), `test/e2e/` (browser and bot runs) |
| `types/` | JSDoc typedefs of the type-checked slice (`types/README.md`) |
| `docs/` | the documents; `docs/research/` the research on the official mode |
## 4. Data flow
```
official zh_CN tables (.cache/gamedata) ─┐
docs/research/*.json ────────────────────┴─▶ tools/build-data.mjs ─▶ data/*.json
official EN tables ────────────────────────▶ tools/build-i18n.mjs ─▶ data/i18n/en.json
public mirrors, a local client ────────────▶ tools/setup.mjs ──────▶ public/assets/ + data/assets.json
data/*.json ──────────┬─▶ server/data.js ─────────────▶ the match, the server's battles
├─▶ public/js/data.js ──────────▶ the UI
└─▶ public/js/battle/runner.js ─▶ the browser's battles (setSimData)
data/i18n/en.json ──────▶ public/js/data.js ──────────▶ the English game texts
public/assets/ ─────────▶ public/js/render/, public/js/audio.js (URLs listed in data/assets.json)
```
- **Game data.** `tools/build-data.mjs` downloads the official zh_CN tables into `.cache/gamedata/` (or reads them
there with `--offline`), joins them with the research JSON and writes `data/*.json` deterministically; DATA.md
documents every field. A data fix goes into the builder: rebuild with `node tools/build-data.mjs --offline` and
compare the JSON to see that only the intended data changed.
- **On the server**, `server/data.js` loads the files once; the match reads them through `server/match/gamedata.js`,
the sim through `server/sim/simdata.js`, which turns every record into a frozen engine def.
- **In the browser**, `public/js/data.js` fetches the same files once per page for the UI, and the battle runner loads
its own frozen copies for the sim (`loadBrowserSim` → `setSimData`), so both sides simulate from the same data.
- **Languages.** Chinese is the source language. The English game texts come from the official EN client
(`tools/build-i18n.mjs` → `data/i18n/en.json`, applied by `public/js/data.js`); UI strings are wrapped in `t('…')`
and translated in `public/i18n/en.json` (`tools/i18n.mjs` lists what is missing); server messages travel as message
ids. I18N.md.
- **Art and audio** are never committed. `npm run setup` downloads them from public mirrors (`tools/fetch-assets.mjs`,
planned by `tools/assets/plan.mjs`) into `public/assets/` and writes the manifest `data/assets.json`;
`tools/local-extract/` can add art from a local game client. The client only requests URLs listed in the manifest.
ASSETS.md.
## 5. Safety nets
- **Golden results** (`tools/golden.mjs`, `test/golden/`, `test/golden.test.js`): a fixed corpus of seeded scenarios —
every chess with every skill and module, every bond, every leader and 联防 field, whole bot matches, every 补位
stand-in and every 自选 operator — reduced to digests. `npm test` checks a fast subset; `npm run golden` or
`GOLDEN_FULL=1 node --test test/golden.test.js` checks everything. A refactor must never change them; a gameplay
change runs `npm run golden:update` and commits the new digests with the change, naming the scenarios that moved
and why (`test/golden/README.md`).
- **Import boundaries** (`tools/check-imports.mjs`, `npm run check:imports`): `server/sim/` is served to browsers, so it
may not import the match, the lobby, the server entry, the net layer (`ws`, `node:http` …), `public/` or any Node
builtin; `server/match/` may not import `public/` or the net layer. The one listed exception is
`server/sim/nodeData.js`, the Node-only data loader the browser never loads, and `test/check-imports.test.js` pins
that list. The kit layout test (`test/content/kits_layout.test.js`) also refuses Node-only imports, clocks and
`Math.random` in kits.
- **Lint and types.** `npm run lint` is a correctness-only ESLint (`eslint.config.js`: warnings are allowed, errors
are not); `npm run typecheck` checks a JSDoc slice (`jsconfig.json`; `types/README.md` says how to widen it). CI
runs both once, and the tests on Ubuntu and Windows with Node 22 and 24 (`.github/workflows/ci.yml`).
- **Tests.** `node --test` runs every suite that needs no browser; the browser suites are opt-in ([CONTRIBUTING.md](../CONTRIBUTING.md) §2).
The battle harness `test/helpers/battleHarness.js` (SIM.md §10) sets up a battle in a few lines.
## 6. Where to start
| to change … | start at | then |
|---|---|---|
| an operator's skill, talent or module | its kit in `server/sim/content/kits/ops/` (search for the chess id or the Chinese name) | the fidelity checklist of `server/sim/content/kits/README.md`, a test in `test/content/`, golden |
| add a 自选 operator | `server/sim/content/kits/README.md`, "How to add an operator (自选)" | `op-<codename>.js`, `OPERATOR_KIT_FILES`, `test/content/op_<codename>.test.js` |
| a rule of the battle engine (blocking, damage, statuses, skills) | `server/sim/battle/`, `server/sim/damage.js`, `server/sim/skills.js`, `server/sim/targeting.js`, `server/sim/ai.js` | SIM.md, DESIGN §5 |
| an enemy or a leader | `server/sim/content/enemies/`, `server/sim/content/bosses.js` | `test/content/enemies_bosses.test.js` |
| a bond, 特质, item or strategy | `server/sim/content/bonds/`, `server/sim/content/garrisons/`, `server/sim/content/items/`, `server/sim/content/bands/` (the battle side and the prep side apart) | META.md §2 |
| the shop, economy, hand or placement | `server/match/player/`, `server/match/pool.js`, `server/match/board.js` | `test/match/` |
| phases, timers, 联防, the Final Assault | `server/match/match/phases.js`, `server/match/match/unitePhase.js`, `server/match/match/bossRounds.js`, `server/match/unite.js`, `server/match/finalAssault.js` | META.md §1, §4 |
| the bots | `server/match/bot.js` | `tools/botbench.mjs` |
| a new client message | `shared/protocol.js` (`C2S`), then `server/match/match/intents.js` (or `server/lobby.js` for `room.*`) | DESIGN §8 |
| data from the official tables | `tools/build-data.mjs` | rebuild `--offline`, JSON compare, DATA.md, golden |
| a screen or a HUD panel | `public/js/screens/`, `public/js/ui/`; pure logic in `public/js/ui/gameLogic/` | `test/ui/` |
| battle visuals | `public/js/render/fx/kinds.js`, `public/js/render/units.js`, `public/js/render/spine.js` | `test/render/` |
| a UI text | `t('…')` in the code, the English in `public/i18n/en.json` | I18N.md |
| HTTP, headers, static routes | `server/http/` | `test/version.test.js`, `test/client-static.test.js` |
Before changing a rule, read the DESIGN section that owns it (module headers cite their sections) and the official
source; a rule no source settles is marked `[ASSUMED]` ([CONTRIBUTING.md](../CONTRIBUTING.md) §3).
+579 -1
View File
@@ -4,7 +4,7 @@ This is the **single source of truth** for every implementer. Research lives in
Language: all player-facing text is **Simplified Chinese** (names/descriptions come from official data). Code, comments and identifiers are English.
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); **0.1.1** (`package.json`, `shared/constants.js APP_VERSION`; `CHANGELOG.md`) adds the player feedback after it — §21 — and is the state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (`package.json`, `shared/constants.js APP_VERSION`; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — and is the state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
---
@@ -2501,3 +2501,581 @@ Reports after the 0.1.3 release. Each was checked against the official data and
`playtest5_blocking` (its 瑕光 S2 test included), `combat`, `engine-requests`, `kits_t1t2` / `kits_alt_t2` (小满),
`kits_t3` / `kits_alt_t3` (瑕光), `kits_t5` / `kits_alt_t5` / `garrisons_battle` (缇缇) and the other sleep and block
suites pass unchanged.
---
## 25. 0.2.0 — the maintainability refactor, two languages, 补位, 自选编队, fidelity fixes, the 0.1.4 ports
0.2.0 was built on `feedback5`, branched from feedback4 at 583899d (0.1.3, the golden-result harness and part of 0.1.4);
later merges of feedback4 brought the rest of 0.1.4 in. The workstreams, each on its own `fb5-*` branch, each merged
after review with its golden scenarios named: R1–R6 (refactor, tooling), W5A (golden results), W5B / W5D / W5G /
K1–K3 / W5N (补位), W5H / W5K / O1–O24 (自选编队 and its operator kits), W5I (languages, phase 1), W5C / W5E / W5F /
W5J / W5L (fidelity, wave 1), WE1 / WE2 (the follow-ups the kit waves reported), W5M and the feedback4 merges (the
0.1.4 ports). Each rule was checked against the official data and PRTS; anything no source backs is marked [ASSUMED].
The normative sections were rewritten with the streams: §0 (the DIY slots), §2 (the layout), §5.5 (the enemy damage
frame, the block-count target rule), §5.6 (the trigger rules), §8.1 / §8.3 (`welcome.diyKitted`, `standIns`, `diy`,
`diyBanned`), §16 (干员持有, 自选编队). Where each is handled: refactor and tooling → §25.1; Chinese and English →
§25.2; 补位 → §25.3; 自选编队 and the 71 operator kits → §25.4; trigger rules (the larger-range rule, AUTO skills) →
§25.5; fidelity fixes of wave 1 (GitHub #41, #49, #82, #96 / #137, #107, #116; 伺夜's 狼群; Touch) → §25.6; WE1, sim
rules (GitHub #181, #187 / #170, follow-ups) → §25.7; WE2, data / match / UI (follow-ups, GitHub #148) → §25.8; the
0.1.4 ports → §25.9; the settled decisions, what stays [ASSUMED] and the questions left to the owner → §25.10.
Decisions of the owner: 2026-10-04 — the server stays on plain `node:http` + `ws`, no web framework (§25.1);
2026-10-05 — the 补位 plan (§25.3); the 自选编队 rules, the collab operators out of the pool, ISW-A refused (§25.4);
Chinese by default with an English switch, official English game texts, PR #70's translations reused with credit
(§25.2); the larger-range trigger rule on the basic strategy, the SEARCH row and 深巡 S2, every 自选 重装 cast with an
enemy in range, 纯烬艾雅法拉 S1 at full SP, 伺夜 S1 only with his pack on the field (§25.5); 2026-10-06 — a chess the player
does not own shows as its stand-in everywhere (the owner's recall of the official mode, §25.3).
### 25.1 Refactor and tooling: the big files split, golden results, lint, types, import boundaries (refactor plan; owner's decision of 2026-10-04: no web framework) — `server/http/`, `server/match/match/`, `server/match/player/`, `server/sim/battle/`, `content/enemies/`, `kits/ops/`, `public/js/ui/gameLogic/`, `render/fx/`, `render/app/`, `screens/game/`, `tools/golden.mjs`, `tools/check-imports.mjs`, `eslint.config.js`, `jsconfig.json`
- **Why**: the files every fix touched had grown to thousands of lines (`Match.js` 3237, `enemies.js` 3162,
`Battle.js` 2465, `fx.js` 2259, `render/app.js` 2046, `gameLogic.js` 1913, `PlayerState.js` 1673, the six kit tiers
864–3362 each), and the new operator kits needed one file per operator. **Official**: none — nothing a player sees
changes (refactor plan rule 1).
- **Safety net first** (W5A, merged on feedback4 before the refactor; `test/golden/README.md`): `tools/golden.mjs` runs a
seeded corpus through the production BattleSpec path and `Match` with a `VirtualScheduler` and reduces every scenario
to a digest — families `roster` (every chess record × skill × module, every stage and enemy kind), `bonds` (every
bond at 1 and 999 layers), `fields` (every leader, 联防 with 1 and 2 helpers), `matches` (bot matches to the end), and
since 0.2.0 `standins` (§25.3) and `diy` (§25.4). `npm test` checks the fast subset, `GOLDEN_FULL=1` / `npm run
golden` everything; a refactor never changes the files, a gameplay change regenerates them in the same commit and
names every scenario that moved (the rule every 0.2.0 stream followed).
- **Now** (R1–R5, each a pure move listed in `.git-blame-ignore-revs`, then a docs commit; golden unchanged after each):
1. R1: `server/index.js` (786 lines → 110) keeps `startServer()` and the same 15 exports; the code is in
`server/http/` (config, websocket, static, media, files, buildTag, routes, common, boot). Still plain `node:http` +
`ws` (the owner's decision of 2026-10-04: no Hono); response header order, the /healthz keys, the banner and the
`/data.js` stand-in text are byte for byte the same (a scripted fingerprint of 51 requests matched).
2. R2: `Match.js` (3237 → 383) and `PlayerState.js` (1673 → 239) keep the class, the constructor and the interface
header; their 167 / 87 members moved verbatim into 16 / 9 per-concern method containers (`server/match/match/`,
`server/match/player/` — W5K added `diy.js` later; shared constants in `common.js`), installed on the prototype in
a fixed order — a name defined twice throws.
3. R3: `Battle.js` (2465 → 189) the same way into `server/sim/battle/` (131 methods, 14 containers + `util.js`);
`content/enemies.js` (3162 → 321) into `content/enemies/` (helpers, archetypes, one file per special type,
leaders) with the `KITS` order kept.
4. R4: the 129 kits of `kits/tier1..6.js` became `kits/ops/<chessId>-<codename>.js` (the code name = the charId
without `char_<n>_`; the chess id is the unique key — 锡人, 耶拉, 华法琳 are two chess each), helpers shared by a
tier in `kits/shared/tierN.js`, the registry `kits/index.js` (a guarded dynamic import per file: a broken file only
drops its own kit to the generic one); `kits/README.md` is the kit guide with the fidelity checklist. The
`tier*.js` re-export shims were removed in WE2 (§25.8.8).
5. R5: the client — `ui/gameLogic.js` (1913 → 47) and `render/fx.js` (2259 → 45) became barrels over
`ui/gameLogic/` (18 modules) and `render/fx/` (15); the field view's helpers moved to `render/app/` and the match
screen's standalone parts to `screens/game/` (same exports).
- **Tooling** (R6): `npm run lint` — a correctness-only ESLint (`eslint.config.js`; no formatter, warnings allowed);
`npm run typecheck` — `tsc --checkJs` over a first slice (`jsconfig.json`: `shared/` but `protocol.js` /
`bandBonds.js`, `sim/rng.js`, `dir.js`, `constants.js`, the typedefs of `types/core.js`; `types/README.md` says how to
widen it); `npm run check:imports` — `tools/check-imports.mjs`: `server/sim/` (served to browsers) imports neither the
match, the lobby, the entry, the net layer, `public/` nor a Node builtin, `server/match/` neither `public/` nor the
net layer; the one listed exception is `sim/nodeData.js`, the Node-only loader (`test/check-imports.test.js` pins the
list); `tools/package.mjs` (+ `--lite`) builds the release zip from a runtime allowlist (W5U documents packaging).
CI gained a tooling job (lint, imports, typecheck; once, not per matrix entry).
- **Measured**: a match now loads about 340 small script modules; over HTTP/1.1 at 100 ms latency a cold load took
13.4 s instead of 9.8 s, so docs/DEPLOY.md recommends HTTP/2 at the reverse proxy (the browser sim warms up during the
band draft, so play is not delayed).
- Tests: `test/content/kits_layout.test.js` (every `ops/` file listed once, named after its chess id and charId, one
registry key, no Node-only import, no clock or `Math.random`), `test/check-imports.test.js`, `test/version.test.js`
(reads the banner / `/healthz` from their new files), `test/docs-consistency.test.js` (reads the classes as their
façade plus method modules); `GOLDEN_FULL=1 node --test test/golden.test.js` after every refactor commit.
- Docs: docs/ARCHITECTURE.md (the map of the code after the split) and CONTRIBUTING.md (setup, tests, the fidelity
rules, conventions) are new in 0.2.0; §2 lists every file.
### 25.2 Chinese by default, an English switch (W5I, phase 1; PR #70 by @YuriRestia; the owner's decision of 2026-10-05) — `shared/i18n.js`, `shared/i18nData.js`, `tools/build-i18n.mjs`, `tools/i18n.mjs`, `public/i18n/en.json`, `data/i18n/en.json`, `public/js/ui/lang.js`, docs/I18N.md
- **Seen**: English-speaking players asked for an English interface. PR #70 (@YuriRestia) translated by replacing the
strings in place across every file, logic included; the owner did not merge it (reply on PR #70, 2026-10-05) and
asked for real i18n instead.
- **Official**: the English game texts exist in the official EN client data (the EN tables carry act2autochess with the
zh_CN ids); the YoStar repository the plan first named is archived and has no 盟约 season.
- **Decision**: the owner's decision of 2026-10-05 — Chinese stays the default (an English browser still opens in
Chinese), English is a switch; game texts from the official EN data; PR #70's translations reused with credit; no
i18n framework.
- **Now** (docs/I18N.md is the reference): UI strings go through `t('中文')` — gettext style, the Chinese text is the
message id, so an untranslated string shows Chinese, never a key (`{name}` parameters, one plural form, `tc()` for
context, `N_()` marks module-level tables); their English is `public/i18n/en.json`, seeded from PR #70's
translations. Game texts (operators, skills, talents, modules, traits, enemies, bonds, items, effects, strategies,
特质, 机变 cards, modes, stages, tokens) come from an overlay, `data/i18n/en.json`, which `tools/build-i18n.mjs` builds
by walking the zh_CN and EN tables side by side (exact, template and composite matches; fallbacks: the remake's own
strings, PR #70's, the UI table) and `public/js/data.js` applies: 97.6 % of 22 183 texts with the 自选 data (the
CN-only operators and modules are what is missing). Server toasts and ticker lines travel as `msgid` + `params`
(`wireMessage`; `text` keeps older clients working) and each client renders its own language. The switch: the title
screen (top right) and 设置, `?lang=en` once, kept in `sp.pref.lang`, re-rendered in place. Phase 1 converted the
title screen, lobby, room, settings, the app shell and every server message; `tools/i18n.mjs` (extract / check /
codemod / seed) finds what is left, and phase 2 (W5S) wraps the remaining screens.
- [ASSUMED]: the English of the remake's own strings (排气格栅 "Exhaust Grille" as PR #70 has it, the 联防 map names, the
server's own templates).
- Golden: unchanged. Tests: `test/i18n.test.js`, `test/i18n-data.test.js` (coverage floors), `test/i18n-server.test.js`,
`test/ui/i18n.e2e.test.js` (`SP_E2E=1`).
### 25.3 补位: an operator the player does not own fights as its official stand-in (W5B, W5D, W5G, K1–K3, W5N; the approved plan, the owner's decision of 2026-10-05; the owner's recall of 2026-10-06) — `tools/build-data.mjs buildBackups`, `data/backups.json`, `shared/standIn.js`, `sim/simdata.js getStandIn`, `content/index.js kitOf`, `kits/ops/standin-*.js`, `shared/protocol.js room.ownership`, `screens/ownership.js`, `ui/gameLogic/standIn.js`
- **Seen**: the official mode fills an operator the player does not own with a stand-in; the remake always fielded
the real operator, and the data build dropped the official backup fields.
- **Official**: act2autochess — 133 chess: 74 PRESET (特许, always available), 55 NORMAL (each naming a backup: charId,
template, skill, module, potential), 4 DIY; PRTS 卫戍协议 — a missing operator is filled by a prototype of its class
with a fixed skill, and the bonds and 特质 stay the chess's. The 17 stand-ins: the eight 4★ 预备干员 and nine elite
prototypes (郁金香, Sharp, Mechanist, Stormeye, Pith, Touch, Raidian, Misery, 领主·Sharp).
- **Decision**: the approved 补位 plan (2026-10-05): only the 55 NORMAL chess can be marked not owned, everything is
owned by default, no borrowing from friends, bots own everything; the 重装 stand-ins (预备干员-重装, Mechanist) cast with
an enemy in range, like §21.29. The owner's first-hand recall of 2026-10-06: the shop, the hand, the board and the
result screen show the STAND-IN — this replaced the first build's cards that kept the chess with a 「替补:X」 badge.
- **Now**: data (W5B): every chess.json record keeps `backup`; data/backups.json `units` holds the 17 stand-ins in every
form (DATA.md §18). Battle (W5D): `getChess(id, { standIn: true })` = the chess's ids, bonds and tier with the
stand-in's body (`def.standInFor`); its kit is the stand-in's own (`kits/ops/standin-<codename>.js`,
`STANDIN_KIT_FILES`), never the replaced operator's; without one, the generic kit plus `genericTalents` — the eight
预备干员 get their talents, 一击即退 for 10 s from every deployment and 冲锋号令 +12 DP; the browser battle loads
data/backups.json too. Match (W5G): C→S `room.ownership { notOwned }` (the 55 droppable chess only; fixed for the
match); `battleInput` marks `standIn: true` in every field — the rules read the chess (price, bonds, 特质, merges,
pools), the body is the stand-in's (placement by its own position, range, no summons). Client: the 干员持有 tab of
干员调配 (export / import, `sp.pref.ownership`); since W5N the stand-in is drawn everywhere — shop and reward cards,
hand, 临时整备区, board, detail card, bond popups, result lineup, bench scouting, tickers — with a small ice 「替补」 mark
and the chess's tier, price and bonds. It speaks with its own voice (the §21.30 port; a 预备干员 stays silent).
- Kits (K1–K3; every skill on every form, the numbers from the composed record, the data's trigger):
| stand-in | kit (`kits/ops/`) | stands in for | notable [ASSUMED] |
|---|---|---|---|
| Sharp | `standin-acguad.js` | 忍冬, 银灰, 百炼嘉维尔, 隐德来希, 归溟幽灵鲨, 佩佩 | — |
| 领主·Sharp | `standin-sharp2.js` | 水月, 玛恩纳, 维娜·维多利亚, 妮芙 | 陷阵勇气 counts targetable enemies; LOR-X's 流失 follows a shielded hit |
| Misery | `standin-acspec.js` | 缄默德克萨斯, 山, 新约能天使, 锏 | 四维分离 counts her own tile and air units; S3's drag area is its x-4; S1 / S2 deploy-timed duration skills (WE1) |
| Stormeye | `standin-acnipe.js` | 空弦, 莫斯提马, 百炼嘉维尔, 妮芙, 蕾缪安, 异客 | 风雨欲来's delay = no attack for 2 s, counted from deployment |
| Pith | `standin-accast.js` | 烛煌, 焰影苇草, 迷迭香, 荒芜拉普兰德 | two Piths' 授我所授 / S2 never add up |
| Raidian | `standin-acsupo.js` | 灵知, 夕, 安洁莉娜, 浊心斯卡蒂, 仇白 | 诱引 rolls at an attack's first damage instance; new `applyStatus` option `stackAs` |
| 郁金香 | `standin-acpion.js` | 瑕光, 焰尾, 凛御银灰, 圣聆初雪, 缪尔赛思, 耀骑士临光 | the 集成战略 备注 applies; S3's 8 slashes at the clip's OnAttack events |
| Mechanist | `standin-acfend.js` | 星熊, 号角, 余 | S3 pulses from 1 s after the cast |
| Touch | `standin-acmedc.js` | 塞雷娅, 塑心, 纯烬艾雅法拉 | shares the 外勤医疗 strategy's Touch code (§25.6.6); S2's 所有医疗干员 = her own player's |
- [ASSUMED]: a stand-in is placed by its own position (melee tiles for a melee stand-in); without accounts any of the 55
may be dropped, also one the player owns; AI 托管 values a dropped chess by the chess record; the detail card names
「X的替补」 in place of the English name; the 「替补」 mark's look.
- Golden: new family `standins` (10 battles: every NORMAL record, normal and elite, fielded as its stand-in) and the
match `coop2-NORMAL-14-standins` (a human seat on AI 托管 owning none of 7 NORMAL chess); nothing else moved.
- Tests: `test/backups.test.js`, `test/content/standin.test.js` (the plumbing and the eight 预备干员),
`test/content/standin_*.test.js` (the nine kits), `test/match/standin-ownership.test.js`,
`test/lobby-ownership.test.js`, `test/ui/standin-ui.test.js`, `test/golden-standins.test.js`,
`test/ui/standin.e2e.test.js` (`SP_E2E=1`).
### 25.4 自选编队: the four DIY slots field the player's own 6★, and 71 operator kits (W5H, W5K, O1–O24; the owner's decisions of 2026-10-05) — `data/backups.json diy`, `shared/diy.js`, `sim/simdata.js getDiy`, `server/match/player/diy.js`, `shared/protocol.js room.diy`, `screens/diy.js`, `kits/index.js OPERATOR_KIT_FILES`, `kits/ops/op-*.js`, `kits/shared/summoner.js`
- **Seen**: the four DIY chess were removed from the shared pool (§0). A first attempt (PR #71) had put them in the
shared pool, one chess id meaning a different operator per player — the shape to avoid.
- **Official**: act2autochess `diyChessDict` / `shopLevelDisplayDataDict` — two slots at tier 5 (from 调度中心 level 5),
two at tier 6 (level 6), price 4, sold for 1; PRTS 盟约 下半 — the player's own 6★ or a 6★ prototype, no 特质, the
core bonds its factions meet (else 协防); forms: normal = E2 Lv1 with skills at rank 4, elite = E2 Lv60 rank 7 with its
module at stage 1 (tier 5) or 3 (tier 6).
- **Decision**: the owner's decisions of 2026-10-05 — two plus two slots per player; an owned 6★ with a kit, any of its
three skills, any elite module; a prototype keeps the skill and module of its 补位 rows; a per-player shop; levels
follow the chess forms; the six collab 6★ are out (copyright), and so is 焰狐龙梓兰 (MH05, a Monster Hunter 联动寻访
operator the team list does not name: `DIY_EXCLUDED_NUMBER_PREFIXES`) — 71 owned picks; the 集成战略-only ISW-A
modules are refused (WE2 adds SO-A / SO-B / RA-A, §25.8).
- **Now**: data (W5H): data/backups.json `units` adds the 71 owned picks in the three slot forms, `tokens` their 38
summons, `diy` the slots, the prototypes (15 at tier 5, 9 at tier 6), their locked loadouts, `ownedPool` and the
derived bonds (DATA.md §18); `shared/diy.js` (`diyPool`, `validateDiyPicks`, `diyRecordOf`); the sim fields a pick
(`getDiy`; kit `KITS[charId]`). In play (W5K): C→S `room.diy { picks }` (`checkDiyPicks`; `welcome.diyKitted` lists
the operators with a kit); the player's data view makes a slotted DIY slot its operator everywhere — bonds, placement,
summons, AI 托管 — with no 特质; a per-player stock never enters the shared pool and the player's own shop draws it from
the slot's 调度中心 level; a piece whose every bond is off this match leaves the shop (`m.private.diyBanned`, shown since
WE2); a placed operator's summons go to the hand (official counts since WE2, §25.8). Client: the 自选编队 tab of
干员调配 (two tier-5 and two tier-6 slots, the picker with skill and module, export / import, `sp.pref.diy`) and a mint
「自选」 badge on cards and pieces.
- **The kits**: 推进之王 first (W5H, the worked example of kits/README.md "How to add an operator (自选)"), then O1–O24 in
three waves — every skill at ranks 4 and 7, every talent, every module at stages 1 and 3, the range while a skill
runs, triggers, targeting, damage typing, summons and statuses, per the kit checklist; potential 0 for every pick
[ASSUMED: no account]. Engine additions, each additive (only the `diy` golden family moved): `palsyTrigger` (O3,
`ai.js`: a 麻痹 stack interrupting an enemy attack), the `hpDamage` hook after the shields (O5, `damage.js`), target
priority `heaviest` and the boomerang hooks `boomerangCaught` / `boomerangOnward` (O7), shields limited to damage
types (`shieldType`, O8 / O16) and a profile's `skipEnemy` (O8), enemy shots carrying `isProjectile` and priority
`elite` (O11), a healer profile's `healThrough` (O16), the 缚地 status `groundbind` (O20), the `enemyAttackStart` hook,
the `noNewBlock` flag and `holdsUndying` reading an `undying` buff (O22), ally attack targets
(`Battle.setAllyTarget`, O23), the air-block mod `blockRadiusScale` (O24); `kits/shared/summoner.js` (O21) holds the
召唤师 deck of 麦哲伦 / 令 / 电弧. Match: a placeable summon's hand count reads the active module's variant (O19: 望's
TRP-X 7). Client: visuals for the 13 fx kinds the new kits emit (`render/fx/kinds.js`); the picks' art comes with the
asset plan (`tools/fetch-assets.mjs --add-only`).
| 干员 (summons) | kit (`kits/ops/`) | stream | notable [ASSUMED] (and later changes) |
|---|---|---|---|
| 推进之王 | `op-siege.js` | W5H | S2 跃空锤 reaches ground enemies only (no source says air); SOL-Y's extra SP goes to a random deployed other 【先锋】 |
| 陈 | `op-chen.js` | O4 | S3's ten slashes = the first ten of the clip's eleven OnAttack events; S3 refused with no ground enemy in its 技能范围 |
| 艾丽妮 | `op-irene.js` | O4 | S3's first bombardment 0.3 s after the opening strike, an empty one skipped; ISW-A refused in 自选 (W5K) |
| 赫拉格 | `op-helage.js` | O4 | 运筹帷幄 is the 生命回复速度 attribute (no PRTS 备注); the 武者 heal per damage instance (WE1) |
| 闪灵 | `op-shining.js` | O8 | S2's DEF bonus ends with her last barrier on that unit; PHY-X heals ×1.15 at or below 50 % (WE1) |
| 夜莺 (幻影) | `op-cgbird.js` | O8 | a held 幻影 returns after 20 s for 5 DP whether or not 夜莺 stands; 3 hand pieces (WE2) |
| 嵯峨 | `op-saga.js` | O8 | 清明 is a 生命回复速度 (hpRegenRatio) buff; SOL-Y stage 3 takes the blackboard's 6.5 % (the text says 6 %) |
| 艾雅法拉 | `op-amgoat.js` | O2 | S3's lava has no splash; 乱火 maxima 15 / 15; S3 now casts by the larger-range rule (「攻击范围增大」) |
| 刻俄柏 | `op-cerber.js` | O2 | 剥壳's ramp restarts per deployment |
| 霍尔海雅 | `op-heyak.js` | O2 | S3's whirlwinds hit air units, nearest first; one not yet released drops when she leaves; ISW-A refused |
| 早露 | `op-poca.js` | O7 | S3's strikes are her attacks, the first one tick after the cast; SIE-Y's distance bonus is linear |
| 提丰 | `op-typhon.js` | O7 | S3's arrows are skill damage, not attacks (no SIE-X / SIE-Y bonus) |
| 娜仁图亚 | `op-narant.js` | O7 | S1 (切换) stays on until she leaves; S3's boomerangs fly together and its blast is no attack |
| 逻各斯 | `op-logos.js` | O3 | 语汇演化 rolls once per attacked target and may draw the attacked enemy |
| 真言 | `op-mantra.js` | O3 | S2's first shot keeps the engine's bolt speed 11 (备注 10); only operators starting a skill grant 麻痹 |
| 维伊 | `op-veen.js` | O3 | 战争技艺 restarts its 5 s with every stack; her energy store is now the engine's 秘术师 rule (WE1) |
| 左乐 | `op-zuole.js` | O5 | S3's 7 slashes resolve at the cast and reach air units |
| 重岳 | `op-chyue.js` | O5 | S2's second segment always fires; 止戈 rolls only on hits that leave the target alive |
| 煌 | `op-huang.js` | O5 | S3's cuts deal 100 % ATK once a second (no blackboard key); the card keeps her 1-1 (the game shows 3-6) |
| 黑 | `op-shwaz.js` | O6 | 正前方 = her straight line ahead, her own tile included; S3 DEFAULT (「攻击范围改为」 is a range change) |
| 鸿雪 (打字机) | `op-bgsnow.js` | O6 | the 打字机 returns only while 鸿雪 stands and keeps its 25 s when she falls |
| 伊芙利特 | `op-ifrit.js` | O1 | BLA-Δ's extra 元素伤害 also follows 灼地 ticks (`_targetFamilyMask` 31); 灼地's 2 % 流失 as stated |
| 谬因 (中继器) | `op-aphris.js` | O1 | a huge enemy counts on every tile it covers; the 中继器's 无敌 / 孤立 / 禁疗 are kit flags; S2 area trigger (WE2) |
| 林 | `op-lin.js` | O1 | the 琉璃璧 sits in the buff order (an earlier shield absorbs first); its burst comes before a knock-out |
| 年 | `op-nian.js` | O14 | PRO-Y counts a teammate's 重装 on a shared field; 铁御 skips summons |
| 斩业星熊 | `op-hsgma2.js` | O14 | AST-X's extra damage with or without a running skill; 我执 on the engine's 1-HP floor; normal attack physical (WE2) |
| 珊比 | `op-thumpy.js` | O14 | the conveyor moves 0.533 tile/s (20 frames read per game second); S2 / S3 cast by the area rule |
| 森蚺 | `op-zumama.js` | O15 | a blockee turning 无敌 + 无法选中 loses the S2 stun after one pulse; RA-A refused in 自选 (WE2) |
| 斥罪 | `op-judge.js` | O15 | her barrier is spent oldest first (the client's priority −1000 would put it last); the thorns cannot be dodged |
| 黍 | `op-shu.js` | O15 | the 【播种标记】 and the teleport end with S3; S3's TRY_SEARCH_ALLY_SKILL is now resolved in the data (WE2) |
| 帕拉斯 | `op-pallas.js` | O9 | 休谟斯's own 精力充沛 is not merged with hers; S2 casts by the larger-range rule (「向前延伸」) |
| 止颂 | `op-lessng.js` | O9 | S3 casts nothing under 晕眩 / 冻结 / 沉睡; S2's strike spacing is not modelled |
| 贝洛内 (牵绊) | `op-demetr.js` | O9 | several 贝洛内 on one enemy share one 【手段】; every move is instant |
| W (此面向敌) | `op-cqbw.js` | O12 | no cap on mines; 此面向敌 cannot be targeted; her knock-out withdraws the mines |
| 菲亚梅塔 | `op-phenxi.js` | O12 | S1 follows the current client (no air targets, no range growth, trigger DEFAULT — PRTS 备注 of 2026-08-01) |
| 维什戴尔 (魂灵之影) | `op-wisdel.js` | O12 | aftershocks 0.3 s apart; S3's last shell loses the +ATK (the engine's ammo rule) |
| 棘刺 | `op-thorns.js` | O10 | the spikes carry the LOR-X addition; his poison keeps ticking after he left |
| 赤刃明霄陈 | `op-chen3.js` | O10 | the first slash at the cast; a hiding target counts as gone; the 【移动】 lands at once |
| 司霆惊蛰 | `op-leizi2.js` | O10 | currents outlive the skill, bounce off the field edge, use screen directions; S1 by the larger-range rule |
| 薇薇安娜 | `op-vvana.js` | O11 | enemy content projectiles carry no `isProjectile` (melee for 散华); S3 area trigger (WE2) |
| 怒潮凛冬 | `op-headb2.js` | O11 | the hammers are skill damage, lost while she is stunned; 高台 = the HIGH tiles of the field |
| 赫德雷 | `op-hodrer.js` | O11 | S3's 流失 may knock him out; attack riders land even on a dodged hit |
| 假日威龙陈 | `op-chen2.js` | O13 | the slime shot's selector range is her attack range at the attack; it outlives her |
| 莱伊 (沙地兽) | `op-ray.js` | O13 | the 沙地兽 scouts 25 s, attacks nothing, returns after its redeploy time for 3 DP while she stands |
| 阿斯卡纶 | `op-ascln.js` | O13 | 高台 = the level's HIGHLAND tiles (the forbidden rim too); separate 命中率 cuts roll apart |
| 涤火杰西卡 (机动盾牌) | `op-jesca2.js` | O16 | a 机动盾牌 not next to her gets no turn, mark or aura |
| 机械师 (结构性原理) | `op-mcnist.js` | O16 | the S3 charge lands free of DP; `_ignoreTriggerOnce` read as nothing |
| 凯尔希 (Mon3tr) | `op-kalts.js` | O16 | S2 / S3 are heal skills on DEFAULT (no injured ally, no cast); PHY-X Lv3's half-HP blast once per deployment |
| 琴柳 | `op-sleach.js` | O17 | 精神感召 saves DP only on a redeploy (the battle-start deployment is free here) |
| 可露希尔 (指挥中心) | `op-closur.js` | O17 | 部署费用下限 has no counterpart (costs never drop below 0); her own tile counts beside the 指挥中心 |
| 澄闪 | `op-gdglow.js` | O17 | her own attack stops in every skill (all drones out); S2 fires at full SP |
| 阿 | `op-haak.js` | O18 | S2 / S3 keep the data's DEFAULT: with no ally in range the cast does nothing (owner question) |
| 傀影 (镜中虚影) | `op-phatom.js` | O18 | S3's push and status land on a dodged hit too; the twin is readied at his redeploy (WE1) |
| 弑君者 | `op-crosly.js` | O18 | S2's blast lands at once; any action-stopping state or 缴械 cancels it |
| 老鲤 | `op-lmlee.js` | O19 | 周围八格 = the 3 × 3 with his tile, flyers counted |
| 温蒂 (工程蓄水炮) | `op-weedy.js` | O19 | S2 reaches air units; the cannon's forced skill fires once per deployment (data DEFAULT, re-checked in WE2) |
| 望 (棋子, 跟子) | `op-wang.js` | O19 | 跟子 are kit records, no unit; S1 / S2 add one 棋子 once, then 阻回 (owner question); 棋子 on any tile (WE2) |
| 多萝西 (共振装置) | `op-doroth.js` | O20 | a used-up piece returns 5 s after the LAST trap deployment; the hand holds at most 9 |
| 予愿安洁莉娜 (“一会儿见!”) | `op-aglna2.js` | O20 | flight 2 tiles/s, landing clips not modelled; the marker is no hand card (WE2); new status 缚地 |
| 黑键 (旧日残影) | `op-ebnhlz.js` | O20 | his energies land with the main bolt; S2 at full SP with a free tile (WE2) |
| 麦哲伦 (龙腾) | `op-mgllan.js` | O21 | 龙腾.F makes no attack (ATK 0); 3 summon pieces, 4 with SUM-Y (WE2) |
| 令 (清平 / 逍遥 / 弦惊) | `op-ling.js` | O21 | her summons strike during S2 even when stunned; 3 pieces (WE2) |
| 电弧 (戴乌 / 赛柯 / 桑特拉) | `op-radian.js` | O21 | 桑特拉's link is its own attack with its own ATK; SO-A / SO-B refused in 自选 (WE2) |
| 淬羽赫默 (夜灯) | `op-slent2.js` | O22 | 丰润羽翼 as 生命回复速度 rests on the client template (owner question) |
| 遥 | `op-haruka.js` | O22 | her bubbles outlast her; the T2 heal uses the ATK of the grant (owner question) |
| 酒神 (本能的召唤, 迷狂牢笼) | `op-phatm2.js` | O22 | 本能的召唤 lures at most 4 in all; its redeploys cost no DP |
| 白铁 (支援装置, 铁钳号) | `op-ironmn.js` | O23 | the 铁钳号 is charged only by attacks that select it; 2 devices in hand (WE2) |
| 娜斯提 (支援装置) | `op-nasti.js` | O23 | the built 高台's operator bonus (m4–m6_bonus) never applies |
| 死芒 (悲叹的仆役) | `op-necras.js` | O23 | the servant consumed is the earliest made; S3's strikes are skill damage |
| 凯尔希·思衡托 (战术锚点) | `op-kalts2.js` | O24 | S2's shot flies at the engine's orb speed 10 (official 12); 战术锚点 placement (WE2) |
| Mon3tr (重构体) | `op-monstr.js` | O24 | the 路标形态 marker is not placed; 重构体 placement (WE2); the chain heal jumps in the 3×3 (WE1) |
- [ASSUMED] (besides the table): the per-player stock, 8 at tier 5 and 5 at tier 6 (no table gives it); bots field no
自选 piece; 信标 sends no 自选 piece to a teammate; a prototype in a tier-5 and a tier-6 slot counts as two bond members;
the reward offers' temporary refresh draws the player's 自选 pieces from the slot's level on.
- Golden: new family `diy` (138 battles: every kitted owned operator in each form × module × skill at tiers 5 and 6,
then the prototypes) and the match `coop2-NORMAL-70-diy` (a human seat on AI 托管 slotting 推进之王 and prototypes);
each kit wave added its own scenarios, no other family moved.
- Tests: `test/backups.test.js`, `test/diy.test.js`, `test/content/diy.test.js`, `test/content/kits_layout.test.js`,
`test/content/op_*.test.js` (71: one per kit, every form × module × skill), `test/match/diy-shop.test.js`,
`test/match/diy-bonds.test.js`, `test/lobby-diy.test.js`, `test/ui/diy-ui.test.js`, `test/golden-diy.test.js`,
`test/assets-diy.test.js`, `test/ui/diy.e2e.test.js` (`SP_E2E=1`).
### 25.5 Trigger rules: a larger running range casts as soon as an enemy is inside it; AUTO skills that need no target fire at full SP (W5C, W5F, the kit waves; the owner's decisions of 2026-10-05) — `tools/build-data.mjs resolveTrigger / ACTIVE_RANGE_OVER / ATTACK_RANGE_CHANGE / UNIT_TRIGGER_CORRECTIONS / TRIGGER_ALLY_RULES`, `sim/skills.js setTrigger`, §5.6
- **Seen** (community report): a skill that enlarges the attack range waited for an enemy inside the normal range,
as the official basic strategy does (§20.2); the 0.2.0 audit after GitHub #124 found 35 AUTO skills among the 112
visible chess that never fired alone with full SP and no enemy.
- **Official**: PRTS 卫戍协议/帮助 技能策略 — DEFAULT casts on an enemy in the initial range; SEARCH on an enemy in the
initial range; AUTO skills take no strategy row, so DEFAULT waits for the unit's own attack.
- **Decision and now** (the normative text is §5.6):
1. **ACTIVE_RANGE** — the owner's deliberate deviation of 2026-10-05, like §21.29: a MANUAL skill whose running
range strictly contains the operator's own checks the DEFAULT condition on that running range every tick, no
attack needed (`rawRule` keeps the official row). First on the basic strategy (W5C: 33 skills / 66 records, e.g.
野鬃 S2, 银灰 S3, 莫斯提马 S3), then the SEARCH row and 深巡 S2 (W5F: 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2, 安洁莉娜 S3,
深巡 S2 — 39 skills / 78 records). The wording of a range change was widened as the kit reports found misses:
「攻击范围增大」 (艾雅法拉 S3), 「攻击范围改为」 (黑 S3: DEFAULT, not a 技能范围), 「攻击范围向前延伸」 / 「攻击范围朝…扩大」 (帕拉斯 S2,
司霆惊蛰 S1); `UNIT_TRIGGER_CORRECTIONS` follows a live-client change the text misses (菲亚梅塔 S1 is DEFAULT since the
2026-08-01 client, PRTS 备注: no range growth, no air targets). A range the data cannot express is set by the kit:
珊比 S2 / S3 (her tile plus the glue / conveyor area), 谬因 S2 (the beam path, `addTriggerRange`), 薇薇安娜 S3 (3-2
from her second cast, the new `skill.setTrigger`).
2. **重装 of data/backups.json** — the owner's decisions of 2026-10-05: the 重装 stand-ins and every 自选 重装 (森蚺,
涤火杰西卡, 斩业星熊, 年, 黍, 斥罪, 机械师, 珊比; 48 skill forms) cast with an enemy in range — DEFAULT, or ACTIVE_RANGE
when the running range is larger — instead of the class row's TAKE_DAMAGE.
3. **AUTO skills that need no target** fire at full SP (`SP_FULL`; kits/README.md checklist item 5): 浊心斯卡蒂 S2 and
伺夜 S2 (W5C, after GitHub #124's 引星棘刺 S1), 纯烬艾雅法拉 S1 (the owner's decision of 2026-10-05, W5F), and in the 自选
kits every AUTO skill that acts on the operator itself, its allies or its summons (e.g. 谬因 S1, 维伊 S1, 莱伊 S2,
澄闪 S2, 多萝西's three). Skills that need a target keep waiting (a "next attack / next heal" skill, attack buffs).
伺夜 S1 / S2 cast only while his pack is on the field (PRTS 备注; the owner's decision of 2026-10-05 for S1, W5F; the
战术点形态, §25.6.5 and §25.7.9), 黑键 S2 only with a free deployable tile in range (§25.8.4).
4. Data aliases: 黍 S3's TRY_SEARCH_ALLY_SKILL resolves to SKILL_RANGE with `allies` on its x-2 (`TRIGGER_ALLY_RULES`,
§25.8.4).
- Kept on their rows: 烛煌 S2 / S3 (their ranges do not contain her 3-1), TAKE_DAMAGE 重装 skills of the chess pool but
the §21.29 six, CUSTOM_RANGE skills, AUTO skills that need a target, SEARCH skills whose range does not grow.
- Golden: each step names its scenarios in its commit (the SEARCH / 深巡 step: roster-001 / 002 / 016 / 026 / 027 / 041
/ 042 / 044, bond-egirShip-low, bond-suntShip-high, boss-boss_2-pair, boss-boss_6-pair and the bot matches fielding
them; 纯烬艾雅法拉 S1: roster-038; 伺夜 S1: roster-028; the range-wording fixes: only `diy`).
- Tests: `test/sim/feedback5-active-range.test.js`, `test/sim/feedback1-tank-triggers.test.js`,
`test/sim/feedback1e-skillrange.test.js`, `test/data.test.js` (the counts), `test/content/kits_alt_t3.test.js`
(伺夜), `test/content/kits_alt_t6.test.js` (浊心斯卡蒂, 纯烬艾雅法拉), `test/content/feedback5-we2-area-triggers.test.js`.
### 25.6 Fidelity fixes, wave 1 (W5C, W5E, W5J, W5L)
#### 25.6.1 琳琅诗怀雅 S2 “见面礼” throws onto an enemy's tile on x-6, with no enemy needed (GitHub #116 item 2) — `kits/ops/chess_char_3_04-swire2.js` (installS2)
- **Seen**: the bomb landed only in front of her or nowhere. **Official**: PRTS S2 备注 — bomb range x-6; an enemy in range
gets it on its tile, else a random deployable tile (the client needs a free x-6 tile and her coin buff). **Now**: with a
coin and a free x-6 tile her attack turn throws: onto the first ground enemy's tile in her target order, else a random
free tile (in the field, empty, walkable melee ground, not a knocked-out operator's tile); damage, 停顿 and the 3 s
second hit unchanged. Item 1 of #116 (the solo 标准 shop prices) is not a bug. [ASSUMED]: the throw is instant and costs
one tick of her attack. Tests: `test/content/kits_t3.test.js` (3 cases), `summon_loadout_conflicts.test.js`.
#### 25.6.2 新硎 / 沉沙: one ATK layer while a 断刃 is held, gone with the last one (GitHub #107) — `content/enemies/times.js` (kitBlades)
- **Seen**: 新硎's ATK kept climbing (1350 → 3510 after two hits). **Official**: PRTS talent — ATK +80 % (沉沙's
blackboard 0.7) while holding 4 【断刃】, one spent per attack; the attack spending the last drops the bonus and clears
its cooldown; unspent blades become 矛头 on death (at least 1). **Now**: one layer from the spawn, removed by the
attack that spends the 4th blade, which is followed at once by the next (unbuffed) attack; death spawns max(1,
unspent) 矛头 at once (the 0.7 s delay is not modelled). Tests: `test/content/enemies_bosses.test.js`.
#### 25.6.3 A full 整备区 buys nothing; a freed slot pulls the 临时整备区 in (GitHub #82, second part, reported by Hucxious) — `PlayerState.buy / pickReward / _fillHandFromTemp`, `match/invariants.js`, `match/bot.js`
- **Seen**: a full hand still bought a third copy (it merged), and a freed slot did not pull the overflowed operator
back. **Official**: PRTS 卫戍协议/帮助 §手牌区 — with the 10 regular slots full, 招募 / 购入 are refused, merge or not;
temp pieces move in when a slot frees; board ↔ hand swaps stay allowed. **Now**: `buy` / `pickReward` refuse with
`HAND_FULL` (client: 整备区已满); every `recompute()` moves temp pieces into free slots (invariant: temp is non-empty
only while the hand is full); bots never buy into a full hand and shed their weakest single for a third copy.
[ASSUMED]: temp empties right → left into the hand's next free slot right → left. Golden: coop2-NORMAL-3,
solo-HARD-9-boosted, coop2-ABYSS-10-boosted. Tests: `test/match/feedback5-hand.test.js`.
#### 25.6.4 联防 plays on its own official map (GitHub #41 item 3, reported by Hucxious and shanzhaikabi) — `tools/build-data.mjs buildStages`, data/stages.json `kind: 'unite'`, `unite.js uniteStageId`, `match/unitePhase.js _uniteOpts`
- **Seen**: the 联防 field showed the round's battlefield (water pools, crates) opened to both halves. **Official**:
act2autochess `level_act1autochess_escaped_single` / `_multi` — one 19 × 21 road grid, no devices, runes or terrain;
the right half is the own board + 8 columns. **Now**: two `kind: 'unite'` stage records (1 / 2 helpers, `active:
false`) carry the 联防 battle in both combat modes; the client draws that map and restores the board after. No water,
crates, devices or terrain in 联防; the BGM (PR #110) is unchanged. [ASSUMED]: the records' names 联防阵地(1名玩家) /
(2名玩家). Golden: unite-1, unite-2 and the co-op matches. Tests: `test/match/feedback5-unite-map.test.js`.
#### 25.6.5 伺夜's 狼群 has the official 战术点形态; S1 acts by the pack's state (PRTS 备注; the owner's decision of 2026-10-05) — `content/tokens.js wolfPack / installWolfTacticalPoint / wolfReturnNow`, `kits/ops/chess_char_3_19-vigil.js`
- **Seen**: a pack losing its last 狼影 died and came back after 10 s with 2 狼影; S1 fired and paid its DP with no pack
on the field. **Official**: PRTS 伺夜 talent and 狼群 备注 — the last 狼影 lost or a 撤退 turns the pack into its
战术点形态 (无敌, no attack) for the 25 s talent interval, then back with 1 狼影; S1 casts only with the pack on the field:
① the form returns at once, ② +1 狼影, ③ at the cap HP to max. **Now**: `installWolfTacticalPoint` keeps a 'killed' /
'retreat' pack as a knocked-out piece (tile reserved) while 伺夜 stands, back after 25 s at full HP with 1 狼影; S1 is
the kit's own cast in either form (+cost DP, ① ② ③). S2 in the form: §25.7.9. [ASSUMED]: the return is a redeploy (仇恨
order, deploy hooks); a form at the combat's end enters 联防 fresh. Golden: roster-007 / 009 / 026 / 028 / 040,
bond-siracusaShip-high and the bot matches fielding 伺夜. Tests: `test/content/kits_alt_t3.test.js`, `kits_t3`,
`tokens_devices`.
#### 25.6.6 Touch: S3's extra heal, 「低于」 half HP, 攫升 on every heal, 超脱 on a dying exit (review of the stand-in kits) — `content/tokens.js touchGospel / mapCharTalents`, `kits/ops/standin-acmedc.js`
- **Seen**: S3's extra heal copied the boosted main heal; the boost applied at exactly 50 %; 攫升 missed self / zero
heals; 超脱 missed 余烬 exits. **Official**: PRTS Touch (卫戍协议) — the extra heal is 30 % of the main heal on x-5,
lowest ratio first, boosted itself on units 「低于」 half HP; 攫升 3 SP for any heal; 超脱 5 SP on dying exits too.
**Now**: the extra heal = `battle.heal(Touch, recipient, base × 0.3)` once per heal action with its own boost; the boost
strictly below `hp_ratio`; 攫升 on every heal she outputs; 超脱 on a knock-out or `Battle.retreat(…, { dying: true })`
(the 外勤医疗 strategy's Touch shares the code). [ASSUMED]: equal ratios go to the main target; 骑士戒律 + 竞技旗's exit
counts as dying. Golden: roster-041, standin-03 / 04 / 08 / 09 / 10, coop2-NORMAL-14-standins. Tests:
`test/content/standin_acmedc.test.js`, `tokens_devices.test.js`.
#### 25.6.7 突袭: a passive skill that is on counts as 技能就绪 (GitHub #49) — `content/bonds/addon/battle.js raidPoll`
- **Seen**: 突袭 members with passive skills (缄默德克萨斯, 宴, 斯卡蒂 S2 …) waited for the 10 s idle trigger. **Official**:
PRTS 【突袭】 — idle 10 s or 技能就绪, no enemy in range → redeploy beside a ground enemy; nothing on passives. **Now**:
`raidPoll` counts a passive that is on, and since the PR #109 port a deploy-timed duration skill while its window runs
(§25.9); §22.2's landing rule kept. [ASSUMED]: 「技能就绪」 = the skill is running. Golden: roster-039 / 046,
bond-raidShip-low / high, boss-boss_2 / 4 / 6-pair, standin-01 / 06, coop2-HARD-4, coop2-NORMAL-14-standins. Tests:
`test/content/bonds_addon.test.js`.
#### 25.6.8 生命回复速度 is not 治疗 (GitHub #96 / #137; the separation proposed in PR #135 by @SpiritedAwayCN) — `sim/damage.js` (heal `regen`), `professions.js bardRegen`, kits of 魔王 ×2, 浊心斯卡蒂 (+ 海嗣), 调香师, 瑕光, 铃兰, 锡人
- **Seen**: per-second HP effects were heals, refused on 无法被友方治疗 / 禁疗 units (#96: 隐德来希 got nothing from 浊心斯卡蒂;
#137: 折桠 and 圣约送葬人 from 调香师). **Official**: PRTS 吟游者 raises 生命回复速度; the 备注 of 调香师, 瑕光 S2, 铃兰 S3, 锡人 S2 —
unaffected by 治疗加成 and 禁疗. **Now**: hpRegen buffs the provider refreshes, applied as the target's own regen tick (no
healing multiplier, no heal hook): the 吟游者 trait (`bardRegen`), 调香师, 瑕光 S2, 铃兰 S3, 锡人; direct heals unchanged
(引星棘刺 S1 / S2, 塞雷娅 S3, 流明's and 纯烬's HoTs stay heals). kits/README.md checklist item 11. [ASSUMED]: the buffs
refresh from the start; two providers add up. Golden: 50 scenarios that field a converted operator (listed in the
merge). Tests: `test/content/feedback5-regen.test.js`, the kits' tests, `test/sim/professions.test.js`.
### 25.7 WE1 — engine-fidelity follow-ups (sim rules)
#### 25.7.1 秘术师 store energy at the attack check, not one interval later (GitHub #181, reported by Paper-Yuan) — `professions.js installMystic` (`storeEnergy` / `releaseEnergy` / `hitsFn`), `ai.js updateAlly / performAttack`
- **Seen**: 深靛 stood 4.2 s after binding the only enemy; with no enemy the first energy came after 6 s. **Official**:
PRTS 分支特性信息 §秘术师 — storing shares the attack interval: at the check, a target → attack; none and energy not full
→ store (an attack action); idle only when full. **Now**: `updateAlly` stores at the check when nothing is valid and
restarts the interval; `releaseEnergy` spends the energies with the next attack, one arts hit each; 维伊 and 黑键 use
the shared check. [ASSUMED]: a redeployment holds no energy. Golden: roster-003 / 021 / 022, bond-preciShip-high,
diy-032, diy-114 and 13 matches fielding 深靛. Tests: `test/sim/feedback5-mystic-store.test.js`.
#### 25.7.2 Enemy attacks strike at their damage frame; a short stun before it cuts the swing (GitHub #187 point 1, #170) — `ai.js updateEnemy / enemyAttack / attackWindup / attackTargets`, `battle/spawns.js` (`enemy.swing`)
- **Seen**: short stuns never stopped an enemy attack (忍冬 S3's stun showed, the hit still landed): a stun only paused
the cooldown. **Official**: PRTS 异常效果 — STUNNED cannot attack, DISARMED interrupts; the damage lands at the clip's
strike frame (data/enemies.json `attackAnim.hit`). **Now**: an enemy with an attack clip strikes at its damage frame;
stun / freeze / sleep / 浮空 / 缴械 / 恐惧 or losing every target before it cut the swing (restart from the wind-up), a
stun in the cooldown only pauses it, shots in flight land; 36 of 249 kinds without a clip keep the old rule (§5.5;
withdraws §22.14's [ASSUMED]). [ASSUMED]: targets re-taken at the frame; an interrupted swing keeps its cooldown.
Golden: 275 of 283 scenarios moved (solo-HARD-2 now wins, coop2-NORMAL-8-serverrun loses in round 13). Tests:
`test/sim/feedback5-enemy-swing.test.js`, `feedback2-enemy-attack-stand.test.js`.
#### 25.7.3 武者: the trait heal on every damage instance, through the own 禁疗 (follow-up 1) — `professions.js installMushaHeal / installReaperHeal`, `kits/ops/op-helage.js`, `op-zuole.js`
- **Seen**: a 武者 healed once per enemy struck (a double strike healed once). **Official**: PRTS §武者 — 常态持有禁疗,
ignored by the unit's own trait / talent / skill heals; the trait heal follows every damage output. **Now**: one heal
per damage instance dealt to an enemy (skill, item and bond damage too; not dodged hits, 流失, element 损伤, talent or
DoT damage), passing `ignoreHealFree`; 收割者 keeps its own rule. Golden: diy-007 / 008 / 009 (赫拉格), diy-033 / 034 /
035 (左乐); 宴 unchanged. Tests: `test/sim/feedback5-musha-heal.test.js`.
#### 25.7.4 庇护 is one effect per unit, the strongest counts (follow-up 18) — `kits/shared/tier1.js PROTECT / holdProtect`, kits 宴, 余, 缪尔赛思, 赫拉格, 左乐, 赫德雷
- **Seen**: the older kits used private 庇护 multipliers, so two sources multiplied (25 % + 18 % → 38.5 %). **Official**:
ba.protect — same-name 庇护 take the highest (遥's bubbles use another key). **Now**: `holdProtect` applies the shared
`PROTECT` effect (`applyStrongest`), refreshed by each kit; the 自选 kits already did. Golden: unchanged. Tests:
`test/content/feedback5-protect.test.js`.
#### 25.7.5 "治疗生命值低于50%…" compares as each module's client buff does (follow-up 19) — `kits/shared/tier4.js installLowHpHealBonus`, `kits/shared/tier5.js lowHpHealUp`
- **Official**: the client module buffs — LT for 瑕光 / 古米 / 塞雷娅 / 录武官 / Touch, LE for 华法琳 / 闪灵 / 黍 / 凯尔希.
**Now**: 华法琳's and 闪灵's PHY-X heal ×1.15 at or below 50 % (不高于), the LT ones strictly below; 黍 and 凯尔希 were
already ≤. Golden: unchanged. Tests: `test/content/feedback5-lowhp-heal.test.js`.
#### 25.7.6 链愈师: the heal jumps within the 3×3 around the last one healed (follow-up 27) — `ai.js chainHealNext / doHeal`, `kits/ops/op-monstr.js`
- **Seen**: 莎草's chain jumped within 2.5 tiles, Mon3tr's kit within the 3×3. **Official**: PRTS §链愈师 — jump range x-4
(3×3), no repeat, ×75 % per jump, lowest HP ratio first then the latest deployed, full-HP allies selectable, bound by
禁疗. **Now**: `chainHealNext` serves the profession and Mon3tr. [ASSUMED]: a 禁疗 unit is never a jump target. Golden:
roster-004 / 005 / 023 / 024, bond-sargonShip-low, diy-133 / 134. Tests: `test/sim/feedback5-chain-heal.test.js`.
#### 25.7.7 One reading of charge_token[born]: the owner's redeployment readies a waiting summon (follow-up 20) — `kits/ops/op-phatom.js`, `op-kalts.js`
- **Official**: the client buff template — `charge_token[born]` on the owner's birth (NORMAL timing); the other summoner
kits read it so. **Now**: 傀影's (re)deployment readies a waiting 镜中虚影 at once, as 凯尔希's does Mon3tr. [ASSUMED]:
NORMAL = usable now. Golden: unchanged. Tests: `test/content/feedback5-summon-recharge.test.js`.
#### 25.7.8 Misery's S1 / S2 are deploy-timed duration skills (follow-up 12) — `kits/ops/standin-acspec.js`
- **Now**: 物理的服从 / 战争的恭顺 (PASSIVE / ON_DEPLOY, 10 s) are `kind: 'duration'` with `activateOnDeploy` — a start and an
end at every deployment, the PR #109 contract; numbers unchanged. Golden: standin-01, standin-06. Tests:
`test/content/standin_acspec.test.js`.
#### 25.7.9 伺夜 S2 casts while the pack is in its 战术点形态 (follow-up 2) — `kits/ops/chess_char_3_19-vigil.js` (installGift)
- **Now**: S2 casts when ready while the pack is on the field in either form and holds no unused gift; in the form it
pays its DP at once and the gift waits for the returning pack. [ASSUMED]: S2's 备注 uses S1's words, so the form counts
as on the field. Golden: unchanged. Tests: `test/content/kits_alt_t3.test.js`.
#### 25.7.10 「同时攻击阻挡的所有敌人」 takes up to the block count of targets (the queued centurion question; Mon3tr S3) — `ai.js acquireTargets / targetCount` (`hitAllBlocked`)
- **Official**: PRTS §强攻手 / 重剑手 / 推击手 — max targets = the block count (≥ 1), blocked enemies first; the client
flag `_limitedMaxTargetNumToBlockedCnt`. **Now**: an attack with `hitAllBlocked` takes max(1, ⌊block count⌋) +
`maxTargets` mods targets from its range, blocked first (§5.5); Mon3tr S3 drops its `maxTargets: 1`. [ASSUMED]: the
mods add to the block count. Golden: 52 scenarios with such an attacker. Tests:
`test/sim/feedback5-block-targets.test.js`.
### 25.8 WE2 — fidelity follow-ups (data, match, UI)
#### 25.8.1 Summon hand counts from the tokens' own talents (follow-up 21) — `tools/build-data.mjs tokenTalentDeckBonus`, `match/player/diy.js placeableTokens`
- **Seen**: placing 麦哲伦 or 令 gave one summon piece, 白铁 one device, 夜莺 one 幻影. **Official**: PRTS 卫戍协议/帮助
§战斗部署 — the hand gets as many summons as the deploy limit, not the initial stock; the owners' talents give 3 at once /
5 held (麦哲伦, 令, 电弧), 2 / 3 (白铁, 娜斯提), 幻影 3. **Now**: the token talents' `max_deploy_count` /
`max_deck_stack_cnt` join the variant stats; hand counts 麦哲伦 / 令 3 (4 with SUM-Y), 电弧 3, 白铁 2, 夜莺 3, 娜斯提 2, 望 6
(TRP-X 7), 多萝西 9 (the hand's cap), others 1. Golden: unchanged. Tests:
`test/content/feedback5-we2-summon-hand.test.js`.
#### 25.8.2 望's 棋子 on any tile; no hand card for 予愿安洁莉娜's “一会儿见!” (follow-ups 22, 24) — `tools/build-data.mjs TOKEN_POSITION_CORRECTIONS / summonRecord`
- **Official**: PRTS 棋子 部署位置 全部位 (its 备注: the in-game summon info is wrong); PRTS “一会儿见!” is not selectable and
her `displayTokenDict` is empty. **Now**: the 棋子's `position` is ALL (a 高台 too); a summon is `placeable` only when an
owner displays it, so the marker stays out of the hand (the kit places it). Golden: unchanged. Tests:
`test/match/feedback5-we2-placement.test.js`, `test/content/op_aglna2.test.js`.
#### 25.8.3 Where 重构体 and 战术锚点 may stand (follow-up 26) — `tools/build-data.mjs summonRecord`, `match/board.js positionClass / canPlace`, `match/player/placement.js _legal / summonExcluded / _liftOutOfRange`
- **Official**: Mon3tr's 自我修复 — 重构体 on the ground inside her attack range; PRTS 战术锚点 — only ranged tiles outside
凯尔希·思衡托's attack range. **Now**: `ownerRange` from the owner's talent, new `ownerRangeOutside` / `rangedTilesOnly`;
an owner turned or promoted so that her range covers the 战术锚点 sends it back with a toast; client and bot follow.
[ASSUMED]: "ranged operators may stand on melee tiles" does not widen the 战术锚点. Tests:
`test/match/feedback5-we2-placement.test.js`.
#### 25.8.4 Triggers re-checked: 黑键 S2 at full SP with one free tile; 黍 S3's ally rule in the data (follow-ups 23, 17) — `kits/ops/op-ebnhlz.js`, `tools/build-data.mjs TRIGGER_ALLY_RULES`, `sim/simdata.js normalizeSkill`
- **Official**: 黑键 S2 (AUTO) 备注 — no deployable tile in range → no cast, so the condition is a free tile, not an enemy;
act2autochess `TRY_SEARCH_ALLY_SKILL` (黍 S3) = an injured ally on the skill range. **Now**: 黑键 S2 is the kit's cast at
full SP with a free attack-range tile; 黍 S3 resolves to SKILL_RANGE + `allies` in the data (the kit override is gone);
温蒂's cannon (DEFAULT) and 凯尔希·思衡托 S2 (ACTIVE_RANGE on an injured ally) stay. Golden: diy-114 / 115 / 116. Tests:
`test/content/op_ebnhlz.test.js`, `op_shu.test.js`.
#### 25.8.5 Kit-side larger-range triggers: 谬因 S2 and 薇薇安娜 S3 (follow-up 16) — `kits/ops/op-aphris.js`, `op-vvana.js`, `sim/skills.js setTrigger`
- **Now**: the scan of the 41 kitted 自选 MANUAL skills found two whose running range the data cannot hold (§25.5): 谬因
S2's beam path is a content trigger range; 薇薇安娜 S3 switches to ACTIVE_RANGE 3-2 after its first cast of a
deployment (new additive `skill.setTrigger`). [ASSUMED]: the owner's rule read through these kit ranges. Golden:
diy-045, diy-078. Tests: `test/content/feedback5-we2-area-triggers.test.js`.
#### 25.8.6 自选 rosters refuse SO-A / SO-B / RA-A like ISW-A (follow-ups 25, 10) — `shared/diy.js DIY_EXCLUDED_MODULE_TYPE`
- **Official**: their texts name other modes (集成战略 / 生息演算); battle_equip_table gates them (`validInGameTag`).
**Now**: `/^(?:ISW|SO|RA)-/` modules are refused by `validateDiyPicks`, dropped by `checkDiyPicks` and hidden by the
picker. [ASSUMED]: the reason of the owner's ISW-A decision applies. Tests: `test/diy.test.js`, `test/ui/diy-ui.test.js`.
#### 25.8.7 自选 in the match: random grants draw 自选 pieces, the banned line, AI 托管 (follow-ups 9, 11, 13) — `match/player/diy.js diyStockEntries`, `effectsMeta.js rollChess / rollPool`, `ui/gameLogic/diy.js diyBannedPieces`, `ui/matchInfo.js`, `match/bot.js buyScore / levelUp`
- **Official**: PRTS / the season notes — 自选 pieces widen the 调度中心's random range. **Now**: bands, items, 特质, 机变 cards
and reward pools that grant a random operator also draw the player's own 自选 stock (never another player's); a
slotted piece left out of the shop (every bond banned) is named under 本局禁用干员 in the briefing, the draft's match info
and the in-game tab; AI 托管 scores the player's own 自选 piece higher and levels 调度中心 one round earlier for its tier.
[ASSUMED]: the 托管 preference (bonus 12, one round). Golden: coop2-NORMAL-70-diy. Tests:
`test/match/feedback5-we2-diy-grants.test.js`, `test/ui/feedback5-we2-diybanned.test.js`,
`test/match/feedback5-we2-bot-diy.test.js`.
#### 25.8.8 Smaller items: 塑心's cap, 斩业星熊's normal attack, GitHub #148, the shims (follow-ups 30, 15, 3, 5) — `content/garrisons/battle.js fireGain`, `tools/build-data.mjs classifyAttack`, `content/bonds/addon/battle.js raidTile`
- 塑心 (garrison_90): the per-battle cap (10 / elite 20) is the trait instance's whichever bond is highest — a new highest
bond no longer opens a fresh cap (found in the PR #178 review; `test/content/feedback5-we2-garrison90.test.js`).
- 驭法铁卫 (斩业星熊): the data's normal attack is physical — arts only while a skill runs (display and AI only).
- GitHub #148 reproduced: a melee 突袭 member never lands on a 高台; on 战场#08 it lands on a 围墙 tile (deployable, drawn
raised), so no rule changed (`test/content/feedback5-we2-raid-148.test.js`; the owner's question in §25.10).
- The `kits/tier1..6.js` re-export shims and `shared/standIn.js diyRecord` are removed.
### 25.9 Ports from 0.1.4 (W5M and the feedback4 merges)
0.1.4 (§24, CHANGELOG 0.1.4) shipped from feedback4 while the refactor ran; every fix was ported by hand into the split
layout, with the golden moves each named: PRs #150, #66, #146, #74, #72, #103, #149, #109, #160 and W4A (高台 by the
trait, the 阿戈尔 revive slots, the 联防 devour) in W5M; PR #129 (teammate scouting); the restarted-cast events and 伊内丝's
影哨 before 不屈; PR #73 (operator battle voices, §21.30) and the settlement line's speaker; PRs #186, #134, #185, #24, #183
(their 42 new strings through `t()`); W4B (阿戈尔's devoured ATK as a 最终加算, a fallen marker's marks, 缇缇 S2's pulses,
§24.7 / §24.8); W4D (a sleeping enemy cannot be blocked, §24.9) is ported by W5R. Where a 0.1.4 change met 0.2.0 work:
- **突袭 × PR #109**: deploy-timed skills became duration skills, which the #49 rule (a passive that is on) missed;
`raidPoll` counts a skill that needs no SP while its window runs (`test/content/bonds_addon.test.js`).
- **一击即退 × PR #109**: the generic deploy-timed passives (the 预备干员-特种 stand-in) are `kind: 'duration'` too
(`content/generic.js`; numbers unchanged).
- **The invite line (PR #103) × i18n**: the copied line goes through `t()`, Chinese byte-identical to the PR's.
- **高台 by the trait × 补位**: a stand-in is placed by its own trait (`placeClass`, `piecePosition`); of the 自选 picks only
温蒂 carries the line.
- **Voices × 补位 / 自选**: the stand-in or the 自选 operator on screen speaks for itself; the settlement line, which never
played (it looked up chess ids as character ids), now names the record the own piece shows (`screens/game.js ownShown`).
### 25.10 Settled decisions, what stays [ASSUMED], and the questions left to the owner
| Decision | Settled |
|---|---|
| No web framework: plain `node:http` + `ws` (§25.1) | the owner, 2026-10-04 |
| The 补位 plan: the 55 NORMAL chess droppable, all owned by default, no borrowing, bots own everything (§25.3) | the owner, 2026-10-05 |
| A not-owned chess shows as its stand-in everywhere (§25.3) | the owner's recall, 2026-10-06 |
| 自选编队: 2 + 2 slots, owned 6★ with a kit, any skill / module, prototypes locked, per-player shop, levels by the chess forms (§25.4) | the owner, 2026-10-05 |
| The collab 6★ out of the 自选 pool, 焰狐龙梓兰 included; ISW-A refused (§25.4) | the owner, 2026-10-05 |
| Chinese default, English switch, official EN game texts, PR #70 credited (§25.2) | the owner, 2026-10-05 |
| The larger-range rule (ACTIVE_RANGE) on DEFAULT, on SEARCH and for 深巡 S2 (§25.5) | the owner, 2026-10-05 (deliberate deviation) |
| The 重装 stand-ins and every 自选 重装 cast with an enemy in range (§25.5) | the owner, 2026-10-05 (deliberate deviation) |
| 纯烬艾雅法拉 S1 at full SP; 伺夜 S1 only with his pack on the field (§25.5, §25.6.5) | the owner, 2026-10-05 |
Still [ASSUMED] (each also in its code comment): potential 0 for every 自选 pick; the per-player 自选 stock 8 / 5;
bots field no 自选 piece; SO-A / SO-B / RA-A refused for ISW-A's reason; the AI 托管 preference for 自选 pieces; a
stand-in placed by its own position; the 联防 map names; 伺夜 S2 in the 战术点形态; the enemy swing's re-taken targets and
kept cooldown; the stand-in and operator kit items of the tables above.
Left to the owner (none changed in 0.2.0): 望's stock and 阻回 (placed 棋子 never draw from her stock, so S1 / S2 add one
棋子 once, then 阻回 holds); whether a melee 突袭 member should prefer a tile where it can block (GitHub #148); trigger areas
through summons (麦哲伦 S1, 令 S3, 电弧 S2 / S3); an ally condition for 阿 S2 / S3; 淬羽赫默's 丰润羽翼 as 生命回复速度 (the
client template, no PRTS note) and 遥's break heal on the ATK cached at the grant; whether a passive counts as 技能就绪
for the whole deployment or its window only (§25.6.7); Stormeye S2 / Pith S1 (AUTO attack buffs) on DEFAULT or at full
SP; 赫默 S2 and 巫恋 S2 (wasted with nobody to heal / no enemy); the AUTO cast of an attacker of 白铁's 铁钳号; 泥岩 S3 闭锁
(PRTS 「强制缴械+无敌+不可阻挡」 against the kit keeping its block).
+86
View File
@@ -0,0 +1,86 @@
// The newcomer docs name only paths that exist: every repository path in docs/ARCHITECTURE.md and CONTRIBUTING.md
// (inline code, the diagrams and commands in fenced blocks) and every relative Markdown link. A path with a
// placeholder (`<codename>`, `{a,b}`) or a glob (`*`) is checked up to its last fixed directory; a path ending in `/`
// must be a directory. Outputs that a fresh checkout does not have (downloaded art, vendored libraries) are exempt.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Each document with the least number of paths it names (a guard against an extraction that finds nothing). */
const DOCS = [['docs/ARCHITECTURE.md', 100], ['CONTRIBUTING.md', 15]];
/** A repository path starts with one of these top-level directories … */
const TOP = /^(?:server|shared|public|data|tools|test|docs|types|scripts|\.github)\//;
/** … or is one of these root files (README.md is left out: a bare `README.md` often means a folder's own). */
const ROOT_FILES = new Set(['package.json', 'eslint.config.js', 'jsconfig.json', 'CHANGELOG.md', 'CONTRIBUTING.md']);
/** Git-ignored or made at install time. */
const EXEMPT = ['public/assets/', 'public/vendor/', 'data/local-assets.json'];
/** Repository paths named in a Markdown text (inline code and fenced blocks), without duplicates. */
function namedPaths(md) {
const spans = [];
const fenced = md.split(/^```.*$/m);
fenced.forEach((part, i) => {
if (i % 2 === 1) spans.push(part); // a fenced block: every word
else for (const m of part.matchAll(/`([^`\n]+)`/g)) spans.push(m[1]);
});
const out = new Set();
for (const span of spans) {
for (let token of span.match(/[A-Za-z0-9_.\/*<>{}@-]+/g) ?? []) {
token = token.replace(/\.+$/, '');
if (TOP.test(token) || ROOT_FILES.has(token)) out.add(token);
}
}
return [...out];
}
/** The part of a path that must exist: up to the last directory before a placeholder or a glob. */
function fixedPart(path) {
const parts = path.split('/');
const i = parts.findIndex((p) => /[*<>{}]/.test(p));
if (i < 0) return { path, dir: path.endsWith('/') };
return { path: parts.slice(0, i).join('/') + '/', dir: true };
}
function missing(path) {
if (EXEMPT.some((e) => path === e || path.startsWith(e) || `${path}/` === e)) return null;
const { path: fixed, dir } = fixedPart(path);
const abs = join(ROOT, fixed.replace(/\/$/, ''));
if (!existsSync(abs)) return `${path}: not found`;
if (dir && !statSync(abs).isDirectory()) return `${path}: not a directory`;
return null;
}
for (const [docPath, least] of DOCS) {
const md = readFileSync(join(ROOT, docPath), 'utf8');
test(`${docPath}: every repository path it names exists`, () => {
const paths = namedPaths(md);
assert.ok(paths.length >= least, `only ${paths.length} paths found — is the extraction broken?`);
const bad = paths.map(missing).filter(Boolean);
assert.deepEqual(bad, [], `stale paths in ${docPath}`);
});
test(`${docPath}: every relative link resolves`, () => {
const bad = [];
for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
const target = m[1];
if (/^(?:[a-z]+:|#)/i.test(target)) continue;
const file = normalize(join(ROOT, dirname(docPath), target.replace(/#.*$/, '')));
if (!existsSync(file)) bad.push(target);
}
assert.deepEqual(bad, [], `broken links in ${docPath}`);
});
}
test('the path extraction: placeholders, globs, directories and the exemptions', () => {
const md = 'a `server/sim/content/kits/ops/op-<codename>.js` b `data/*.json` c `node tools/golden.mjs --update`\n'
+ '```\nnpm test → test/golden.test.js; public/assets/ README.md\n```\n';
assert.deepEqual(namedPaths(md).sort(), ['data/*.json', 'public/assets/', 'server/sim/content/kits/ops/op-<codename>.js', 'test/golden.test.js', 'tools/golden.mjs'].sort());
assert.deepEqual(fixedPart('server/sim/content/kits/ops/op-<codename>.js'), { path: 'server/sim/content/kits/ops/', dir: true });
assert.equal(missing('public/assets/'), null);
assert.equal(missing('server/no-such-file.js'), 'server/no-such-file.js: not found');
assert.equal(missing('server/index.js/'), 'server/index.js/: not a directory');
});