feedback5 WT: docs/ARCHITECTURE.md — a newcomer's map: the Node server on node:http, the WebSocket protocol, the deterministic sim shared by server and browser, the 0.2.0 directory layout, the data flow (build-data, assets, the i18n overlay), the golden results and import boundaries, where to start for common changes; test/docs-paths.test.js checks every path and link it names; README links it
This commit is contained in:
1 parent
e2bb090f44
commit
11655e19d6
3 files changed
+271
No files matched your search
@@ -169,6 +169,7 @@ npm start # 启动服务器:http://localhost:3000
|
|||||||
| [docs/DEPLOY.md](docs/DEPLOY.md) | 部署指南:Windows 开服与开机自启、防火墙、组网 / 隧道、反向代理与 HTTPS、Docker、systemd、排错 |
|
| [docs/DEPLOY.md](docs/DEPLOY.md) | 部署指南:Windows 开服与开机自启、防火墙、组网 / 隧道、反向代理与 HTTPS、Docker、systemd、排错 |
|
||||||
| [docs/WINDOWS.md](docs/WINDOWS.md) | Windows 便携包:怎么打一份「零安装」包(`scripts/make-windows-bundle.mjs`)、包里放了什么、授权注意事项 |
|
| [docs/WINDOWS.md](docs/WINDOWS.md) | Windows 便携包:怎么打一份「零安装」包(`scripts/make-windows-bundle.mjs`)、包里放了什么、授权注意事项 |
|
||||||
| [docs/DESIGN.md](docs/DESIGN.md) | 架构与契约(英文):技术栈、目录分工、网络协议、渲染与 UI、各次试玩后的规则修订 |
|
| [docs/DESIGN.md](docs/DESIGN.md) | 架构与契约(英文):技术栈、目录分工、网络协议、渲染与 UI、各次试玩后的规则修订 |
|
||||||
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 代码地图(英文):服务器、联机协议和前后端共用的战斗模拟,0.2.0 重构后的目录,数据流,黄金结果与导入边界,常见改动从哪里入手 |
|
||||||
| [docs/SIM.md](docs/SIM.md) | 战斗模拟引擎参考(英文):钩子、技能描述格式、职业默认行为 |
|
| [docs/SIM.md](docs/SIM.md) | 战斗模拟引擎参考(英文):钩子、技能描述格式、职业默认行为 |
|
||||||
| [docs/META.md](docs/META.md) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 |
|
| [docs/META.md](docs/META.md) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 |
|
||||||
| [docs/DATA.md](docs/DATA.md) | 由官方数据表生成的游戏数据(英文) |
|
| [docs/DATA.md](docs/DATA.md) | 由官方数据表生成的游戏数据(英文) |
|
||||||
@@ -190,6 +191,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
|
|||||||
- 游戏数据由 `npm run build-data`(`tools/build-data.mjs`)从官方数据表生成,不要手工修改 `data/*.json`。
|
- 游戏数据由 `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))。
|
- 只重构、不改玩法的提交不能改变 `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` 和服务器冒烟测试。
|
- 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)。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -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 and run the tests: [README · 开发与测试](../README.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 (README · 开发与测试).
|
||||||
|
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]` (`server/sim/content/kits/README.md`, "The fidelity rule").
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// The newcomer docs name only paths that exist: every repository path in docs/ARCHITECTURE.md — inline code, the
|
||||||
|
// diagrams 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, the official-data cache) 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)), '..');
|
||||||
|
const DOCS = ['docs/ARCHITECTURE.md'];
|
||||||
|
/** 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 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 >= 40, `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');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user