feedback5: merge W5AD — language packs as the first type of a generic content pack: a language is a file in the language folder (public/i18n/<code>.json with a _meta manifest, optional data/i18n/<code>.json) or a pack folder (packs/<id>/pack.json); the server lists them live at /packs/index.json, the menu shows every pack, strings fall back pack → base → declared fallback → Chinese, broken placeholders are skipped, plurals follow Intl.PluralRules (zh / en unchanged); tools i18n.mjs template / check, packs.mjs list / index, build-i18n --lang ja|ko|zh-TW; the release zips ship packs/ and packs/index.json; docs/PACKS.md, docs/I18N.md §5; §25.16 for now (renumbered next); golden: unchanged vs feedback5; vs W5AD exactly what feedback5 changed since f1df0d3 (CB5's 18 bot matches, CB2's roster 13 / bonds 7 / fields 6, CB4's roster-016 / 7 boss fields / standin-04/06/08 / diy-028/124)

No conflicts outside docs/history/0.2.0.md; the §25 intro keeps one sentence per stream (W5AD's line that also named
the hotkeys' §25.15 joined to CB4's).

Gate: non-browser suites 4981 / 4981 (test/i18n-packs 12 new, test/package 1 new), npm run golden ok,
test/golden.test.js 9 / 9, lint 0 errors, check:imports ok, typecheck ok, docs-consistency + docs-paths 31 / 31.
This commit is contained in:
sganggs committed 2026-10-07 03:27:10 +08:00
commit 66cdf493fc
30 files changed
+2670 -288

No files matched your search

+4
View File
@@ -19,6 +19,10 @@ venv/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# --- content packs (docs/PACKS.md): the pack index for a static host, generated by `node tools/packs.mjs index`
# (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) -----------
packs/index.json
# --- tests & logs ---------------------------------------------------------------------------------- # --- tests & logs ----------------------------------------------------------------------------------
test/e2e/out/ test/e2e/out/
logs/ logs/
+6 -1
View File
@@ -72,6 +72,9 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里
`test/docs-consistency.test.js` 固定了文档里的一些句子,改文档后跑一下。 `test/docs-consistency.test.js` 固定了文档里的一些句子,改文档后跑一下。
- 代码和注释用英文;玩家文档(README、PLAYING、DEPLOY)用简体中文,技术文档用英文。界面上的文字写中文并用 `t('…')` - 代码和注释用英文;玩家文档(README、PLAYING、DEPLOY)用简体中文,技术文档用英文。界面上的文字写中文并用 `t('…')`
包起来,英文放进 `public/i18n/en.json`([docs/I18N.md](docs/I18N.md))。 包起来,英文放进 `public/i18n/en.json`([docs/I18N.md](docs/I18N.md))。
- 翻译成新的语言:一个语言就是 `public/i18n/` 里的一个文件,不用改代码。`node tools/i18n.mjs template <语言代码>` 生成骨架,
翻译后用 `node tools/i18n.mjs check <语言代码>` 检查,步骤见 [docs/I18N.md](docs/I18N.md)「Adding a language」;语言包是
内容包的第一种,格式见 [docs/PACKS.md](docs/PACKS.md)。
- 提交的代码以 GPL-3.0-or-later 发布。本项目坚持非商业,请不要加入广告、付费、打赏等任何变现功能。 - 提交的代码以 GPL-3.0-or-later 发布。本项目坚持非商业,请不要加入广告、付费、打赏等任何变现功能。
## 5. 添加干员(自选编队) ## 5. 添加干员(自选编队)
@@ -111,7 +114,9 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里
`Generated with …` (the maintainers' convention) — merged pull requests are credited in the CHANGELOG. Describe the `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 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 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. `public/i18n/en.json`. A new language is one file in `public/i18n/` and no code: `node tools/i18n.mjs template <code>`,
translate, `node tools/i18n.mjs check <code>` (docs/I18N.md "Adding a language"; packs in general: docs/PACKS.md).
Code is GPL-3.0-or-later; no monetisation features.
- **Adding an operator (自选)**: follow "How to add an operator (自选)" in - **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, [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 registered in `OPERATOR_KIT_FILES`, a test `test/content/op_<codename>.test.js`, the fidelity checklist in the pull
+1 -1
View File
@@ -185,7 +185,7 @@ npm start # 启动服务器:http://localhost:3000
| [docs/META.md](docs/META.md) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 | | [docs/META.md](docs/META.md) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 |
| [docs/DATA.md](docs/DATA.md) | 由官方数据表生成的游戏数据(英文) | | [docs/DATA.md](docs/DATA.md) | 由官方数据表生成的游戏数据(英文) |
| [docs/ASSETS.md](docs/ASSETS.md) | 素材来源、目录结构与清单(英文) | | [docs/ASSETS.md](docs/ASSETS.md) | 素材来源、目录结构与清单(英文) |
| [docs/I18N.md](docs/I18N.md) | 中文 / English 界面(英文):界面文字、游戏文本与服务器消息怎样翻译,覆盖范围,切到英文后仍显示中文的内容 | | [docs/I18N.md](docs/I18N.md) | 界面语言(英文):界面文字、游戏文本与服务器消息怎样翻译,覆盖范围;**添加一种语言只需在 `public/i18n/` 放一个语言文件**(社区翻译步骤见「Adding a language」,内容包格式见 [docs/PACKS.md](docs/PACKS.md)) |
| [docs/BALANCE.md](docs/BALANCE.md) | 难度模型与测量(英文) | | [docs/BALANCE.md](docs/BALANCE.md) | 难度模型与测量(英文) |
| [docs/research/](docs/research/00-INDEX.md) | 官方规则、数据与界面的调研记录 | | [docs/research/](docs/research/00-INDEX.md) | 官方规则、数据与界面的调研记录 |
+12 -8
View File
@@ -77,6 +77,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation | | `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` | | `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `server/data.js` | loads `data/*.json` once (frozen) | | `server/data.js` | loads `data/*.json` once (frozen) |
| `server/packs.js` | the content packs (PACKS.md): finds the language packs of `public/i18n/` and the pack folders of `packs/`, validates them, answers `/packs/index.json` and which pack files may be served; re-reads the folders when they change |
| `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/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/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/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` |
@@ -88,7 +89,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| `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/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/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) | | `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` | | `shared/` | imported by the server and the browser: `protocol.js`, `constants.js`, `i18n.js`, `i18nData.js` and `i18nPacks.js` (languages), `packs.js` (the content-pack format), `standIn.js` (补位), `diy.js` (自选编队), `highGround.js`, `loadoutRecord.js` |
### Client (`public/`) ### Client (`public/`)
@@ -100,14 +101,15 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| `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/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/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/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 | | `public/css/`, `public/i18n/<code>.json` | the styles; the UI strings of each language pack (English ships) |
### Data, tools, tests ### Data, tools, tests
| path | what | | 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 | | `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/<code>.json` the game texts of a language pack (English), `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` | | `packs/` | content packs installed as folders, `packs/<id>/pack.json` + files (PACKS.md); empty in the repository but for its readme |
| `tools/` | `build-data.mjs`, `build-i18n.mjs`, `i18n.mjs` (UI strings, language packs), `packs.mjs` (content packs), `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`; `designDocs.js`, the design document in § order for the doc tests), `test/e2e/` (browser and bot runs) | | `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`; `designDocs.js`, the design document in § order for the doc tests), `test/e2e/` (browser and bot runs) |
| `types/` | JSDoc typedefs of the type-checked slice (`types/README.md`) | | `types/` | JSDoc typedefs of the type-checked slice (`types/README.md`) |
| `docs/` | the documents; `docs/DESIGN.md` the index of the design document, `docs/design/` its current rules, `docs/history/` its per-release revisions; `docs/research/` the research on the official mode | | `docs/` | the documents; `docs/DESIGN.md` the index of the design document, `docs/design/` its current rules, `docs/history/` its per-release revisions; `docs/research/` the research on the official mode |
@@ -135,10 +137,11 @@ public/assets/ ─────────▶ public/js/render/, public/js/audio
the sim through `server/sim/simdata.js`, which turns every record into a frozen engine def. 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 - **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. 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 - **Languages.** Chinese is the source language; every other language is a pack — `public/i18n/<code>.json` (UI
(`tools/build-i18n.mjs` → `data/i18n/en.json`, applied by `public/js/data.js`); UI strings are wrapped in `t('…')` strings) and optionally `data/i18n/<code>.json` (game texts), listed by the server at `/packs/index.json`
and translated in `public/i18n/en.json` (`tools/i18n.mjs` lists what is missing); server messages travel as message (`server/packs.js`; PACKS.md). The English game texts come from the official EN client (`tools/build-i18n.mjs` →
ids. I18N.md. `data/i18n/en.json`, applied by `public/js/data.js`); UI strings are wrapped in `t('…')` and translated in the packs
(`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`, - **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`; 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. `tools/local-extract/` can add art from a local game client. The client only requests URLs listed in the manifest.
@@ -181,6 +184,7 @@ public/assets/ ─────────▶ public/js/render/, public/js/audio
| a screen or a HUD panel | `public/js/screens/`, `public/js/ui/`; pure logic in `public/js/ui/gameLogic/` | `test/ui/` | | 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/` | | 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 | | a UI text | `t('…')` in the code, the English in `public/i18n/en.json` | I18N.md |
| a language | `public/i18n/<code>.json` (`node tools/i18n.mjs template <code>`), no code | I18N.md "Adding a language", PACKS.md |
| HTTP, headers, static routes | `server/http/` | `test/version.test.js`, `test/client-static.test.js` | | 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; the table in Before changing a rule, read the DESIGN section that owns it (module headers cite their sections; the table in
+1 -1
View File
@@ -300,7 +300,7 @@ npm run package -- --out <目录> # 完整包 Stronghold-Protocol-v<版
npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版本>-lite.zip npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版本>-lite.zip
``` ```
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。 - **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包相比,完整包少了约 570 个文件、解压后小约 25 MB,zip 小约 8 MB。 - **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包相比,完整包少了约 570 个文件、解压后小约 25 MB,zip 小约 8 MB。
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。 - **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
- **需要**:已下载素材的仓库(完整包)——打包前先联网运行一次 `node tools/fetch-assets.mjs`,补齐清单计划但本机还没有的素材(清单只列出磁盘上有的文件,打包工具看不出缺了哪些;`data/assets.json` 有变化就先提交);能访问 npm 的网络(`npm ci`);`zip`(或 bsdtar 的 `tar`,Windows 10 起自带)。`--out` 默认是系统临时目录下的 `stronghold-protocol-release`,不能在仓库里面;`--force` 覆盖已有的 zip,`--keep-stage` 保留打包用的目录供检查。 - **需要**:已下载素材的仓库(完整包)——打包前先联网运行一次 `node tools/fetch-assets.mjs`,补齐清单计划但本机还没有的素材(清单只列出磁盘上有的文件,打包工具看不出缺了哪些;`data/assets.json` 有变化就先提交);能访问 npm 的网络(`npm ci`);`zip`(或 bsdtar 的 `tar`,Windows 10 起自带)。`--out` 默认是系统临时目录下的 `stronghold-protocol-release`,不能在仓库里面;`--force` 覆盖已有的 zip,`--keep-stage` 保留打包用的目录供检查。
+107 -23
View File
@@ -1,17 +1,22 @@
# I18N.md — languages (中文 / English) # I18N.md — languages (中文, English, language packs)
Chinese is the default; English is a switch (the owner's decision of 2026-10-05): an English browser still opens in Chinese is the default; every other language is a switch (the owner's decision of 2026-10-05): an English browser still
Chinese until the player picks English on the title screen (top right) or in 设置 / Settings. The choice is kept in opens in Chinese until the player picks a language on the title screen (top right) or in 设置 / Settings. Every
`localStorage` (`sp.pref.lang`); `?lang=en` / `?lang=zh` in the address bar sets it once and is then removed. A switch language but Chinese is a **language pack** — a file in the language folder, nothing else (the owner's decision of
re-renders the page in place (no reload). 2026-10-07; §5 "Adding a language"): the menu lists every pack the server finds, each under its own name (buttons up to
four languages, a list beyond). English is a pack like any other; it ships with the game and is the complete one. The
choice is kept in `localStorage` (`sp.pref.lang`); `?lang=<code>` in the address bar (`?lang=en`, `?lang=zh`) sets it
once and is then removed; a stored language whose pack is gone opens in Chinese. A switch re-renders the page in place
(no reload). Language packs are the first type of the content packs (docs/PACKS.md: the manifest, the registry, the
index, the pack folders).
Three kinds of text, three mechanisms — no i18n framework: Three kinds of text, three mechanisms — no i18n framework:
| Text | Source of the English | Mechanism | | Text | Source of a language | Mechanism |
|---|---|---| |---|---|---|
| UI strings (buttons, labels, hints, toasts) | `public/i18n/en.json` — Chinese msgid → English | `t()` in the code (`shared/i18n.js`) | | UI strings (buttons, labels, hints, toasts) | `public/i18n/<code>.json` — Chinese msgid → translation (English: `public/i18n/en.json`) | `t()` in the code (`shared/i18n.js`) |
| Game texts (operators, skills, talents, modules, traits, enemies, bonds, items, effects, strategies, 特质, 机变 cards, modes, stages, tokens) | `data/i18n/en.json` — the official EN client texts by record id and field | `public/js/data.js` returns localized records | | Game texts (operators, skills, talents, modules, traits, enemies, bonds, items, effects, strategies, 特质, 机变 cards, modes, stages, tokens) | `data/i18n/<code>.json` — the official client texts by record id and field (English: the EN client) | `public/js/data.js` returns localized records |
| Server messages (m.toast, m.ticker, error codes) | the same `public/i18n/en.json` | the server sends msgid + params, the client translates | | Server messages (m.toast, m.ticker, error codes) | the same `public/i18n/<code>.json` | the server sends msgid + params, the client translates |
## 1. UI strings: `t()` ## 1. UI strings: `t()`
@@ -27,9 +32,25 @@ const TABLE = { a: N_('独立模拟') }; // module level: N_ only marks it;
html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${r}</b>` })}</span>`; // markup inside a sentence html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${r}</b>` })}</span>`; // markup inside a sentence
``` ```
- **Placeholders** are the same in the msgid and the translation: `{name}`, positional `{0}`, and one plural form for - **Placeholders** are the same in the msgid and the translation: `{name}`, positional `{0}`, and plural forms. Two
English, `{n|one|other}` (`'{n} {n|round|rounds}'`). A value that is an array renders as a list (`、` / `, `). A plain forms `{n|one|other}` (`'{n} {n|round|rounds}'`) pick `one` when the language's plural category of the number
missing value leaves the placeholder as it is (official templates such as `{0}博士…` pass through untouched). is `one` (`Intl.PluralRules`; in English when n is 1 — as before packs, but for −1, now singular) and `other` otherwise. A language with
more categories names them: `{n|one:секунда|few:секунды|many:секунд|other:секунды}` (categories `zero`, `one`, `two`,
`few`, `many`, `other`; `other` is required; three or more plain forms are refused — the order of a language's
categories differs between engines). A plural form holds no placeholder of its own: write the number beside it
(`{n} {n|…}`). A value that is an array renders as a list joined with the language's separator (its translation of
`list::、`; `、` in Chinese, `, ` without one). A missing value leaves the placeholder as it is (official templates such
as `{0}博士…` pass through untouched).
- **Validation.** A translation must keep every plain `{name}` of its msgid (a plural form does not count: it does not
show the number), and may use another name only when the caller passes it (`'{n} {count|unit|units}'` with `count`
passed beside a markup `n`). A translation that drops or invents a placeholder, or has a broken plural form, is
skipped like a missing one — the next language of the chain shows — and reported once on the console of a
development page (a dev build, or a page served from this machine: localhost); `node tools/i18n.mjs check` flags it
against the call sites.
- **Fallback chain**, per string: the chosen pack → its base (`_meta.base`, else the language of a regional code:
`pt-BR` → `pt` when there is a `pt` pack; `zh-TW` has none — Chinese is the source) with the base's own chain → the
packs its `_meta.fallback` names (`["en"]`: what it lacks shows in English) → the Chinese msgid. A partial pack is
fine. `tc()` tries its context key and then the plain msgid in each language before the next one.
- **Markup inside a sentence** (a number in `<b>`, a `<kbd>`): `tParts(msgid, params)` returns the translation split at - **Markup inside a sentence** (a number in `<b>`, a `<kbd>`): `tParts(msgid, params)` returns the translation split at
its placeholders, with a param that is markup (a vnode, or an array of vnodes) kept as it is — the translator gets the its placeholders, with a param that is markup (a vnode, or an array of vnodes) kept as it is — the translator gets the
whole sentence and its word order, the Chinese pieces are the text around the markup exactly as before. Do not split whole sentence and its word order, the Chinese pieces are the text around the markup exactly as before. Do not split
@@ -46,10 +67,13 @@ html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">$
### Translating ### Translating
Edit `public/i18n/en.json` (`"msgid": "English"`, two-space JSON; `_about` is metadata). Keep every `{placeholder}`; Edit the pack, e.g. `public/i18n/en.json` (`"msgid": "English"`, two-space JSON; `_meta` is its manifest, `_about` a
`test/i18n.test.js` checks that, that no Chinese is left in a value, and that every msgid the code uses is present. A note; keys starting with `_` are never strings). Keep every `{placeholder}` (above); `test/i18n.test.js` checks that,
translation may use more params than its msgid (`'{n} {count|unit|units}'` with `count` passed beside a vnode `n`). Use that no Chinese is left in an English value, and that every msgid the code uses is in English — English declares
the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts): `"complete": true`, and `node tools/i18n.mjs check --strict` holds a complete pack to every msgid, a partial pack only to
its placeholders. A translation may use more params than its msgid (`'{n} {count|unit|units}'` with `count` passed
beside a vnode `n`). Use the official terms of the game (for English the EN client's own wording, e.g. its tips and mode
texts):
| 中文 | English | 中文 | English | | 中文 | English | 中文 | English |
|---|---|---|---| |---|---|---|---|
@@ -69,8 +93,16 @@ the official EN terms of the game (the EN client's own wording, e.g. its tips an
- `extract [paths] [--list]` — per file: the msgids in `t()` / `tc()` / `tParts()` / `N_()` / `msg()`, and the Chinese - `extract [paths] [--list]` — per file: the msgids in `t()` / `tc()` / `tParts()` / `N_()` / `msg()`, and the Chinese
literals still shown untranslated; the total says how many of those already have a translation (ready for the literals still shown untranslated; the total says how many of those already have a translation (ready for the
codemod). codemod).
- `check [paths] [--strict] [--stale]` — msgids the code uses that `public/i18n/en.json` lacks (`--strict` exits 1; - `check [<code>… | --all] [paths] [--strict] [--stale] [--list]` — per language pack (default `en`): how many of the
`--stale` lists entries no code uses yet). msgids the code uses it translates, and its errors — a translation that drops a placeholder, uses one no call site
passes, or has a broken plural form; a value that is not a string; a manifest problem. Missing strings are listed for
a complete pack (English) or with `--list`; `--stale` lists entries no code uses and no complete pack has. `--strict`
exits 1 on an error, or when a complete pack misses a msgid. `--root <dir>` reads the packs of another checkout.
- `template <code> [--fill <code>]` — writes `public/i18n/<code>.json`: a `_meta` manifest to fill in (the names from
`Intl.DisplayNames`, `"fallback": ["en"]` — none for a Chinese variant, whose gaps show the Chinese msgid) and every
msgid with an empty value in the order of English (`--fill en`: English values to overwrite). On an existing pack it
keeps the translations and `_meta` and adds the msgids the pack lacks — run it again after an update.
- `index` — the same as `node tools/packs.mjs index` (the pack index for a static host, docs/PACKS.md).
- `codemod <files> [--write]` — wraps a file's Chinese literals: html`` text and attribute values → `${t('…')}`, strings → - `codemod <files> [--write]` — wraps a file's Chinese literals: html`` text and attribute values → `${t('…')}`, strings →
`t('…')`, template literals → `t('…{name}…', { name })` (an expression's param name: identifier → itself, `a.b` → `b`, `t('…')`, template literals → `t('…{name}…', { name })` (an expression's param name: identifier → itself, `a.b` → `b`,
`x.length` → `n`, else its position), nested strings in params included; adds the import. Module-level literals get `x.length` → `n`, else its position), nested strings in params included; adds the import. Module-level literals get
@@ -92,18 +124,22 @@ line marked `// i18n-ignore` and a file marked `i18n-ignore-file` in a comment (
`test/i18n.test.js` scans what `extract` / `check` scan (public/js, shared, server) and fails on a Chinese literal left `test/i18n.test.js` scans what `extract` / `check` scan (public/js, shared, server) and fails on a Chinese literal left
unwrapped or a msgid missing from `public/i18n/en.json` — `extract` 0 and `check --strict` are part of the tests. unwrapped or a msgid missing from `public/i18n/en.json` — `extract` 0 and `check --strict` are part of the tests.
## 2. Game texts: `data/i18n/en.json` ## 2. Game texts: `data/i18n/<code>.json`
``` ```
node tools/build-i18n.mjs # downloads the EN tables once into .cache/gamedata-en/, writes data/i18n/en.json node tools/build-i18n.mjs # downloads the EN tables once into .cache/gamedata-en/, writes data/i18n/en.json
node tools/build-i18n.mjs --offline # never download (the zh_CN tables come from .cache/gamedata, as for build-data) node tools/build-i18n.mjs --offline # never download (the zh_CN tables come from .cache/gamedata, as for build-data)
node tools/build-i18n.mjs --check # exit 1 when data/i18n/en.json is out of date (nothing written) node tools/build-i18n.mjs --check # exit 1 when data/i18n/en.json is out of date (nothing written)
node tools/build-i18n.mjs --source yostar # the other EN source (see below) node tools/build-i18n.mjs --source yostar # the other EN source (see below)
node tools/build-i18n.mjs --lang ja # another official client: ja (JP), ko (KR), zh-TW (TW) → data/i18n/<code>.json
node tools/build-i18n.mjs --lang fr --dict fr-texts.json # no official client: a community dictionary (below)
``` ```
**Rebuild it after every `node tools/build-data.mjs`** that changes a text: `test/i18n-data.test.js` fails while a **Rebuild it after every `node tools/build-data.mjs`** that changes a text: `test/i18n-data.test.js` fails while a
translation's Chinese source no longer matches `data/*.json`, and at run time such a stale leaf is not applied (the translation's Chinese source no longer matches `data/*.json`, and at run time such a stale leaf is not applied (the
player sees the current Chinese text, never an outdated number). player sees the current Chinese text, never an outdated number). The same holds for the game texts of every pack: a
stale text of a pack falls back like a missing one (the next overlay of its chain, then Chinese) until the pack is
rebuilt.
- **Sources.** The English comes from the official EN client tables. Default `--source assets`: - **Sources.** The English comes from the official EN client tables. Default `--source assets`:
[ArknightsAssets/ArknightsGamedata](https://github.com/ArknightsAssets/ArknightsGamedata) `en/gamedata` — current, and [ArknightsAssets/ArknightsGamedata](https://github.com/ArknightsAssets/ArknightsGamedata) `en/gamedata` — current, and
@@ -131,9 +167,27 @@ player sees the current Chinese text, never an outdated number).
data. What stays Chinese is CN-only content the EN client does not have yet: some of the 71 owned 6★ of the 自选 data. What stays Chinese is CN-only content the EN client does not have yet: some of the 71 owned 6★ of the 自选
pool (names, skills, talents) and some newer modules (before the 自选 data: 14 931 texts, 99.9 %). Per kind in `.cache/build-i18n-report.json` (also `missing`: pool (names, skills, talents) and some newer modules (before the 自选 data: 14 931 texts, 99.9 %). Per kind in `.cache/build-i18n-report.json` (also `missing`:
every untranslated text) and in `meta.coverage`. every untranslated text) and in `meta.coverage`.
- **Client**: `data.setLocale(lang)` downloads the overlay once (~1.5 MB, ~190 KB gzipped) and from then on `get`, - **Other official clients** (`--lang`): ArknightsAssets/ArknightsGamedata also mirrors the JP, KR and TW clients
`lookup` and `list` (and every getter built on them) return localized copies; `data.getRaw(name)` keeps the Chinese (`jp/`, `kr/`, `tw/gamedata`), each with both 盟约 seasons (checked 2026-10-07): `--lang ja`, `ko` and `zh-TW` build the
file; `data.localeName(zh)` translates a name. game texts of a pack of that language the same way (measured on that day: ja 96.5 %, ko 97.4 %, zh-TW 91.1 % of the
22 114 texts — the TW data is older, 51.2, and a Traditional text equal to the Simplified one counts as untranslated
although it shows the same). The remake's own strings (stage-name composites, the fallback files) are English only;
for another language the pack's UI file (`public/i18n/<code>.json`) is the fallback dictionary, and what is left falls
back per text at run time (the pack's chain, then Chinese). Only English is generated in the repository: a game-text
overlay ships with the UI pack of its language (a pack is listed by its UI file).
- **A language without an official client** (French, German, Russian …) needs no game texts at all: they follow the
pack's chain (`"fallback": ["en"]` shows the English ones). To translate some, write a community dictionary — a flat
JSON `{ "<Chinese data text>": "<translation>" }`; the `names` of `data/i18n/en.json` (every game-data name with its
English) is a good first list — and run `node tools/build-i18n.mjs --lang <code> --dict <file.json>`: it writes
`data/i18n/<code>.json` from the dictionary and the pack's UI file (exact matches only: a text whose numbers build-data
resolved needs its own entry), and every text it does not cover falls back per text at run time. `--dict` also adds to
an official client (tried after it).
- **Client**: `data.setLocale(lang, chain)` downloads the overlays of the chain once each — the pack's own, its base's,
a fallback's (`ui/lang.js dataChain`: the languages of the pack's chain that have game texts, with the URLs the pack
index gives; English ~1.5 MB, ~190 KB gzipped) — and from then on `get`, `lookup` and `list` (and every getter built
on them) return localized copies, **each text from the first overlay of the chain that has it**, else the Chinese one
(`shared/i18nData.js applyFileOverlay(json, overlay, onto)`); `data.getRaw(name)` keeps the Chinese file;
`data.localeName(zh)` translates a name through the chain.
## 3. Server messages ## 3. Server messages
@@ -181,6 +235,36 @@ Not player text, never translated: developer details and logs (`detail`, `log.*`
(`shared/highGround.js` reads the trait text 「可以放置于远程位」), the development STUB match, and the HTTP error pages, (`shared/highGround.js` reads the trait text 「可以放置于远程位」), the development STUB match, and the HTTP error pages,
which are bilingual on purpose. which are bilingual on purpose.
## 5. Adding a language
A language is a file in the language folder; the server lists it on the next page load (no restart, no build step, no
code change). For a community translator:
1. **Get the code and the tools**: a clone of the repository and `npm install` (the checker reads the code's msgids).
Pick the language's BCP 47 code in its usual case: `ja`, `ko`, `fr`, `pt-BR`, `zh-TW`.
2. **Make the skeleton**: `node tools/i18n.mjs template <code>` writes `public/i18n/<code>.json` — the `_meta` manifest
and every msgid (about 1 000) with an empty value, in the order of the English pack (`--fill en` fills in the English
to overwrite). Fill in `_meta`: `name` (the language in itself, shown in the menu), `englishName`, `authors`,
`version`; keep `"fallback": ["en"]` to show English where a string is still empty (drop it to show Chinese); set
`numberUnits` if the language counts large numbers in 10⁴ steps like Chinese (`["万", "億"]`).
3. **Translate** the values (empty = not yet: it falls back). Keep every `{placeholder}` of the msgid; plural forms as
in §1; `public/i18n/en.json` is the reference for meaning and context (`ctx::msgid` keys are the same Chinese text in
another context). Use the official terms of your language's client where it has one.
4. **Check and try**: `node tools/i18n.mjs check <code>` — coverage, and errors to fix (a dropped or unknown
placeholder, a broken plural form). Start the server (`npm start`), pick the language on the title screen; a
translation the game skips is reported on the browser console. `node tools/packs.mjs list` says why a pack is not
listed (a file name not in its usual case, broken JSON …).
5. **Game texts** (optional): with an official client of the language, `node tools/build-i18n.mjs --lang <code>` writes
`data/i18n/<code>.json` (§2); without one, the game texts follow the chain, or a dictionary translates some of them
(`--dict`, §2). Share the pack as a pull request (the
file, plus the game-text file if built), or as a folder pack that a server installs by copying it into `packs/`
(`packs/<id>/pack.json` naming `ui.json` and `data.json`: docs/PACKS.md). After a game update, run `template <code>`
again: it keeps the translations and adds the new msgids.
The tests: `test/i18n-packs.test.js` (discovery, the fallback chain, placeholders, plurals in English and Russian, the
template and check commands, and that the Chinese and English output of `t()` is what it was before packs: every msgid
of `public/i18n/en.json` rendered with sample params, compared with the renderer of 0.2.0 before packs).
## Credits ## Credits
The English UI strings were seeded from GitHub PR #70 (branch `en-translation`) by **@YuriRestia**, reused with credit The English UI strings were seeded from GitHub PR #70 (branch `en-translation`) by **@YuriRestia**, reused with credit
+164
View File
@@ -0,0 +1,164 @@
# PACKS.md — content packs
A **content pack** is add-on content a server picks up from a folder: a manifest plus its files. The owner's decision of
2026-10-07 made the language pack the first type of one general mechanism, so that later resource packs (art, audio,
fonts) and server-side data packs reuse the same manifest, registry and index instead of each inventing its own. In
0.2.0 one type loads, **`lang`** (a language: docs/I18N.md "Adding a language"); `assets` and `data` are planned.
| File | Role |
|---|---|
| `shared/packs.js` | the format: the manifest, the pack types (`PACK_TYPES`), app version ranges, the index (browser, server and tools) |
| `shared/i18nPacks.js` | the `lang` type: language codes, the language fields, the fallback chain, the script family |
| `server/packs.js` | the registry: discovery, validation, the index, which pack files may be served |
| `server/http/static.js` | `GET /packs/index.json` (the live index) and `GET /packs/<id>/<file>` |
| `public/js/ui/lang.js` | the client of the `lang` entries: the language menu and the loader |
| `tools/packs.mjs` | `list`, `index` (the index file for a static host), `check` |
| `tools/package.mjs` | ships the committed packs and writes `packs/index.json` into the release zips |
## 1. Layouts
Two layouts, read by the same registry and listed alike:
```
packs/<id>/pack.json + its files a folder pack — any type; the manifest names its files
public/i18n/<code>.json a single-file language pack — its manifest is the file's `_meta` block
data/i18n/<code>.json (+ its game texts, when present)
```
The single file is the quickest way for a translator (one file, the format English has always had, and how English
ships); the folder is the layout every pack type shares — a pack distributed as one folder with its own readme and
licence, and the only layout a resource or data pack will have. A language can come either way; the menu does not tell
them apart.
The id of a folder pack is its folder name; the id of a single-file language pack is its language code. Ids are
letters, digits, `.`, `_` and `-` (at most 64), unique across types and without regard to case (Windows and macOS
would see one folder).
## 2. The manifest
`packs/<id>/pack.json`, or the `_meta` block of `public/i18n/<code>.json` (the same fields):
```json
{
"type": "lang",
"lang": "ja",
"name": "日本語",
"englishName": "Japanese",
"version": "1.0.0",
"app": ">=0.2.0",
"authors": ["…"],
"credits": "…",
"license": "CC-BY-4.0",
"fallback": ["en"],
"files": { "ui": "ui.json", "data": "data.json" }
}
```
| Field | Meaning |
|---|---|
| `id` | optional: the folder or file name decides; a manifest that names another id gets a warning |
| `type` | `lang` (a single-file language pack may leave it out); planned: `assets`, `data` |
| `version` | the pack's own version |
| `app` | the app versions it is made for, a range: `>=0.2.0`, `>=0.2.0 <0.3.0`, `0.2.x`, `^0.2.0`, `~0.2.1`, `*`, alternatives with `||`. A pre-release (0.2.0-dev) counts as its release. Outside the range a language pack still loads — what it lacks falls back — and is flagged `compatible: false` (the server log, `tools/packs.mjs list`); a later type decides what an incompatible pack means for it |
| `name`, `englishName` | the name in its own language (a language pack: the language's own name, shown in the menu) and in English (the menu's tooltip) |
| `authors`, `credits`, `license` | who made it; free text (`license`: an SPDX id or text) |
| `files` | a folder pack: role → path inside the folder. Each type lists its roles and their extensions (`lang`: `ui` required, `data` optional, both `.json`) |
| the type's fields | `lang`: `lang`, `base`, `fallback`, `complete`, `numberUnits` (docs/I18N.md) |
A **problem** keeps a pack from loading (unknown type, no `lang`, a file missing or outside the folder, broken JSON, an
id or a language already taken — the language folders' files come first, then the folders by id); a **warning** does
not (an id that differs, an app range this version is outside of, an unknown file role). Both are printed when the
server starts or notices a change, and by `node tools/packs.mjs list`.
## 3. Discovery and the index
The server's registry (`server/packs.js`) scans `public/i18n/`, `data/i18n/` and `packs/` when it starts and again
whenever something there changes: on a request for the index or a pack file it compares the names, sizes and times of
the files it reads (at most once a second, a few `stat` calls) and scans again if they differ. A pack dropped into a
folder shows on the next page load — no restart, no build step; a removed one leaves (a player whose stored language is
gone gets Chinese).
`GET /packs/index.json` (never cached) answers the packs of supported types:
```json
{ "version": 1, "app": "0.2.0", "packs": [
{ "id": "en", "type": "lang", "name": "English", "englishName": "English", "version": "0.2.0", "app": ">=0.2.0",
"compatible": true, "authors": ["…"], "lang": "en", "fallback": [], "complete": true, "strings": 1017,
"files": { "ui": "/i18n/en.json", "data": "/data/i18n/en.json" } } ] }
```
`files` holds URLs on this server, so a client never builds a path itself. A client reads the entries of its type
(`readPackIndex(json, 'lang')`: an entry of another type, with a bad id or a URL that is not a path on this server is
dropped).
**A static host** cannot list folders: `node tools/packs.mjs index` writes the same body to `packs/index.json`
(git-ignored), and `tools/package.mjs` writes it into the release zips. The live server answers `/packs/index.json`
itself and ignores that file. Serving the game from plain files needs the server's URL layout: `public/` at `/`,
`data/` at `/data/`, `shared/` at `/shared/`, `server/sim/` at `/sim/` and `packs/` at `/packs/` (the game itself still
needs the Node server for its rooms; DEPLOY.md).
**What is served.** `GET /packs/<id>/<file>` answers only a file that the manifest of a loaded folder pack names under a
role of its type, with that role's extension, inside the folder once links are resolved — `pack.json`, a readme, an
HTML or script file in the folder are never served; a pack of a planned type serves nothing. A single-file language
pack's files are the language folders' own (`/i18n/<code>.json`, `/data/i18n/<code>.json`).
## 4. The types
| Type | Status | Where it applies | Loader |
|---|---|---|---|
| `lang` | supported (0.2.0) | the client: UI strings, game texts | `public/js/ui/lang.js` (the menu, the strings: `shared/i18n.js`; the game texts: `public/js/data.js`) |
| `assets` | planned | the client: art, audio, font replacements | — |
| `data` | planned | the server: data patches applied at start | — |
A pack of a planned type is parsed and listed as not supported ("type "assets" is planned …, not loaded by this
version"); it is never served and never changes anything.
### Adding a pack type
1. **The type** — its entry in `PACK_TYPES` (`shared/packs.js`): `status`, `summary`, `live` (whether a running server
may take changes without a restart) and `files` (the roles a folder pack names, with the extensions that may be
served for each). Its own manifest fields get a normalizer beside `langFields` and a branch in `normalizeManifest`;
its index fields a branch in `packIndexEntry`.
2. **The loader** — reads the registry by type: `createPackRegistry(…).list('assets')` on the server, or the index
entries of the type in the browser (`readPackIndex(json, 'assets')`), the way `ui/lang.js` reads `lang`. A
server-side type (`data`) loads once at start (`live: false`): the simulation's code and data are fixed for the life
of the process, and a battle must run the same data on the server and in every browser.
3. **The checks** — what `tools/packs.mjs check` and the tests verify for the type (a data pack: the same validation
the data builder applies; a resource pack: the files exist and are of the allowed kinds), and its documentation
here.
Things later types must keep (the maintainers' study of mods, 2026-10-07):
- **Server-side only for gameplay.** A data pack is installed on the server, so every player of a room uses the same
content and the server can still check or recompute a battle. A player never installs gameplay content of their own.
- **A content hash in the handshake** before data packs ship (`welcome`, `/healthz`, the BattleSpec), so a client and
a server can tell they run the same content, and a "modded" mark in the UI and in bug reports.
- **No third-party code.** The manifest and its files are data (JSON, images, audio, fonts); a pack type that runs code
is a separate decision of the owner's.
- **The golden results stay on the original content**; a pack never changes them.
## 5. Tools
```
node tools/packs.mjs list [--json] the packs by type, what was skipped and why, the warnings
node tools/packs.mjs index [--out <file>] write packs/index.json (the static index)
node tools/packs.mjs check [--strict] list; --strict exits 1 when a pack is skipped or warned about
node tools/i18n.mjs template <code> a new language pack (docs/I18N.md)
node tools/i18n.mjs check <code> | --all a language pack's strings: coverage, placeholders
```
`--root <dir>` points `tools/packs.mjs` at another checkout or an unpacked release.
## 6. Shipping
`tools/package.mjs` ships the committed packs (`public/i18n/`, `data/i18n/`, `packs/` — tracked files only: a pack
installed on this machine and not committed stays out) and writes `packs/index.json` into the stage from the packs
that ship. `test/package.test.js` checks both layouts and that a local pack stays out.
## Tests
`test/i18n-packs.test.js` (discovery of both layouts, broken / planned / duplicate packs, a pack dropped in or removed
while the server runs, the file allowlist, the manifest and app ranges, the fallback chain, placeholders, plurals,
the per-text game-text fallback, the client loader and menu, the tools, build-i18n `--lang` / `--dict`),
`test/package.test.js`, `test/ui/i18n.e2e.test.js`
(`SP_E2E=1`: a pack file and a pack folder dropped in while the server runs show in the menu and switch).
+63 -2
View File
@@ -34,7 +34,8 @@ redeploy, 赫默's drone at full SP, the countdown summons' 无敌 / 禁疗 / ti
DoT damage, 伺夜's 狼影 and the 吟游者 trait answered) → §25.16; the community reports of 2026-10-06, stream CB4 (enemies, bosses, waves, movement: the 突袭 landing on the 临时整备区 row and the other half of the DoT damage, 伺夜's 狼影 and the 吟游者 trait answered) → §25.16; the community reports of 2026-10-06, stream CB4 (enemies, bosses, waves, movement: the 突袭 landing on the 临时整备区 row and the other half of the
联防 map, a silenced 萨卡兹枯朽战士's death pollution, 萨卡兹穿刺手's speed after a stun and its first hit, 自制投石机 blocked on the 排气格栅; the briefing types' 联防 map, a silenced 萨卡兹枯朽战士's death pollution, 萨卡兹穿刺手's speed after a stun and its first hit, 自制投石机 blocked on the 排气格栅; the briefing types'
7–9 waves, 新硎's 断刃, a freed prisoner in 联防 and 鸭爵's two swaps in one wave already answered; the moving leaders' routes 7–9 waves, 新硎's 断刃, a freed prisoner in 联防 and 鸭爵's two swaps in one wave already answered; the moving leaders' routes
checked; 野鬃, 锏's pulls, 昆图斯's footprint and the leaders' attack rate not reproduced) → §25.18. checked; 野鬃, 锏's pulls, 昆图斯's footprint and the leaders' attack rate not reproduced) → §25.18; the rebindable shortcuts
→ §25.15; language packs, the first type of content packs (a language is a file in the language folder) → §25.16.
Decisions of the owner: 2026-10-04 — the server stays on plain `node:http` + `ws`, no web framework (§25.1); 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); 2026-10-05 — the 补位 plan (§25.3); the 自选编队 rules, the collab operators out of the pool, ISW-A refused (§25.4);
@@ -47,7 +48,8 @@ a melee 突袭 member lands where it can block (§25.11); 华法琳's granted tr
lock (§25.13.2); a newly merged elite 拉普兰德 counts as a new 拉普兰德 (§25.13.3); the leader pool = bloodPoint × the players lock (§25.13.2); a newly merged elite 拉普兰德 counts as a new 拉普兰德 (§25.13.3); the leader pool = bloodPoint × the players
alive at the fight's start, adopting PR #209 and replacing 「保持固定血量」 (§25.13.4); flying units at 1.3 tiles and the two alive at the fight's start, adopting PR #209 and replacing 「保持固定血量」 (§25.13.4); flying units at 1.3 tiles and the two
model quirks of PR #211 (§25.13.5); the bounty pays whenever its enemy dies (relayed with the community model quirks of PR #211 (§25.13.5); the bounty pays whenever its enemy dies (relayed with the community
reports, §25.14.3). reports, §25.14.3); 2026-10-07 — a language is a file in the language folder, and the language pack is the first type
of a general content-pack mechanism (§25.16).
### 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` ### 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`
@@ -1505,3 +1507,62 @@ briefing types' share of the waves).
卢西恩 attacks only its blocker and blinks past it. A leader and a situation from the reporter would show what differs. 卢西恩 attacks only its blocker and blinks past it. A leader and a situation from the reporter would show what differs.
- Golden: unchanged. Pinned: `test/sim/feedback5-cb4-pins.test.js` (the four interval leaders at ≥ max − 1 attacks). - Golden: unchanged. Pinned: `test/sim/feedback5-cb4-pins.test.js` (the four interval leaders at ≥ max − 1 attacks).
### 25.16 Language packs: a language is a file in the language folder, the first type of content packs (W5AD; the owner's decisions of 2026-10-07) — `shared/packs.js`, `shared/i18nPacks.js`, `shared/i18n.js`, `shared/i18nData.js`, `server/packs.js`, `server/http/static.js`, `public/js/ui/lang.js`, `public/js/data.js`, `tools/i18n.mjs`, `tools/packs.mjs`, `tools/build-i18n.mjs`, `tools/package.mjs`, docs/I18N.md §5, docs/PACKS.md
- **Seen**: after §25.2 English was wired in by name — the language list `['zh', 'en']`, the switch's labels, English
checks in the title screen, `fmtNum`, the tab title, a CSS rule, the overlay URL pattern — so another language meant
editing code, and players asked to translate into their own languages.
- **Decision**: the owner's decision of 2026-10-07 — putting an adapted language file into the language folder is all
it takes for the game to offer that language, so the community can add languages themselves; the same day, design the
language pack as the first type of a general content-pack mechanism (later: resource packs for art / audio, then
server-side data packs) with one manifest, one registry and one index.
- **Now** (docs/I18N.md §5 for translators, docs/PACKS.md for the mechanism):
- **A pack** is a manifest plus its files, in one of two layouts the same registry reads: the single file
`public/i18n/<code>.json` (manifest: its `_meta` block; optional game texts `data/i18n/<code>.json`) — the
translator's way and how English ships — or a folder `packs/<id>/pack.json` naming its files (every future type).
The manifest: type (`lang`; `assets` and `data` planned: parsed, listed as not supported, never served), version,
the app versions it targets (a range; outside it a language pack still loads and is flagged), its name in its own
language and in English, authors / credits / licence, and the type's fields (`lang`, `base`, `fallback`, `complete`,
`numberUnits`). English carries one like any pack (`complete: true`).
- **Discovery**: `server/packs.js` scans the language folders and `packs/` at start and again when their files
change (looked at on each index or pack-file request, at most once a second), validates the manifests, and answers
`GET /packs/index.json` (live, never cached; packs of supported types with the URLs of their files) and
`GET /packs/<id>/<file>` (only a file a manifest names, with its role's extension, inside the pack folder). A static
host uses `node tools/packs.mjs index` (packs/index.json); the release zips ship packs/ and that file. A broken
manifest, a planned type, a second pack for an id or a language: skipped with the reason in the server log and
`node tools/packs.mjs list`.
- **The client** lists every `lang` entry under its own name (title screen and 设置: buttons up to four languages, a
list beyond); `?lang=<code>` and the stored choice resolve to a listed pack (`en-US` → `en`; a pack that is gone →
Chinese); without an index the stored pack still loads by its code. Nothing in the client names English: the title
screen lays out by the script of the pack's title (`scriptOf`: an alphabetic title takes English's layout), the tab
title and `<html lang>` follow the pack, `fmtNum` takes `numberUnits` (Chinese 万 / 亿; without them K / M / B).
- **Fallback chain** per string: the pack → its base (`pt-BR` → `pt`; `zh-TW` none) → the packs `_meta.fallback`
names (English) → the Chinese msgid; game texts per text (each from the first overlay of the chain that has it:
`applyFileOverlay(json, overlay, onto)`, `data.setLocale(lang, chain)`).
- **Placeholders**: a translation that drops a plain placeholder of its msgid, uses one the caller does not pass, or
has a broken plural form is skipped like a missing one (the next language of the chain shows; a development page
warns once on the console; `tools/i18n.mjs check` checks the call sites). **Plurals**: `{n|one|other}` picks by
`Intl.PluralRules` (English exactly as before), and named categories serve other languages
(`{n|one:…|few:…|many:…|other:…}`).
- **Tools**: `node tools/i18n.mjs template <code>` (a skeleton: `_meta` and every msgid; on an existing pack only
the msgids it lacks), `check <code> | --all` (coverage; errors; a complete pack must have every msgid);
`node tools/build-i18n.mjs --lang ja | ko | zh-TW` builds a pack's game texts from the official JP / KR / TW client
(ArknightsAssets/ArknightsGamedata `jp/`, `kr/`, `tw/`, each with both 盟约 seasons, checked 2026-10-07: ja 96.5 %,
ko 97.4 %, zh-TW 91.1 % of 22 114 texts); a language without a client uses `--dict` (a community dictionary) or no
game texts at all (they follow the chain). No language but English ships: there is no Japanese, Korean or
Traditional Chinese UI pack yet, and a game-text file without its UI pack is not listed.
- **The output in Chinese and English did not change**: `test/i18n-packs.test.js` renders every msgid of
`public/i18n/en.json` in both languages with sample params and compares it with the renderer of 0.2.0 before packs;
`node tools/build-i18n.mjs --check` still finds `data/i18n/en.json` up to date. One English entry was added, the tab
title (`卫戍协议:盟约 · STRONGHOLD PROTOCOL`, the same text as before).
- [ASSUMED]: the menu turns into a list above four languages; two plain plural forms follow the language's rules (only
-1 renders differently from before in English, "-1 second"); a regional code falls back to its language's pack before
the declared fallbacks; with two packs for one language the language folder's file wins; a pack outside its app range
still loads; a development page is a dev build or one served from localhost.
- Golden: unchanged. Tests: `test/i18n-packs.test.js` (12: discovery of both layouts and of broken / planned /
duplicate packs, a pack dropped in or removed while the server runs, the file allowlist, manifests and app ranges,
the chain, placeholders, plurals in English and Russian, the per-text game-text fallback, the client loader and menu,
the template / check / index tools, build-i18n's `--lang` and `--dict`, zh / en unchanged), `test/package.test.js` (both layouts ship, a local pack does
not, the generated index), `test/ui/i18n.e2e.test.js` (`SP_E2E=1`: a pack file and a pack folder dropped in while the
server runs show in the menu and switch, English filling the gaps; a removed pack sends the stored choice back to
Chinese); `test/i18n.test.js`, `test/i18n-data.test.js`, `test/i18n-server.test.js` unchanged and green.
+28
View File
@@ -0,0 +1,28 @@
# packs/ — content packs
Put a content pack here as its own folder: `packs/<id>/pack.json` (the manifest) next to the files it names. The
server lists every pack it finds at `/packs/index.json` and picks up a new or changed pack on the next page load — no
restart, no build step. `node tools/packs.mjs list` shows what was found, what was skipped and why.
In this version one type loads: **`lang`**, a language for the interface (and, optionally, the game texts):
```json
{
"id": "ja",
"type": "lang",
"lang": "ja",
"name": "日本語",
"englishName": "Japanese",
"version": "1.0.0",
"app": ">=0.2.0",
"authors": ["your name"],
"fallback": ["en"],
"files": { "ui": "ui.json", "data": "data.json" }
}
```
A language can also be one file, `public/i18n/<code>.json`, with the same fields in its `_meta` block — that is how
English ships. Types `assets` (art, audio, fonts) and `data` (data patches) are planned; this version lists such a pack
as not supported and does not load it.
The format, the rules and how later types plug in: [docs/PACKS.md](../docs/PACKS.md). Translating: [docs/I18N.md](../docs/I18N.md).
+5 -3
View File
@@ -192,7 +192,7 @@
animation: rise-in 600ms 140ms var(--ease-out) backwards; animation: rise-in 600ms 140ms var(--ease-out) backwards;
} }
.title-cn__colon { color: #f4f6f5; } .title-cn__colon { color: #f4f6f5; }
/* English title (ui/lang.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */ /* a title in an alphabetic script (English …; screens/title.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */
.title-cn--latin { font-family: var(--font-display); font-size: .6rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; } .title-cn--latin { font-family: var(--font-display); font-size: .6rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
.title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); } .title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); }
.title-tag { .title-tag {
@@ -243,10 +243,12 @@
.title-invite .icon { width: .2rem; height: .2rem; } .title-invite .icon { width: .2rem; height: .2rem; }
.title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; } .title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); } .title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); }
/* language switch (中文 | English) in the top-right corner */ /* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */
.title-lang { display: inline-flex; margin-bottom: .1rem; } .title-lang { display: inline-flex; margin-bottom: .1rem; }
.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; } .title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
html[data-lang="en"] .title-login .btn--xl { letter-spacing: .3em; } .lang-select select { height: .38rem; min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: .16rem; cursor: pointer; }
.title-lang.lang-select select { height: .3rem; font-size: .14rem; }
html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
.title-conn .ping { height: .26rem; font-size: .15rem; } .title-conn .ping { height: .26rem; font-size: .15rem; }
.title-foot { .title-foot {
+15
View File
@@ -1,4 +1,18 @@
{ {
"_meta": {
"type": "lang",
"lang": "en",
"name": "English",
"englishName": "English",
"version": "0.2.0",
"app": ">=0.2.0",
"authors": [
"@YuriRestia (GitHub PR #70, the seed of these strings)",
"the Stronghold Protocol contributors"
],
"credits": "Game texts (data/i18n/en.json): the official EN client data (© Hypergryph / Yostar) via ArknightsAssets/ArknightsGamedata.",
"complete": true
},
"_about": "English UI strings: Chinese msgid → English (shared/i18n.js t(); docs/I18N.md). Placeholders {name} and {n|one|other} as in the msgid; a 'context::msgid' key is a tc() variant. Seeded from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (NOTICE.md). Game texts are not here: data/i18n/en.json (tools/build-i18n.mjs).", "_about": "English UI strings: Chinese msgid → English (shared/i18n.js t(); docs/I18N.md). Placeholders {name} and {n|one|other} as in the msgid; a 'context::msgid' key is a tc() variant. Seeded from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (NOTICE.md). Game texts are not here: data/i18n/en.json (tools/build-i18n.mjs).",
"你已在其他同盟中,请先离开当前同盟": "You are already in another Alliance. Leave it first.", "你已在其他同盟中,请先离开当前同盟": "You are already in another Alliance. Leave it first.",
"你已不在该同盟中": "You are no longer in this Alliance", "你已不在该同盟中": "You are no longer in this Alliance",
@@ -116,6 +130,7 @@
"请输入博士代号": "Please enter your Doctor callsign", "请输入博士代号": "Please enter your Doctor callsign",
"卫戍协议": "Stronghold Protocol", "卫戍协议": "Stronghold Protocol",
"盟约": "Alliance", "盟约": "Alliance",
"卫戍协议:盟约 · STRONGHOLD PROTOCOL": "Stronghold Protocol: Alliance · Web Simulation",
"调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。": "Allocate Funds and Operators, coordinate defenses with your allies, hold off wave after wave, and defeat the Enemy Leader.", "调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。": "Allocate Funds and Operators, coordinate defenses with your allies, hold off wave after wave, and defeat the Enemy Leader.",
"收到同盟邀请": "Alliance invite received", "收到同盟邀请": "Alliance invite received",
"· 输入代号后将自动加入": "· You'll join automatically after entering your callsign", "· 输入代号后将自动加入": "· You'll join automatically after entering your callsign",
+61 -26
View File
@@ -18,15 +18,18 @@
// Synchronous getters (getChess, getBond, …) return null until the file has loaded; use // Synchronous getters (getChess, getBond, …) return null until the file has loaded; use
// `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive. // `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive.
// //
// Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale('en')` downloads the overlay // Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale(lang, chain)` downloads (once each) the
// data/i18n/en.json once (tools/build-i18n.mjs: the official EN texts by record id and field, shared/i18nData.js) and // game-text overlays data/i18n/<code>.json of the chain — the language's own, its base's, a fallback's (ui/lang.js
// from then on `get`, `lookup` and `list` — and every getter built on them — return the records with their English // passes the languages of the pack's chain that have one; default: the language alone) — built by tools/build-i18n.mjs
// texts (a copy per file, built on first use; untranslated fields keep the Chinese text). `setLocale('zh')` goes back. // (the official texts by record id and field, shared/i18nData.js). From then on `get`, `lookup` and `list` — and every
// Each switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps // getter built on them — return the records with their texts in that language (a copy per file, built on first use):
// a Chinese game-data name to the current language (server messages name operators / items in Chinese). // each text from the first overlay of the chain that has it, else the Chinese one. `setLocale('zh')` goes back. Each
// switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps a
// Chinese game-data name to the current language (server messages name operators / items in Chinese).
import { useEffect, useReducer } from '../vendor/hooks.module.js'; import { useEffect, useReducer } from '../vendor/hooks.module.js';
import { applyFileOverlay } from '../../shared/i18nData.js'; import { applyFileOverlay } from '../../shared/i18nData.js';
import { canonicalLang } from '../../shared/i18nPacks.js';
/** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */ /** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */
export const DATA_FILES = Object.freeze({ export const DATA_FILES = Object.freeze({
@@ -134,6 +137,10 @@ export function createDataStore(opts = {}) {
const warned = new Set(); const warned = new Set();
/** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */ /** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */
let locale = 'zh'; let locale = 'zh';
/** The languages whose overlays apply, best first (a subset of the chain setLocale got: those that downloaded). */
let chain = [];
/** setLocale calls so far: a slower earlier call never overrides a later one. */
let switches = 0;
/** @type {Map<string, Promise<any>>} */ /** @type {Map<string, Promise<any>>} */
const overlayLoads = new Map(); const overlayLoads = new Map();
/** @type {Map<string, any>} */ /** @type {Map<string, any>} */
@@ -237,16 +244,22 @@ export function createDataStore(opts = {}) {
return entry.promise; return entry.promise;
} }
/** The localized copy of a ready file (null in Chinese or without an overlay for the file). */ /**
* The localized copy of a ready file (null in Chinese or without an overlay for the file): the overlays of the chain
* applied from the last to the first, each text checked against the Chinese file, so the best overlay wins per text.
*/
function localizedEntry(name) { function localizedEntry(name) {
if (locale === 'zh') return null; if (locale === 'zh' || !chain.length) return null;
const e = entries.get(name); const e = entries.get(name);
const fileOverlay = overlays.get(locale)?.files?.[name]; if (!e || e.status !== 'ready') return null;
if (!e || e.status !== 'ready' || !fileOverlay) return null; const fileOverlays = chain.map((c) => overlays.get(c)?.files?.[name]).filter(Boolean);
if (!fileOverlays.length) return null;
let l = localized.get(name); let l = localized.get(name);
if (!l || l.lang !== locale || l.base !== e.value) { if (!l || l.lang !== locale || l.base !== e.value) {
let value = e.value; let value = e.value;
try { value = applyFileOverlay(e.value, fileOverlay).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); } for (const fo of fileOverlays.reverse()) {
try { value = applyFileOverlay(e.value, fo, value).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); }
}
l = { lang: locale, base: e.value, value, index: null }; l = { lang: locale, base: e.value, value, index: null };
localized.set(name, l); localized.set(name, l);
} }
@@ -265,13 +278,16 @@ export function createDataStore(opts = {}) {
return e.index; return e.index;
} }
/** Download (once per language) the overlay data/i18n/<lang>.json; resolves to it, or null when unavailable. */ /**
function loadOverlay(lang) { * Download (once per language) a game-text overlay — `url` (a pack folder's file, from the pack index) or the language
* folder's data/i18n/<lang>.json; resolves to it, or null when unavailable.
*/
function loadOverlay(lang, url = `${base}i18n/${lang}.json`) {
if (overlayLoads.has(lang)) return overlayLoads.get(lang); if (overlayLoads.has(lang)) return overlayLoads.get(lang);
const p = (async () => { const p = (async () => {
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { try {
const res = await doFetch(`${base}i18n/${lang}.json`, { cache: 'no-cache' }); const res = await doFetch(url, { cache: 'no-cache' });
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null }); if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
const json = await res.json(); const json = await res.json();
if (!json || typeof json !== 'object' || !json.files) throw Object.assign(new Error('not an overlay'), { badJson: true }); if (!json || typeof json !== 'object' || !json.files) throw Object.assign(new Error('not an overlay'), { badJson: true });
@@ -279,7 +295,7 @@ export function createDataStore(opts = {}) {
return json; return json;
} catch (err) { } catch (err) {
if (transientFailure(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); continue; } if (transientFailure(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); continue; }
console.warn(`[data] ${base}i18n/${lang}.json unavailable (${err?.message || err}); game texts stay Chinese`); console.warn(`[data] ${url} unavailable (${err?.message || err}); its game texts are not applied`);
overlayLoads.delete(lang); // a later switch may try again overlayLoads.delete(lang); // a later switch may try again
return null; return null;
} }
@@ -333,33 +349,52 @@ export function createDataStore(opts = {}) {
listeners.add(fn); listeners.add(fn);
return () => listeners.delete(fn); return () => listeners.delete(fn);
}, },
/** Current game-text locale ('zh' or a language whose overlay is applied). */ /** Current game-text locale ('zh' or the language of the last switch). */
locale: () => locale, locale: () => locale,
/** The languages whose overlays apply now, best first (empty in Chinese). */
localeChain: () => [...chain],
/** /**
* Switch the game texts' language: 'zh' at once; another language once its overlay has downloaded (it stays Chinese * Switch the game texts' language: 'zh' at once; another language once the overlays of `chain` (default: the
* when the overlay is unavailable). Every loaded file's subscribers are notified. Resolves to the locale in effect. * language alone) have downloaded — those that fail are left out. When every overlay of a non-empty chain fails the
* locale does not change (the texts stay as they are); an empty chain (a pack without game texts) switches with
* the Chinese texts. Every loaded file's subscribers are notified. Resolves to the locale in effect.
* @param {string} lang * @param {string} lang
* @param {(string | { code: string, url?: string })[]} [chainIn] overlay languages, best first (with the URL of
* each overlay when it is not the language folder's data/i18n/<code>.json)
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async setLocale(lang) { async setLocale(lang, chainIn) {
const want = typeof lang === 'string' && /^[a-z]{2}$/.test(lang) ? lang : 'zh'; const code = canonicalLang(lang);
if (want !== 'zh' && !(await loadOverlay(want))) return locale; const want = code && code === lang ? code : 'zh';
if (want === locale) return locale; const items = (Array.isArray(chainIn) ? chainIn : [want]).map((c) => (typeof c === 'string' ? { code: c, url: undefined } : c));
const seen = new Set();
const asked = want === 'zh' ? [] : items.filter((c) => c && canonicalLang(c.code) === c.code && c.code !== 'zh' && !seen.has(c.code) && seen.add(c.code));
const ask = asked.map((c) => c.code);
const seq = ++switches;
const got = await Promise.all(asked.map((c) => loadOverlay(c.code, typeof c.url === 'string' && c.url ? c.url : undefined)));
if (seq !== switches) return locale; // a later switch is under way
const next = ask.filter((_, i) => got[i]);
if (ask.length && !next.length) return locale;
if (want === locale && next.join('|') === chain.join('|')) return locale;
locale = want; locale = want;
chain = next;
localized.clear(); localized.clear();
for (const name of [...entries.keys()]) notify(name); for (const name of [...entries.keys()]) notify(name);
return locale; return locale;
}, },
/** /**
* A Chinese game-data name (operator, item, bond, token …) in the current locale (itself in Chinese or when the * A Chinese game-data name (operator, item, bond, token …) in the current locale: the first overlay of the chain
* overlay does not know it). * that knows it, else the name itself (also in Chinese).
* @param {string} zh * @param {string} zh
* @returns {string} * @returns {string}
*/ */
localeName(zh) { localeName(zh) {
if (locale === 'zh' || typeof zh !== 'string') return zh; if (locale === 'zh' || typeof zh !== 'string') return zh;
const names = overlays.get(locale)?.names; for (const c of chain) {
return (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) || zh; const names = overlays.get(c)?.names;
if (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) return names[zh];
}
return zh;
}, },
}; };
} }
+10 -5
View File
@@ -1,5 +1,6 @@
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language switch // Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language menu
// (中文 | English, ui/lang.js; in English the big title is the English one and the small wordmark above it hides). // (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and
// the small wordmark above it hides).
// //
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it, // Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which // marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
@@ -20,6 +21,7 @@ import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js'; import { FullscreenButton, detectFeatures } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js'; import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js'; import { t, N_ } from '../../../shared/i18n.js';
import { scriptOf } from '../../../shared/i18nPacks.js';
import { GIcon } from '../ui/gameComponents.js'; import { GIcon } from '../ui/gameComponents.js';
import { SettingsModal } from '../ui/settings.js'; import { SettingsModal } from '../ui/settings.js';
@@ -187,7 +189,7 @@ const STATUS_TEXT = {
export function TitleScreen() { export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual); const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin); const pendingJoin = useStore((s) => s.ui.pendingJoin);
const lang = useLang(); useLang(); // re-render on a language switch
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || ''); const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const assetsSettled = useData('assets'); const assetsSettled = useData('assets');
@@ -215,6 +217,9 @@ export function TitleScreen() {
// touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view) // touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view)
const touchUi = useMemo(() => detectFeatures().coarse, []); const touchUi = useMemo(() => detectFeatures().coarse, []);
// a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it
// hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag)
const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic';
return html`<div class="screen title-screen"> return html`<div class="screen title-screen">
<div class=${`title-bg${bgLoaded ? ' has-art' : ''}${ridgesLoaded ? ' has-ridges' : ''}`} aria-hidden="true"> <div class=${`title-bg${bgLoaded ? ' has-art' : ''}${ridgesLoaded ? ' has-ridges' : ''}`} aria-hidden="true">
${backdrop ? html`<img class="title-bg__art" src=${backdrop} alt="" draggable=${false} ${backdrop ? html`<img class="title-bg__art" src=${backdrop} alt="" draggable=${false}
@@ -247,11 +252,11 @@ export function TitleScreen() {
<main class="title-main"> <main class="title-main">
<${Emblem} /> <${Emblem} />
${lang === 'en' ? null : html`<div class="title-en"> ${alphabetic ? null : html`<div class="title-en">
<span class="title-en__a">STRONGHOLD PROTOCOL</span> <span class="title-en__a">STRONGHOLD PROTOCOL</span>
<span class="title-en__b">ALLIANCE</span> <span class="title-en__b">ALLIANCE</span>
</div>`} </div>`}
<h1 class=${`title-cn${lang === 'en' ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${lang === 'en' ? ': ' : ':'}</span><em>${t('盟约')}</em></h1> <h1 class=${`title-cn${alphabetic ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${alphabetic ? ': ' : ':'}</span><em>${t('盟约')}</em></h1>
<p class="title-tag">${t('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}</p> <p class="title-tag">${t('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}</p>
<div class="title-login"> <div class="title-login">
+8 -6
View File
@@ -2,7 +2,7 @@
import { UF } from '../../../../shared/constants.js'; import { UF } from '../../../../shared/constants.js';
import { clamp, isObj, tileKey } from './shared.js'; import { clamp, isObj, tileKey } from './shared.js';
import { N_, getLang } from '../../../../shared/i18n.js'; import { N_, langInfo } from '../../../../shared/i18n.js';
// ---- players, statuses, fields ------------------------------------------------------------------------ // ---- players, statuses, fields ------------------------------------------------------------------------
@@ -71,20 +71,22 @@ export function attackInterval(bat, aspd = 100) {
} }
/** /**
* Compact number: 12345 → '12,345'; 1.5e6 → '150万' — in English the thousands-based units: 1.5e6 → '1.5M', 2.5e5 → * Compact number: 12345 → '12,345'; 1.5e6 → '150万' in Chinese, whose units count in 10⁴ / 10⁸ steps (万 / 亿; a language
* '250K', 3e9 → '3B' (万 / 亿 count in 10⁴ / 10⁸ steps, no template can move the decimal point). * pack names its own pair in `_meta.numberUnits`, shared/i18nPacks.js — no template can move the decimal point); a
* language without them uses the thousands-based units: 1.5e6 → '1.5M', 2.5e5 → '250K', 3e9 → '3B' (English).
*/ */
export function fmtNum(v) { export function fmtNum(v) {
const n = Number(v); const n = Number(v);
if (!Number.isFinite(n)) return '—'; if (!Number.isFinite(n)) return '—';
if (getLang() === 'en') { const units = langInfo()?.numberUnits;
if (!units) {
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(Math.abs(n) >= 1e10 ? 0 : 1)}B`; if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(Math.abs(n) >= 1e10 ? 0 : 1)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(Math.abs(n) >= 1e7 ? 0 : 1)}M`; if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(Math.abs(n) >= 1e7 ? 0 : 1)}M`;
if (Math.abs(n) >= 1e5) return `${Math.round(n / 1e3)}K`; if (Math.abs(n) >= 1e5) return `${Math.round(n / 1e3)}K`;
return Math.round(n).toLocaleString('en-US'); return Math.round(n).toLocaleString('en-US');
} }
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}亿`; // i18n-ignore: the Chinese units (English above) if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}${units[1]}`;
if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}万`; // i18n-ignore if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}${units[0]}`;
return Math.round(n).toLocaleString('en-US'); return Math.round(n).toLocaleString('en-US');
} }
+180 -41
View File
@@ -1,47 +1,90 @@
// Language switch of the client (docs/I18N.md): picks the language at boot, loads the UI translations // Language switch of the client (docs/I18N.md): lists the language packs — the "lang" entries of the pack index
// (public/i18n/<lang>.json, shared/i18n.js) and the localized game data (data/i18n/<lang>.json through data.js), keeps // /packs/index.json (shared/packs.js; the server's registry, server/packs.js, scans the language folders and the pack
// the choice, and translates what the server sends (m.toast / m.ticker / error codes). // folders; a static host serves the file `node tools/packs.mjs index` writes) — picks the language at boot, loads the UI
// strings of the chosen pack and of its fallback chain (shared/i18n.js; the URLs the index gives: /i18n/<code>.json or
// /packs/<id>/<file>) and its game texts (data.js), keeps the choice, and translates what the server sends (m.toast /
// m.ticker / error codes). No language is known by name here: a pack is a file in a folder (the owner's decision of
// 2026-10-07).
// //
// Chinese is the default; English is a switch (the owner's decision of 2026-10-05) — the browser's language is not // Chinese is the default (the owner's decision of 2026-10-05) — the browser's language is not consulted. Order at boot:
// consulted. Order at boot: `?lang=en|zh` in the URL (then removed from the address bar and kept as the choice), the // `?lang=<code>` in the URL (then removed from the address bar and kept as the choice), the stored choice (localStorage
// stored choice (localStorage `sp.pref.lang`), else Chinese. A switch re-renders the app in place (main.js App // `sp.pref.lang`), else Chinese; a code no pack has falls back to Chinese (`en-US` takes the `en` pack). A switch
// subscribes with useLang) — no reload; the game texts follow as soon as the data overlay has downloaded (data.js // re-renders the app in place (main.js App subscribes with useLang) — no reload; the game texts follow as soon as their
// notifies its subscribers). // overlays have downloaded (data.js notifies its subscribers). Without an index (a static host that lacks the file) the
// stored or requested pack still loads by its code, and the menu offers it beside Chinese.
import { useEffect, useState } from '../../vendor/hooks.module.js'; import { useEffect, useState } from '../../vendor/hooks.module.js';
import { LANGS, DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire } from '../../../shared/i18n.js'; import {
DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire, t,
registerLangs, getLangs, langInfo, langChain, onLangsChange, setI18nWarn,
} from '../../../shared/i18n.js';
import { canonicalLang, computeChain, scriptOf } from '../../../shared/i18nPacks.js';
import { PACKS_URL, PACK_INDEX_FILE, readPackIndex, langMetaOf } from '../../../shared/packs.js';
import { DEV_BUILD } from '../../../shared/constants.js';
import { loadPref, savePref } from '../store.js'; import { loadPref, savePref } from '../store.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { html } from './components.js'; import { html } from './components.js';
/** Labels of the language switch (each in its own language). */
export const LANG_LABELS = Object.freeze({ zh: '中文', en: 'English' }); // i18n-ignore: each language in its own name
/** The switch's own label, in both languages (whoever opens it may not read the current one). */ /** The switch's own label, in both languages (whoever opens it may not read the current one). */
const SWITCH_LABEL = 'Language / 语言'; // i18n-ignore const SWITCH_LABEL = 'Language / 语言'; // i18n-ignore
const PREF_KEY = 'lang'; const PREF_KEY = 'lang';
/** How long boot waits for the UI translations before rendering in Chinese anyway (they apply when they arrive). */ /** How long boot waits for the index and the UI strings before rendering in Chinese anyway (they apply when they arrive). */
const BOOT_WAIT_MS = 2500; const BOOT_WAIT_MS = 2500;
/** Up to this many languages the switch is a row of buttons (中文 | English …); more make it a list. */
export const SEGMENTED_MAX = 4;
/** @type {Map<string, Promise<boolean>>} */ /** @type {Map<string, Promise<boolean>>} */
const uiLoads = new Map(); const uiLoads = new Map();
/** @type {Promise<boolean> | null} */
let indexLoad = null;
/** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */
let indexOk = false;
const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`;
const defaultFetch = (...a) => globalThis.fetch(...a);
/** /**
* Download (once) the UI translations of a language. * Download (once) the pack index and register its language packs (shared/i18n.js registerLangs), so the menu lists
* them.
* @param {typeof fetch} [doFetch]
* @returns {Promise<boolean>} false when unavailable (the menu then offers Chinese and the stored language)
*/
export function loadLangIndex(doFetch = defaultFetch) {
if (indexLoad) return indexLoad;
indexLoad = (async () => {
try {
const res = await doFetch(INDEX_URL, { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
registerLangs(readPackIndex(await res.json(), 'lang').map(langMetaOf));
indexOk = true;
return true;
} catch (err) {
console.warn(`[i18n] ${INDEX_URL} unavailable (${err?.message || err}); the menu offers Chinese and the stored language`);
return false;
}
})();
return indexLoad;
}
/**
* Download (once) the UI strings of one language pack: the URL its index entry gives, else the language folder's file
* /i18n/<code>.json (whose `_meta` then registers it).
* @param {string} lang * @param {string} lang
* @param {typeof fetch} [doFetch] * @param {typeof fetch} [doFetch]
* @returns {Promise<boolean>} false when unavailable (the UI stays Chinese) * @returns {Promise<boolean>} false when unavailable (the interface stays as it is)
*/ */
export function loadUiMessages(lang, doFetch = (...a) => globalThis.fetch(...a)) { export function loadUiMessages(lang, doFetch = defaultFetch) {
if (lang === DEFAULT_LANG) return Promise.resolve(true); if (lang === DEFAULT_LANG) return Promise.resolve(true);
if (uiLoads.has(lang)) return uiLoads.get(lang); if (uiLoads.has(lang)) return uiLoads.get(lang);
const url = langInfo(lang)?.ui || `/i18n/${lang}.json`;
const p = (async () => { const p = (async () => {
try { try {
const res = await doFetch(`/i18n/${lang}.json`, { cache: 'no-cache' }); const res = await doFetch(url, { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`); if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
addMessages(lang, await res.json()); addMessages(lang, await res.json());
return true; return true;
} catch (err) { } catch (err) {
console.warn(`[i18n] /i18n/${lang}.json unavailable (${err?.message || err}); the interface stays Chinese`); console.warn(`[i18n] ${url} unavailable (${err?.message || err}); the interface stays as it is`);
uiLoads.delete(lang); uiLoads.delete(lang);
return false; return false;
} }
@@ -51,17 +94,44 @@ export function loadUiMessages(lang, doFetch = (...a) => globalThis.fetch(...a))
} }
/** /**
* The language to start in: the URL's `?lang=`, then the stored choice, then Chinese. * Load a pack and the packs of its chain (its base, its fallbacks — shared/i18n.js langChain). A fallback that fails
* only leaves its strings to the next language of the chain.
* @param {string} lang
* @param {typeof fetch} [doFetch]
* @returns {Promise<boolean>} whether the pack itself loaded
*/
export async function loadLangChain(lang, doFetch = defaultFetch) {
if (lang === DEFAULT_LANG) return true;
if (!(await loadUiMessages(lang, doFetch))) return false;
// without the index, the chain's codes are not registered yet: try them by the pack's own `_meta`
const chain = indexOk ? langChain(lang) : computeChain(lang, (c) => langInfo(c), () => true);
await Promise.all(chain.filter((c) => c !== lang).map((c) => loadUiMessages(c, doFetch)));
return true;
}
/**
* The game-text overlays that apply for `lang`, best first: the chain's packs that have one (the index's `files.data`;
* unknown without the index → the language folder's data/i18n/<code>.json is tried), with their URLs (data.js).
* @param {string} lang
* @returns {{ code: string, url?: string }[]}
*/
export const dataChain = (lang) => langChain(lang).filter((c) => langInfo(c)?.data !== false).map((c) => ({ code: c, url: langInfo(c)?.dataUrl }));
/**
* The language to start in: the URL's `?lang=`, then the stored choice, then Chinese. `tentative`: also a well-formed
* code no pack is known for (boot without an index tries to load it).
* @param {string} [search] location.search * @param {string} [search] location.search
* @param {(key: string, fallback: any) => any} [load] * @param {(key: string, fallback: any) => any} [load]
* @param {{ tentative?: boolean }} [opts]
* @returns {{ lang: string, fromUrl: boolean }} * @returns {{ lang: string, fromUrl: boolean }}
*/ */
export function initialLang(search = globalThis.location?.search || '', load = loadPref) { export function initialLang(search = globalThis.location?.search || '', load = loadPref, { tentative = false } = {}) {
const pick = (v) => normalizeLang(v) || (tentative ? canonicalLang(v) : null);
let fromUrl = null; let fromUrl = null;
try { fromUrl = normalizeLang(new URLSearchParams(search).get('lang')); } catch { /* ignore */ } try { fromUrl = pick(new URLSearchParams(search).get('lang')); } catch { /* ignore */ }
if (fromUrl) return { lang: fromUrl, fromUrl: true }; if (fromUrl) return { lang: fromUrl, fromUrl: true };
let stored = null; let stored = null;
try { stored = normalizeLang(load(PREF_KEY, null)); } catch { /* ignore */ } try { stored = pick(load(PREF_KEY, null)); } catch { /* ignore */ }
return { lang: stored || DEFAULT_LANG, fromUrl: false }; return { lang: stored || DEFAULT_LANG, fromUrl: false };
} }
@@ -74,46 +144,76 @@ function stripLangParam() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
/** <html lang> and the tab title follow the language. */ /** The `lang` attribute of a language: its code ('zh-CN' for the Chinese source). @param {string} code */
export const htmlLang = (code) => (code === DEFAULT_LANG ? 'zh-CN' : code);
/**
* <html lang>, data-lang, data-script (the title screen's layout: CJK or alphabetic, from the pack's title) and the tab
* title follow the language.
*/
function applyDocument(lang) { function applyDocument(lang) {
const doc = globalThis.document; const doc = globalThis.document;
if (!doc) return; if (!doc) return;
doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN'; doc.documentElement.lang = htmlLang(lang);
doc.documentElement.dataset.lang = lang; doc.documentElement.dataset.lang = lang;
doc.title = lang === 'en' ? 'Stronghold Protocol: Alliance · Web Simulation' : '卫戍协议:盟约 · STRONGHOLD PROTOCOL'; // i18n-ignore: one title per language doc.documentElement.dataset.script = scriptOf(t('卫戍协议'));
doc.title = t('卫戍协议:盟约 · STRONGHOLD PROTOCOL');
} }
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names) // `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
setNameResolver((name) => data.localeName(name)); setNameResolver((name) => data.localeName(name));
/** A development page (a dev build, or served from this machine): report translations t() skips (shared/i18n.js). */
function isDevPage() {
if (DEV_BUILD) return true;
try {
const h = String(globalThis.location?.hostname || '');
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1' || h.endsWith('.localhost');
} catch { return false; }
}
let wired = false; let wired = false;
function wire() { function wire() {
if (wired) return; if (wired) return;
wired = true; wired = true;
onLangChange((lang) => { applyDocument(lang); }); onLangChange((lang) => { applyDocument(lang); });
if (isDevPage()) {
setI18nWarn(({ lang, key, problems }) => console.warn(`[i18n] ${lang}: the translation of "${key}" is skipped (${problems.join('; ')}); the next language of the chain shows`));
}
}
/** Make a loaded language current: the interface now, the game texts once their overlays are in. */
function applyLang(lang) {
setLang(lang);
data.setLocale(getLang(), dataChain(getLang())).catch(() => {});
} }
/** /**
* Boot: choose the language and load its UI translations (waits at most BOOT_WAIT_MS) before the first render; the * Boot: list the packs, choose the language and load its UI strings (waits at most BOOT_WAIT_MS) before the first
* game-data overlay downloads in the background. * render; the game-text overlays download in the background.
* @returns {Promise<string>} the language in effect * @returns {Promise<string>} the language in effect
*/ */
export async function initLang() { export async function initLang() {
wire(); wire();
const { lang, fromUrl } = initialLang();
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
applyDocument(getLang()); applyDocument(getLang());
if (lang === DEFAULT_LANG) return getLang(); const TIMEOUT = Symbol('timeout');
const ok = await Promise.race([loadUiMessages(lang), new Promise((r) => setTimeout(() => r(null), BOOT_WAIT_MS))]); const boot = (async () => {
if (ok === null) loadUiMessages(lang).then((loaded) => { if (loaded && normalizeLang(loadPref(PREF_KEY, null)) === lang) setLang(lang); }); const listed = await loadLangIndex();
else if (ok) setLang(lang); const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed });
data.setLocale(lang).catch(() => {}); if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
if (lang === DEFAULT_LANG) return null;
return (await loadLangChain(lang)) ? lang : null;
})();
const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]);
if (got === TIMEOUT) {
boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
} else if (got) applyLang(got);
return getLang(); return getLang();
} }
/** /**
* Switch the language (the switch on the title screen and in 设置): keeps the choice, loads the translations, then * Switch the language (the menu on the title screen and in 设置): keeps the choice, loads the pack and its chain, then
* re-renders; the game texts follow when their overlay has downloaded. * re-renders; the game texts follow when their overlays have downloaded.
* @param {string} lang * @param {string} lang
* @returns {Promise<string>} the language in effect * @returns {Promise<string>} the language in effect
*/ */
@@ -121,9 +221,8 @@ export async function switchLang(lang) {
wire(); wire();
const want = normalizeLang(lang) || DEFAULT_LANG; const want = normalizeLang(lang) || DEFAULT_LANG;
savePref(PREF_KEY, want); savePref(PREF_KEY, want);
if (want !== DEFAULT_LANG && !(await loadUiMessages(want))) return getLang(); if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang();
setLang(want); applyLang(want);
data.setLocale(want).catch(() => {});
return getLang(); return getLang();
} }
@@ -141,14 +240,54 @@ export function useLang() {
} }
/** /**
* The language switch (segmented 中文 | English). * Preact hook: the known languages (shared/i18n.js getLangs); re-renders when the index or a pack arrives.
* @returns {ReturnType<typeof getLangs>}
*/
export function useLangs() {
const [list, setList] = useState(getLangs);
useEffect(() => {
setList(getLangs());
return onLangsChange(() => setList(getLangs()));
}, []);
return list;
}
/**
* What the language menu shows: every known language under its own name (the English name as a tooltip when it reads
* differently), current one marked — a row of buttons up to SEGMENTED_MAX languages, a list beyond.
* @param {ReturnType<typeof getLangs>} langs
* @param {string} current
* @returns {{ kind: 'buttons' | 'select', items: { code: string, label: string, title: string, htmlLang: string, on: boolean }[] }}
*/
export function langMenuModel(langs, current) {
const items = (Array.isArray(langs) ? langs : []).filter(Boolean).map((m) => ({
code: m.code,
label: m.name || m.code,
title: m.englishName && m.englishName !== m.name ? m.englishName : '',
htmlLang: htmlLang(m.code),
on: m.code === current,
}));
return { kind: items.length > SEGMENTED_MAX ? 'select' : 'buttons', items };
}
/**
* The language menu: 中文 | English | … as buttons, or a list when there are many packs.
* @param {{ class?: string }} props * @param {{ class?: string }} props
*/ */
export function LangToggle({ class: cls }) { export function LangToggle({ class: cls }) {
const lang = useLang(); const lang = useLang();
const menu = langMenuModel(useLangs(), lang);
const pick = (code) => { if (code && code !== getLang()) switchLang(code); };
if (menu.kind === 'select') {
return html`<label class=${`set-seg lang-toggle lang-select${cls ? ` ${cls}` : ''}`} data-testid="lang-toggle">
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value)}>
${menu.items.map((it) => html`<option key=${it.code} value=${it.code} lang=${it.htmlLang} title=${it.title || undefined} selected=${it.on}>${it.label}</option>`)}
</select>
</label>`;
}
return html`<div class=${`set-seg lang-toggle${cls ? ` ${cls}` : ''}`} role="radiogroup" aria-label=${SWITCH_LABEL} data-testid="lang-toggle"> return html`<div class=${`set-seg lang-toggle${cls ? ` ${cls}` : ''}`} role="radiogroup" aria-label=${SWITCH_LABEL} data-testid="lang-toggle">
${LANGS.map((l) => html`<button key=${l} type="button" role="radio" lang=${l === 'zh' ? 'zh-CN' : 'en'} aria-checked=${lang === l ? 'true' : 'false'} ${menu.items.map((it) => html`<button key=${it.code} type="button" role="radio" lang=${it.htmlLang} aria-checked=${it.on ? 'true' : 'false'}
class=${lang === l ? 'is-on' : ''} data-lang=${l} onClick=${() => { if (lang !== l) switchLang(l); }}>${LANG_LABELS[l]}</button>`)} class=${it.on ? 'is-on' : ''} data-lang=${it.code} title=${it.title || undefined} onClick=${() => pick(it.code)}>${it.label}</button>`)}
</div>`; </div>`;
} }
+5 -4
View File
@@ -4,8 +4,8 @@
// * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private // * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private
// peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy; // peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy;
// * DEBUG → the console logger's debug level; // * DEBUG → the console logger's debug level;
// * the served directories (public/, data/, shared/ of this repository unless the options name others), and which // * the served directories (public/, data/, shared/ and the content packs' packs/ of this repository unless the
// startServer() options are handed on to net.js Network and lobby.js Lobby. // options name others), and which startServer() options are handed on to net.js Network and lobby.js Lobby.
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -34,14 +34,15 @@ export function listenAddress(opts) {
} }
/** /**
* The directories the static server reads. * The directories the static server reads (packsDir: the pack folders, server/packs.js).
* @param {{ publicDir?: string, dataDir?: string, sharedDir?: string }} opts * @param {{ publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string }} opts
*/ */
export function serveDirs(opts) { export function serveDirs(opts) {
return { return {
publicDir: opts.publicDir || path.join(ROOT, 'public'), publicDir: opts.publicDir || path.join(ROOT, 'public'),
dataDir: opts.dataDir || path.join(ROOT, 'data'), dataDir: opts.dataDir || path.join(ROOT, 'data'),
sharedDir: opts.sharedDir || path.join(ROOT, 'shared'), sharedDir: opts.sharedDir || path.join(ROOT, 'shared'),
packsDir: opts.packsDir || path.join(ROOT, 'packs'),
}; };
} }
+21 -3
View File
@@ -12,6 +12,9 @@
// /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 — the same audio files, addressed // /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 — the same audio files, addressed
// **without** an extension so download managers (IDM / 迅雷 …) stop popping a // **without** an extension so download managers (IDM / 迅雷 …) stop popping a
// "下载文件信息" dialog for every BGM track (media.js, shared/media.js, public/js/media.js) // "下载文件信息" dialog for every BGM track (media.js, shared/media.js, public/js/media.js)
// /packs/index.json → the content packs of this server (server/packs.js: the language packs of
// public/i18n/ and the pack folders of packs/, re-read when they change; never cached)
// /packs/<id>/<file> → a file of a pack folder — only one its manifest names (server/packs.js servable)
// //
// Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent // Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent
// data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile. // data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile.
@@ -20,9 +23,11 @@ import fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { MEDIA_PREFIX } from '../../shared/media.js'; import { MEDIA_PREFIX } from '../../shared/media.js';
import { ROOT, noopLog } from './config.js'; import { ROOT, noopLog } from './config.js';
import { sendError } from './common.js'; import { sendError, sendJson } from './common.js';
import { MIME, GzipCache, isNotModified, serveFile } from './files.js'; import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
import { serveMedia } from './media.js'; import { serveMedia } from './media.js';
import { createPackRegistry } from '../packs.js';
import { PACKS_URL, PACK_INDEX_FILE } from '../../shared/packs.js';
/** Browser stand-in of server/data.js, served at /data.js (see the header). Served byte for byte: its first line names the entry, server/index.js. */ /** Browser stand-in of server/data.js, served at /data.js (see the header). Served byte for byte: its first line names the entry, server/index.js. */
export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat). export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat).
@@ -45,10 +50,13 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
/** /**
* Create the static request handler. * Create the static request handler.
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, log?: object }} dirs * @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
* packs?: ReturnType<typeof createPackRegistry>, log?: object }} dirs
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs)
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>} * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
*/ */
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), log = noopLog }) { export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog }) {
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
const mounts = [ const mounts = [
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) }, { prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
{ prefix: '/shared/', name: 'shared', dir: path.resolve(sharedDir) }, { prefix: '/shared/', name: 'shared', dir: path.resolve(sharedDir) },
@@ -74,6 +82,16 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
res.end(req.method === 'HEAD' ? undefined : shimBody); res.end(req.method === 'HEAD' ? undefined : shimBody);
return; return;
} }
// Content packs (server/packs.js): the live index, and only the files a pack's manifest names
if (decoded.startsWith(PACKS_URL)) {
const parts = decoded.slice(PACKS_URL.length).split('/');
if (parts.length === 1 && parts[0] === PACK_INDEX_FILE) { sendJson(req, res, 200, registry.index()); return; }
const abs = parts.length >= 2 && !parts.some((s) => s === '' || s === '.' || s === '..') ? registry.servable(parts[0], parts.slice(1).join('/')) : null;
const st = abs ? await fsp.stat(abs).catch(() => null) : null;
if (!st || !st.isFile()) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; }
await serveFile(req, res, abs, st, 'packs', parts, query, gzipCache, log);
return;
}
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3 // Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
if (decoded.startsWith(MEDIA_PREFIX)) { if (decoded.startsWith(MEDIA_PREFIX)) {
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log); await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log);
+12 -6
View File
@@ -5,7 +5,8 @@
// which startServer() options go to net.js / lobby.js, the console logger // which startServer() options go to net.js / lobby.js, the console logger
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB; // http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
// refused at upgrade with 404 / 429 per network / 503) // refused at upgrade with 404 / 429 per network / 503)
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in // http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension) // http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
// http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges // http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js) // http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
@@ -24,6 +25,7 @@ import { getData, loadData } from './data.js';
import { ROOT, listenAddress, serveDirs, makeLogger, parseTrustProxy } from './http/config.js'; import { ROOT, listenAddress, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js'; import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js'; import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
import { createPackRegistry } from './packs.js';
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js'; import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js'; import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
import { createRequestHandler } from './http/routes.js'; import { createRequestHandler } from './http/routes.js';
@@ -40,7 +42,7 @@ export {
* Build and start the HTTP + WebSocket server. * Build and start the HTTP + WebSocket server.
* @param {{ * @param {{
* port?: number, host?: string, quiet?: boolean, log?: object, * port?: number, host?: string, quiet?: boolean, log?: object,
* publicDir?: string, dataDir?: string, sharedDir?: string, * publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string,
* MatchClass?: Function, seedFn?: () => number, * MatchClass?: Function, seedFn?: () => number,
* lobbyGraceMs?: number, reconnectWindowMs?: number, heartbeatMs?: number, helloTimeoutMs?: number, * lobbyGraceMs?: number, reconnectWindowMs?: number, heartbeatMs?: number, helloTimeoutMs?: number,
* ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number, * ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number,
@@ -49,17 +51,21 @@ export {
* }} [opts] * }} [opts]
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer, * @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network, * lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
* registry: import('./net.js').SessionRegistry, close: () => Promise<void> }>} * registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
* close: () => Promise<void> }>}
*/ */
export async function startServer(opts = {}) { export async function startServer(opts = {}) {
const { port, host } = listenAddress(opts); const { port, host } = listenAddress(opts);
const log = opts.log || makeLogger(!!opts.quiet); const log = opts.log || makeLogger(!!opts.quiet);
const { publicDir, dataDir, sharedDir } = serveDirs(opts); const { publicDir, dataDir, sharedDir, packsDir } = serveDirs(opts);
// The process-wide singleton serves the default data dir; a custom dir (tests) gets its own copy. // The process-wide singleton serves the default data dir; a custom dir (tests) gets its own copy.
const data = opts.dataDir ? loadData(dataDir, { log }) : getData({ dir: dataDir, log }); const data = opts.dataDir ? loadData(dataDir, { log }) : getData({ dir: dataDir, log });
const { registry, lobby, network } = createSessionStack(opts, { data, log }); const { registry, lobby, network } = createSessionStack(opts, { data, log });
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, log }); // content packs (docs/PACKS.md): scanned now — the start log names them — and again whenever their folders change
const packs = createPackRegistry({ publicDir, dataDir, packsDir }, { log });
packs.refresh(true);
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log });
const startedAt = Date.now(); const startedAt = Date.now();
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz. // The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
resetBuildTag(); resetBuildTag();
@@ -103,7 +109,7 @@ export async function startServer(opts = {}) {
return closing; return closing;
} }
return { port: actualPort, host, url, server, wss, lobby, network, registry, close }; return { port: actualPort, host, url, server, wss, lobby, network, registry, packs, close };
} }
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js). // `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
+245
View File
@@ -0,0 +1,245 @@
// server/packs.js — this server's content packs (docs/PACKS.md; the format: shared/packs.js). Discovers
//
// * folder packs: packs/<id>/pack.json + the files its manifest names (any type);
// * single-file language packs: public/i18n/<code>.json (manifest: its `_meta`) + data/i18n/<code>.json when present;
//
// validates them, and answers the pack index (GET /packs/index.json, server/http/static.js) and which pack files may be
// served (GET /packs/<id>/<file>: only a file that the manifest of a supported folder pack names, with its role's
// extension, inside the pack folder — nothing else there, ever; a single-file pack's files are the language folders' own,
// /i18n/ and /data/i18n/). The folders are re-read when something in them changes (checked when the index or a pack
// file is asked for, at most once a second: a few stat calls), so a language pack dropped in shows on the next page
// load — no restart, no build step. A pack of a planned type, a broken manifest, a second pack with the same id or
// language: reported (`skipped`: the start log, `node tools/packs.mjs list`), never served.
//
// Extension point (docs/PACKS.md "Adding a pack type"): a new type gets its PACK_TYPES entry (shared/packs.js) and a
// loader that reads `list(type)` — a client-side type through the index, a server-side one (type "data") once at start
// (`live: false`: the simulation's data is fixed for the life of the process).
import fs from 'node:fs';
import path from 'node:path';
import { APP_VERSION } from '../shared/constants.js';
import { PACK_TYPES, PACK_MANIFEST, PACKS_URL, normalizeManifest, packIndexEntry, buildPackIndex } from '../shared/packs.js';
import { SOURCE_LANG, isLangCode, countStrings } from '../shared/i18nPacks.js';
/** The language folders' subdirectory (public/i18n/, data/i18n/). */
export const LANG_DIR = 'i18n';
/** Largest manifest / UI file read (bytes): a pack file is small text. */
const MAX_JSON_BYTES = 8 * 1024 * 1024;
const noop = () => {};
/**
* @typedef {{ rel: string, abs: string, url: string }} PackFile
* @typedef {{ id: string, type: string, layout: 'file' | 'folder', where: string, supported: boolean,
* manifest: import('../shared/packs.js').PackManifest, files: Record<string, PackFile>, strings?: number }} Pack
* @typedef {{ where: string, problems: string[] }} Skipped
*/
const isFile = (abs) => { try { return fs.statSync(abs).isFile(); } catch { return false; } };
const readDirSorted = (dir) => { try { return fs.readdirSync(dir).filter((n) => !n.startsWith('.')).sort(); } catch { return []; } };
/** A JSON file of at most MAX_JSON_BYTES, or { error }. */
function readJson(abs) {
try {
const st = fs.statSync(abs);
if (!st.isFile()) return { error: 'not a file' };
if (st.size > MAX_JSON_BYTES) return { error: `larger than ${MAX_JSON_BYTES >> 20} MB` };
return { json: JSON.parse(fs.readFileSync(abs, 'utf8')) };
} catch (e) {
return { error: e instanceof SyntaxError ? `not valid JSON (${e.message})` : String(e?.code || e?.message || e) };
}
}
/** Whether `abs` is inside `dir` once symbolic links are resolved (a pack file never leads out of its folder). */
function inside(dir, abs) {
try {
const d = fs.realpathSync(dir);
const f = fs.realpathSync(abs);
return f.startsWith(d + path.sep);
} catch { return false; }
}
const fileUrl = (id, rel) => `${PACKS_URL}${encodeURIComponent(id)}/${rel.split('/').map(encodeURIComponent).join('/')}`;
/**
* Scan the pack folders once.
* @param {{ publicDir: string, dataDir: string, packsDir: string }} dirs
* @param {{ app?: string }} [opts]
* @returns {{ packs: Pack[], skipped: Skipped[], warnings: { where: string, warning: string }[] }}
*/
export function scanPacks({ publicDir, dataDir, packsDir }, { app = APP_VERSION } = {}) {
/** @type {Pack[]} */
const found = [];
/** @type {Skipped[]} */
const skipped = [];
const warnings = [];
const warn = (where, list) => { for (const w of list) warnings.push({ where, warning: w }); };
// single-file language packs: public/i18n/<code>.json
const uiDir = path.join(publicDir, LANG_DIR);
for (const name of readDirSorted(uiDir)) {
if (!name.endsWith('.json')) continue;
const code = name.slice(0, -5);
const where = `public/${LANG_DIR}/${name}`;
if (!isLangCode(code)) { skipped.push({ where, problems: [`"${code}" is not a language code in its usual case (en, ja, zh-TW, pt-BR …)`] }); continue; }
if (code === SOURCE_LANG) { skipped.push({ where, problems: ['"zh" is the source language (the msgids), never a pack'] }); continue; }
const abs = path.join(uiDir, name);
const r = readJson(abs);
if (r.error) { skipped.push({ where, problems: [r.error] }); continue; }
if (!r.json || typeof r.json !== 'object' || Array.isArray(r.json)) { skipped.push({ where, problems: ['not a JSON object of msgid → translation'] }); continue; }
const { manifest, problems, warnings: w } = normalizeManifest(r.json._meta, { id: code, type: 'lang', lang: code, app });
warn(where, w);
if (!manifest) { skipped.push({ where, problems }); continue; }
/** @type {Record<string, PackFile>} */
const files = { ui: { rel: name, abs, url: `/${LANG_DIR}/${name}` } };
const dataAbs = path.join(dataDir, LANG_DIR, name);
if (isFile(dataAbs)) files.data = { rel: name, abs: dataAbs, url: `/data/${LANG_DIR}/${name}` };
found.push({ id: code, type: 'lang', layout: 'file', where, supported: true, manifest, files, strings: countStrings(r.json) });
}
// folder packs: packs/<id>/pack.json
for (const id of readDirSorted(packsDir)) {
const dir = path.join(packsDir, id);
let st;
try { st = fs.statSync(dir); } catch { continue; }
if (!st.isDirectory()) continue;
const where = `packs/${id}/`;
const man = path.join(dir, PACK_MANIFEST);
if (!isFile(man)) { skipped.push({ where, problems: [`no ${PACK_MANIFEST}`] }); continue; }
const r = readJson(man);
if (r.error) { skipped.push({ where, problems: [`${PACK_MANIFEST}: ${r.error}`] }); continue; }
const { manifest, problems, warnings: w } = normalizeManifest(r.json, { id, folder: true, app });
warn(where, w);
if (!manifest) { skipped.push({ where, problems }); continue; }
const kind = PACK_TYPES[manifest.type];
if (kind.status !== 'supported') {
skipped.push({ where, problems: [`type "${manifest.type}" is planned (${kind.summary}), not loaded by this version`] });
continue;
}
/** @type {Record<string, PackFile>} */
const files = {};
const missing = [];
for (const [role, rel] of Object.entries(manifest.files)) {
const abs = path.join(dir, ...rel.split('/'));
if (!isFile(abs)) missing.push(`files.${role}: ${rel} is missing`);
else if (!inside(dir, abs)) missing.push(`files.${role}: ${rel} leads out of the pack folder`);
else files[role] = { rel, abs, url: fileUrl(id, rel) };
}
if (missing.length) { skipped.push({ where, problems: missing }); continue; }
/** @type {Pack} */
const pack = { id, type: manifest.type, layout: 'folder', where, supported: true, manifest, files };
if (manifest.type === 'lang') {
const ui = readJson(files.ui.abs);
if (ui.error || !ui.json || typeof ui.json !== 'object' || Array.isArray(ui.json)) {
skipped.push({ where, problems: [`files.ui: ${ui.error || 'not a JSON object of msgid → translation'}`] });
continue;
}
pack.strings = countStrings(ui.json);
}
found.push(pack);
}
// one pack per id (any case: one folder on Windows / macOS) and per language; the language folders' files first
const packs = [];
const ids = new Map();
const langs = new Map();
for (const p of found) {
const idKey = p.id.toLowerCase();
if (ids.has(idKey)) { skipped.push({ where: p.where, problems: [`the id "${p.id}" is taken by ${ids.get(idKey)}`] }); continue; }
if (p.type === 'lang' && langs.has(p.manifest.lang)) {
skipped.push({ where: p.where, problems: [`the language ${p.manifest.lang} is already provided by ${langs.get(p.manifest.lang)}`] });
continue;
}
ids.set(idKey, p.where);
if (p.type === 'lang') langs.set(p.manifest.lang, p.where);
packs.push(p);
}
return { packs, skipped, warnings };
}
/**
* The index body of scanned packs (shared/packs.js buildPackIndex).
* @param {Pack[]} packs
* @param {{ app?: string }} [opts]
*/
export function packIndexOf(packs, { app = APP_VERSION } = {}) {
const entries = packs.filter((p) => p.supported).map((p) => packIndexEntry(p.manifest, {
files: Object.fromEntries(Object.entries(p.files).map(([role, f]) => [role, f.url])),
strings: p.strings,
}));
return buildPackIndex(entries, { app });
}
/** What changes when a pack is added, removed or edited: names, sizes and times of the files the scan reads. */
function signature({ publicDir, dataDir, packsDir }) {
const parts = [];
const add = (dir, depth, tag, namesOnly = false) => {
for (const n of readDirSorted(dir)) {
const abs = path.join(dir, n);
let st;
try { st = fs.statSync(abs); } catch { continue; }
if (st.isDirectory()) {
if (depth > 0) { parts.push(`${tag}${n}/`); add(abs, depth - 1, `${tag}${n}/`); }
continue;
}
parts.push(namesOnly ? `${tag}${n}` : `${tag}${n}:${st.size}:${Math.floor(st.mtimeMs)}`);
}
};
add(path.join(publicDir, LANG_DIR), 0, 'u:');
add(path.join(dataDir, LANG_DIR), 0, 'd:', true); // a game-text file only counts by its presence
add(packsDir, 3, 'p:');
return parts.join('\n');
}
/**
* The registry: the scan, re-done when the folders change (see the header).
* @param {{ publicDir: string, dataDir: string, packsDir: string }} dirs
* @param {{ log?: { info: Function, warn: Function }, app?: string, recheckMs?: number }} [opts]
* recheckMs: how long a scan stays trusted before the folders are looked at again (default 1000; 0 = every call)
*/
export function createPackRegistry(dirs, { log = { info: noop, warn: noop }, app = APP_VERSION, recheckMs = 1000 } = {}) {
let sig = null;
let checkedAt = -Infinity;
/** @type {ReturnType<typeof scanPacks> & { index: ReturnType<typeof packIndexOf>, byId: Map<string, Pack> }} */
let state = null;
const told = new Set();
const tell = (fn, line) => { if (!told.has(line)) { told.add(line); fn(line); } };
function refresh(force = false) {
const now = Date.now();
if (state && !force && now - checkedAt < recheckMs) return state;
checkedAt = now;
const s = signature(dirs);
if (state && s === sig) return state;
const first = !state;
sig = s;
const scan = scanPacks(dirs, { app });
state = { ...scan, index: packIndexOf(scan.packs, { app }), byId: new Map(scan.packs.map((p) => [p.id, p])) };
const line = `[packs] ${scan.packs.length ? scan.packs.map((p) => `${p.type} ${p.type === 'lang' ? p.manifest.lang : p.id} (${p.manifest.name})`).join(', ') : 'none'}`;
if (first || scan.packs.length) tell((m) => log.info(m), line);
for (const k of scan.skipped) tell((m) => log.warn(m), `[packs] ${k.where} skipped: ${k.problems.join('; ')}`);
for (const w of scan.warnings) tell((m) => log.warn(m), `[packs] ${w.where}: ${w.warning}`);
return state;
}
return {
/** Re-read the folders now if they changed (force: even within recheckMs). */
refresh: (force = false) => { refresh(force); },
/** The loaded packs, optionally of one type. @param {string} [type] @returns {Pack[]} */
list: (type) => refresh().packs.filter((p) => !type || p.type === type),
/** The index body (GET /packs/index.json). */
index: () => refresh().index,
/** Packs that were not loaded and why, and the warnings of the loaded ones. */
report: () => { const s = refresh(); return { skipped: s.skipped, warnings: s.warnings }; },
/**
* The file behind /packs/<id>/<rel>, or null: only a file a supported folder pack names.
* @param {string} id
* @param {string} rel
*/
servable(id, rel) {
const p = refresh().byId.get(id);
if (!p || !p.supported || p.layout !== 'folder') return null;
for (const f of Object.values(p.files)) if (f.rel === rel) return f.abs;
return null;
},
};
}
+299 -49
View File
@@ -2,51 +2,157 @@
// //
// The Chinese source string is the message id: `t('整备区已满')`, `t('还剩 {n} 秒', { n })`. A message without a // The Chinese source string is the message id: `t('整备区已满')`, `t('还剩 {n} 秒', { n })`. A message without a
// translation in the current language falls back to the Chinese text, so an untranslated string is never blank. // translation in the current language falls back to the Chinese text, so an untranslated string is never blank.
// Chinese ('zh') is the default language; English ('en') is a switch (the owner's decision of 2026-10-05: an English // Chinese ('zh') is the default language; every other language is a pack (shared/i18nPacks.js): a file in
// browser still opens in Chinese until the player picks English). docs/I18N.md explains how to add and translate // public/i18n/ (or a pack folder, shared/packs.js) that the client lists from /packs/index.json and loads on demand
// strings. // (ui/lang.js). Nothing here knows a language by name (the owner's decisions of 2026-10-05 and 2026-10-07: Chinese by
// default, a language is a file in the language folder). docs/I18N.md explains how to add and translate strings and how
// to add a language.
//
// Fallback chain per string: the chosen pack → its base (pt-BR → pt; zh-TW has none) → the packs its `_meta.fallback`
// names (e.g. English) → the Chinese msgid (i18nPacks.js computeChain). A partial pack is fine: what it lacks falls
// through. A translation that drops a placeholder of its msgid, or uses one the caller does not pass, or has a broken
// plural form, is skipped like a missing one (setI18nWarn reports it; the client warns in development).
// //
// Context: the same Chinese text may need two translations ('关闭' = "Close" on a button, "Off" on a switch): // Context: the same Chinese text may need two translations ('关闭' = "Close" on a button, "Off" on a switch):
// `tc('toggle', '关闭')` looks up the key 'toggle::关闭' first, then '关闭'; Chinese shows '关闭'. // `tc('toggle', '关闭')` looks up the key 'toggle::关闭' first, then '关闭' (in each language of the chain); Chinese shows
// Module-level tables are evaluated once, before a language can change: mark their strings N_('…') (a no-op that // '关闭'. Module-level tables are evaluated once, before a language can change: mark their strings N_('…') (a no-op that
// tools/i18n.mjs extracts) and call t() where they are shown. // tools/i18n.mjs extracts) and call t() where they are shown.
// //
// Placeholders (the same in msgids and translations): // Placeholders (the same in msgids and translations):
// {name} the value of params.name (params may also be an array: {0}, {1} …) // {name} the value of params.name (params may also be an array: {0}, {1} …)
// {n|one|other} `one` when Number(params.n) === 1, else `other` — the only plural form (English needs no more) // {n|one|other} two plain forms: `one` when the language's plural category of Number(params.n) is 'one'
// A value that is an array renders as a list joined with the language's separator ('、' / ', '); a value `{ dn: '…' }` // (Intl.PluralRules; in English exactly when n is 1), else `other`
// (made by `dn()`) is a game-data name, translated through the name resolver the client installs (setNameResolver). // {n|one:…|few:…|other:…} named forms for languages with more categories (zero, one, two, few, many, other; `other`
// A placeholder whose value is missing stays verbatim, so official templates such as '{0}博士…' survive untouched. // is required): the form of the category, else `other`
// A value that is an array renders as a list joined with the language's separator (the translation of 'list::、', '、' in
// Chinese, ', ' without one); a value `{ dn: '…' }` (made by `dn()`) is a game-data name, translated through the name
// resolver the client installs (setNameResolver). A placeholder whose value is missing stays verbatim, so official
// templates such as '{0}博士…' survive untouched.
// //
// Server → client messages (m.toast / m.ticker): the server builds `msg(msgid, params)` and sends // Server → client messages (m.toast / m.ticker): the server builds `msg(msgid, params)` and sends
// `wireMessage(m)` = { text: <the Chinese rendering>, msgid, params } — `text` keeps older clients working, the client // `wireMessage(m)` = { text: <the Chinese rendering>, msgid, params } — `text` keeps older clients working, the client
// renders `translateWire(frame)` in its own language. A plain string message is its own msgid. // renders `translateWire(frame)` in its own language. A plain string message is its own msgid.
/** Supported languages; the first one is the default and the source language of every msgid. */ import { SOURCE_LANG, canonicalLang, primaryLang, packMeta, computeChain } from './i18nPacks.js';
export const LANGS = Object.freeze(['zh', 'en']);
export const DEFAULT_LANG = 'zh';
/** List separator per language (array params). */ /** The default language, the source language of every msgid. */
const LIST_SEP = Object.freeze({ zh: '、', en: ', ' }); export const DEFAULT_LANG = SOURCE_LANG;
/** @typedef {import('./i18nPacks.js').LangMeta} LangMeta */
/** The source language: built in, no pack, no catalog. */
const SOURCE_META = Object.freeze({
code: SOURCE_LANG, name: '中文', englishName: 'Chinese (Simplified)', authors: [], credits: '', version: '', app: '', // i18n-ignore: its own name
base: null, fallback: [], complete: true, numberUnits: ['万', '亿'], builtin: true, // i18n-ignore: the Chinese number units
});
/** @type {Map<string, LangMeta>} code → metadata: the source, the packs of the index, languages given messages */
const registry = new Map([[SOURCE_LANG, /** @type {LangMeta} */ (/** @type {unknown} */ (SOURCE_META))]]);
/** @type {Map<string, Map<string, string>>} lang → msgid → translation */ /** @type {Map<string, Map<string, string>>} lang → msgid → translation */
const catalogs = new Map(); const catalogs = new Map();
let current = DEFAULT_LANG; let current = DEFAULT_LANG;
/** @type {Set<(lang: string, prev: string) => void>} */ /** @type {Set<(lang: string, prev: string) => void>} */
const listeners = new Set(); const listeners = new Set();
/** @type {Set<() => void>} */
const langsListeners = new Set();
/** @type {((name: string, lang: string) => string) | null} */ /** @type {((name: string, lang: string) => string) | null} */
let nameResolver = null; let nameResolver = null;
/** @type {Map<string, string[]>} lang → its chain (cleared whenever the registry changes) */
const chains = new Map();
/** @type {Map<string, Map<string, { ok: boolean, problems: string[], extras: string[] }>>} lang → key → checkTranslation */
const checked = new Map();
/** @type {((info: { lang: string, key: string, problems: string[] }) => void) | null} */
let warnHook = null;
/** lang + key already reported (once each) */
const warned = new Set();
const report = (lang, key, problems) => {
if (!warnHook) return;
const id = `${lang}\u0000${key}`;
if (warned.has(id)) return;
warned.add(id);
try { warnHook({ lang, key, problems }); } catch { /* a broken hook never breaks a render */ }
};
const notifyLangs = () => {
chains.clear();
for (const fn of [...langsListeners]) {
try { fn(); } catch (err) { /** @type {any} */ (globalThis).console?.error('[i18n] listener failed', err); }
}
};
/** /**
* A supported language code from loose input ('en-US' → 'en', 'zh_CN' → 'zh'), or null. * Register languages (the client: the language entries of /packs/index.json, shared/packs.js langMetaOf). A known code
* is merged with what is there. Listeners of onLangsChange run once.
* @param {Partial<LangMeta>[]} entries each with a `code`
* @returns {number} languages registered
*/
export function registerLangs(entries) {
let n = 0;
for (const e of Array.isArray(entries) ? entries : []) {
const code = canonicalLang(e?.code);
if (!code || code === SOURCE_LANG) continue;
const prev = registry.get(code);
registry.set(code, /** @type {LangMeta} */ ({ ...(prev || packMeta(code, null)), ...e, code }));
n++;
}
if (n) notifyLangs();
return n;
}
/**
* The known languages, the source first, then by code: what the language menu lists.
* @returns {LangMeta[]}
*/
export function getLangs() {
const rest = [...registry.values()].filter((m) => m.code !== SOURCE_LANG).sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
return [registry.get(SOURCE_LANG), ...rest];
}
/** The metadata of a known language, or null. @param {string} [lang] */
export const langInfo = (lang = current) => registry.get(String(lang)) || null;
/**
* Subscribe to changes of the known languages (the index arrived, a pack loaded).
* @param {() => void} fn
* @returns {() => void} unsubscribe
*/
export function onLangsChange(fn) {
langsListeners.add(fn);
return () => { langsListeners.delete(fn); };
}
/**
* The languages a lookup tries for `lang`, best first; the Chinese msgid comes after them (i18nPacks.js computeChain).
* Empty for the source language.
* @param {string} [lang]
* @returns {string[]}
*/
export function langChain(lang = current) {
const code = String(lang);
if (code === SOURCE_LANG) return [];
let c = chains.get(code);
if (!c) {
c = computeChain(code, (x) => registry.get(x), (x) => registry.has(x) || catalogs.has(x));
chains.set(code, c);
}
return c;
}
/**
* A known language code from loose input ('en-US' → 'en' when 'en' is known, 'zh_CN' → 'zh', 'PT-br' → 'pt-BR'), or
* null: the exact code, then the same code in any case, then its language part.
* @param {unknown} v * @param {unknown} v
* @returns {string|null} * @returns {string|null}
*/ */
export function normalizeLang(v) { export function normalizeLang(v) {
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''; const code = canonicalLang(v);
if (!s) return null; if (!code) return null;
for (const l of LANGS) if (s === l || s.startsWith(`${l}-`) || s.startsWith(`${l}_`)) return l; if (registry.has(code)) return code;
return null; const lower = code.toLowerCase();
for (const c of registry.keys()) if (c.toLowerCase() === lower) return c;
const p = primaryLang(code);
return registry.has(p) ? p : null;
} }
/** The current language code. */ /** The current language code. */
@@ -79,23 +185,30 @@ export function onLangChange(fn) {
} }
/** /**
* Add translations for a language (merged into what is there). Keys starting with '_' are metadata and skipped; only * Add translations for a language (merged into what is there; the language is registered — with the pack's `_meta`
* non-empty string values count. * when the dict carries one). Keys starting with '_' are metadata and skipped; only non-empty string values count.
* @param {string} lang * @param {string} lang
* @param {Record<string, unknown> | null | undefined} dict msgid → translation * @param {Record<string, unknown> | null | undefined} dict msgid → translation (a whole pack file)
* @returns {number} entries added * @returns {number} entries added
*/ */
export function addMessages(lang, dict) { export function addMessages(lang, dict) {
const l = normalizeLang(lang); const l = canonicalLang(lang);
if (!l || !dict || typeof dict !== 'object') return 0; if (!l || !dict || typeof dict !== 'object') return 0;
let cat = catalogs.get(l); let cat = catalogs.get(l);
if (!cat) { cat = new Map(); catalogs.set(l, cat); } if (!cat) { cat = new Map(); catalogs.set(l, cat); }
checked.delete(l);
let n = 0; let n = 0;
for (const [k, v] of Object.entries(dict)) { for (const [k, v] of Object.entries(dict)) {
if (k.startsWith('_') || typeof v !== 'string' || !v) continue; if (k.startsWith('_') || typeof v !== 'string' || !v) continue;
cat.set(k, v); cat.set(k, v);
n++; n++;
} }
if (l !== SOURCE_LANG && !registry.get(l)?.packId && (dict._meta || !registry.has(l))) {
// a language the pack index did not list (no index, tests): what its own `_meta` says; an index entry (made from
// the pack's manifest by the server) stays as it is
registry.set(l, packMeta(l, dict));
notifyLangs();
} else chains.clear();
return n; return n;
} }
@@ -106,14 +219,15 @@ export function addMessages(lang, dict) {
* @returns {number} * @returns {number}
*/ */
export function setMessages(lang, dict) { export function setMessages(lang, dict) {
const l = normalizeLang(lang); const l = canonicalLang(lang);
if (!l) return 0; if (!l) return 0;
catalogs.delete(l); catalogs.delete(l);
checked.delete(l);
return addMessages(l, dict); return addMessages(l, dict);
} }
/** /**
* Whether a msgid has a translation in a language (the default language always "has" its own msgids). * Whether a msgid has a translation in a language's own pack (the default language always "has" its own msgids).
* @param {string} msgid * @param {string} msgid
* @param {string} [lang] * @param {string} [lang]
*/ */
@@ -122,6 +236,13 @@ export function hasMessage(msgid, lang = current) {
return l === DEFAULT_LANG || !!catalogs.get(l)?.has(String(msgid)); return l === DEFAULT_LANG || !!catalogs.get(l)?.has(String(msgid));
} }
/**
* Install the reporter of skipped translations (a dropped or unknown placeholder, a broken plural form): called once per
* language and key. The client installs console.warn in development (ui/lang.js); null turns it off.
* @param {((info: { lang: string, key: string, problems: string[] }) => void) | null} fn
*/
export function setI18nWarn(fn) { warnHook = typeof fn === 'function' ? fn : null; warned.clear(); }
/** /**
* Install the translator of game-data names (`{ dn: '…' }` params and `tName`). The client maps a Chinese name to the * Install the translator of game-data names (`{ dn: '…' }` params and `tName`). The client maps a Chinese name to the
* current language through the localized game data; without a resolver names stay Chinese. * current language through the localized game data; without a resolver names stay Chinese.
@@ -150,6 +271,19 @@ export const dn = (name) => ({ dn: name == null ? '' : String(name) });
const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k); const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
/**
* The list separator of a language: its translation of 'list::、' through its chain; '、' in Chinese; ', ' without one.
* @param {string} lang
*/
function listSep(lang) {
if (lang === DEFAULT_LANG) return '、';
for (const l of langChain(lang)) {
const s = catalogs.get(l)?.get('list::、');
if (s) return s;
}
return ', ';
}
/** /**
* Render one param value. * Render one param value.
* @param {unknown} v * @param {unknown} v
@@ -159,7 +293,7 @@ const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.has
*/ */
function renderValue(v, lang, resolve) { function renderValue(v, lang, resolve) {
if (v == null) return ''; if (v == null) return '';
if (Array.isArray(v)) return v.map((x) => renderValue(x, lang, resolve)).filter((s) => s !== '').join(LIST_SEP[lang] || LIST_SEP.en); if (Array.isArray(v)) return v.map((x) => renderValue(x, lang, resolve)).filter((s) => s !== '').join(listSep(lang));
if (typeof v === 'object') { if (typeof v === 'object') {
if (own(v, 'dn')) { if (own(v, 'dn')) {
const name = String(/** @type {any} */ (v).dn ?? ''); const name = String(/** @type {any} */ (v).dn ?? '');
@@ -171,10 +305,72 @@ function renderValue(v, lang, resolve) {
return String(v); return String(v);
} }
const PLACEHOLDER = /\{([A-Za-z0-9_$]+)(?:\|([^{}|]*)\|([^{}|]*))?\}/g; /** `{name}` or a plural `{name|form|form…}` (two or more forms); group 2 is the forms with their leading '|'. */
const PLACEHOLDER = /\{([A-Za-z0-9_$]+)((?:\|[^{}|]*){2,})?\}/g;
/** The CLDR plural categories (Intl.PluralRules). */
export const PLURAL_CATEGORIES = Object.freeze(['zero', 'one', 'two', 'few', 'many', 'other']);
const NAMED_FORM = /^(zero|one|two|few|many|other):/;
/** /**
* Fill a template's placeholders (pure: no global language state). * The forms of a plural placeholder ('|second|seconds' or '|one:день|few:дня|many:дней|other:дня') by category, or null
* when malformed: two plain forms are one / other; named forms need `other` and no repeats; plain and named are not
* mixed, and three or more plain forms are refused (the order of a language's categories is not stable across engines).
* @param {string} forms the forms with their leading '|'
* @returns {Record<string, string> | null}
*/
export function parsePluralForms(forms) {
const parts = String(forms).slice(1).split('|');
const named = parts.map((p) => NAMED_FORM.exec(p));
if (named.every(Boolean)) {
/** @type {Record<string, string>} */
const out = {};
for (let i = 0; i < parts.length; i++) {
const cat = named[i][1];
if (own(out, cat)) return null;
out[cat] = parts[i].slice(named[i][0].length);
}
return own(out, 'other') ? out : null;
}
if (parts.length === 2 && !named.some(Boolean)) return { one: parts[0], other: parts[1] };
return null;
}
/** @type {Map<string, any>} lang → Intl.PluralRules (null: the runtime does not know the language) */
const pluralRules = new Map();
/**
* The plural category of a number in a language (Intl.PluralRules; a language the runtime does not know counts one
* for exactly 1, other for the rest — the English rule).
* @param {string} lang
* @param {number} n
* @returns {string}
*/
export function pluralCategory(lang, n) {
if (!Number.isFinite(n)) return 'other';
let pr = pluralRules.get(lang);
if (pr === undefined) {
pr = null;
try {
const PR = /** @type {any} */ (globalThis).Intl?.PluralRules;
const tag = PR && [lang, primaryLang(lang)].find((x) => PR.supportedLocalesOf([x]).length);
if (tag) pr = new PR(tag);
} catch { /* no Intl data for it */ }
pluralRules.set(lang, pr);
}
if (pr) return pr.select(n);
return n === 1 ? 'one' : 'other';
}
/** The form of a plural placeholder for a value (null when the forms are malformed). */
function pluralForm(forms, v, lang) {
const f = parsePluralForms(forms);
if (!f) return null;
const cat = pluralCategory(lang, Number(v));
return own(f, cat) ? f[cat] : f.other;
}
/**
* Fill a template's placeholders (pure: no global language state but the list separator and plural rules of `lang`).
* @param {unknown} template * @param {unknown} template
* @param {Record<string, unknown> | unknown[] | null | undefined} [params] * @param {Record<string, unknown> | unknown[] | null | undefined} [params]
* @param {{ lang?: string, resolveName?: ((name: string, lang: string) => string) | null }} [opts] * @param {{ lang?: string, resolveName?: ((name: string, lang: string) => string) | null }} [opts]
@@ -185,14 +381,79 @@ export function format(template, params, opts = {}) {
if (!params || typeof params !== 'object' || !s.includes('{')) return s; if (!params || typeof params !== 'object' || !s.includes('{')) return s;
const lang = normalizeLang(opts.lang) || DEFAULT_LANG; const lang = normalizeLang(opts.lang) || DEFAULT_LANG;
const resolve = opts.resolveName || null; const resolve = opts.resolveName || null;
return s.replace(PLACEHOLDER, (m, key, one, other) => { return s.replace(PLACEHOLDER, (m, key, forms) => {
if (!own(params, key)) return m; if (!own(params, key)) return m;
const v = /** @type {any} */ (params)[key]; const v = /** @type {any} */ (params)[key];
if (one !== undefined) return Number(v) === 1 ? one : other; if (forms !== undefined) return pluralForm(forms, v, lang) ?? m;
return renderValue(v, lang, resolve); return renderValue(v, lang, resolve);
}); });
} }
/**
* Whether a translation fits its msgid: it keeps every plain placeholder of the msgid as a plain placeholder, and its
* plural forms parse. `extras`: the placeholder names it uses that the msgid lacks — allowed when the caller passes them
* (a plural selector beside a markup value: '{n} {count|unit|units}'), which t() checks per call and
* `node tools/i18n.mjs check` against the call sites.
* @param {string} msgid the msgid (for a tc() key, the part after '::')
* @param {string} text the translation
* @returns {{ ok: boolean, problems: string[], extras: string[] }}
*/
export function checkTranslation(msgid, text) {
const names = new Set();
const plain = new Set();
for (const m of String(msgid).matchAll(PLACEHOLDER)) {
names.add(m[1]);
if (m[2] === undefined) plain.add(m[1]);
}
const kept = new Set();
const extras = [];
const problems = [];
for (const m of String(text).matchAll(PLACEHOLDER)) {
if (m[2] === undefined) kept.add(m[1]);
else if (!parsePluralForms(m[2])) problems.push(`malformed plural ${m[0]}`);
if (!names.has(m[1]) && !extras.includes(m[1])) extras.push(m[1]);
}
for (const k of plain) if (!kept.has(k)) problems.push(`drops {${k}}`);
return { ok: problems.length === 0, problems, extras };
}
/** The checkTranslation of a catalog entry (cached per language). */
function checkedEntry(lang, key, text) {
let m = checked.get(lang);
if (!m) { m = new Map(); checked.set(lang, m); }
let c = m.get(key);
if (!c) {
const sep = key.indexOf('::');
c = checkTranslation(sep >= 0 ? key.slice(sep + 2) : key, text);
m.set(key, c);
}
return c;
}
/**
* The translation of the first key (in order) that a language of the current chain has and that fits: in each
* language the keys in order (a tc() context key, then its plain msgid), then the next language. Null: show the msgid.
* @param {string[]} keys
* @param {Record<string, unknown> | unknown[] | null | undefined} params
* @returns {string|null}
*/
function lookup(keys, params) {
for (const l of langChain(current)) {
const cat = catalogs.get(l);
if (!cat) continue;
for (const key of keys) {
const tr = cat.get(key);
if (!tr) continue;
const c = checkedEntry(l, key, tr);
if (!c.ok) { report(l, key, c.problems); continue; }
const unknown = c.extras.filter((k) => !own(params, k));
if (unknown.length) { report(l, key, unknown.map((k) => `uses {${k}}, which the caller does not pass`)); continue; }
return tr;
}
}
return null;
}
/** /**
* Translate a message into the current language (gettext style: the msgid is the Chinese text). * Translate a message into the current language (gettext style: the msgid is the Chinese text).
* @param {unknown} msgid * @param {unknown} msgid
@@ -201,11 +462,7 @@ export function format(template, params, opts = {}) {
*/ */
export function t(msgid, params) { export function t(msgid, params) {
const id = msgid == null ? '' : String(msgid); const id = msgid == null ? '' : String(msgid);
let text = id; const text = current === DEFAULT_LANG ? id : lookup([id], params) ?? id;
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(id);
if (tr) text = tr;
}
return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text; return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text;
} }
@@ -218,11 +475,8 @@ export function t(msgid, params) {
*/ */
export function tc(context, msgid, params) { export function tc(context, msgid, params) {
const id = msgid == null ? '' : String(msgid); const id = msgid == null ? '' : String(msgid);
if (current !== DEFAULT_LANG) { const text = current === DEFAULT_LANG ? id : lookup([`${context}::${id}`, id], params) ?? id;
const tr = catalogs.get(current)?.get(`${context}::${id}`); return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text;
if (tr) return params ? format(tr, params, { lang: current, resolveName: nameResolver }) : tr;
}
return t(id, params);
} }
/** A tParts() param kept as it is: an object that is not a `{ dn }` name (a vnode), or an array holding one. */ /** A tParts() param kept as it is: an object that is not a `{ dn }` name (a vnode), or an array holding one. */
@@ -231,7 +485,7 @@ const isMarkup = (v) => !!v && typeof v === 'object' && !own(v, 'dn') && (!Array
/** /**
* A message as pieces for a renderer, for a sentence with markup inside (「第 <b>14</b> 回合」): the translation split at * A message as pieces for a renderer, for a sentence with markup inside (「第 <b>14</b> 回合」): the translation split at
* its placeholders; a param that is markup (a vnode, or an array holding vnodes) is kept as it is, the others render as * its placeholders; a param that is markup (a vnode, or an array holding vnodes) is kept as it is, the others render as
* in t() ({ dn } names, lists, {n|one|other}). Adjacent text joins, empty text is dropped, so the Chinese pieces are the * in t() ({ dn } names, lists, plural forms). Adjacent text joins, empty text is dropped, so the Chinese pieces are the
* text around the markup exactly as written before: * text around the markup exactly as written before:
* html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${n}</b>` })}</span>` * html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${n}</b>` })}</span>`
* @param {unknown} msgid * @param {unknown} msgid
@@ -240,11 +494,7 @@ const isMarkup = (v) => !!v && typeof v === 'object' && !own(v, 'dn') && (!Array
*/ */
export function tParts(msgid, params) { export function tParts(msgid, params) {
const id = msgid == null ? '' : String(msgid); const id = msgid == null ? '' : String(msgid);
let text = id; const text = current === DEFAULT_LANG ? id : lookup([id], params) ?? id;
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(id);
if (tr) text = tr;
}
/** @type {unknown[]} */ /** @type {unknown[]} */
const out = []; const out = [];
const push = (s) => { const push = (s) => {
@@ -255,11 +505,11 @@ export function tParts(msgid, params) {
let last = 0; let last = 0;
if (params && typeof params === 'object') { if (params && typeof params === 'object') {
for (const m of text.matchAll(PLACEHOLDER)) { for (const m of text.matchAll(PLACEHOLDER)) {
const [whole, key, one, other] = m; const [whole, key, forms] = m;
if (!own(params, key)) continue; if (!own(params, key)) continue;
push(text.slice(last, m.index)); push(text.slice(last, m.index));
const v = /** @type {any} */ (params)[key]; const v = /** @type {any} */ (params)[key];
if (one !== undefined) push(Number(v) === 1 ? one : other); if (forms !== undefined) push(pluralForm(forms, v, current) ?? whole);
else if (isMarkup(v)) out.push(v); else if (isMarkup(v)) out.push(v);
else push(renderValue(v, current, nameResolver)); else push(renderValue(v, current, nameResolver));
last = m.index + whole.length; last = m.index + whole.length;
+33 -22
View File
@@ -10,6 +10,11 @@
// the 2-character hash (textHash2) of each Chinese string a leaf replaces, in walk order (object keys in their order, // the 2-character hash (textHash2) of each Chinese string a leaf replaces, in walk order (object keys in their order,
// array entries by index, `_h` itself skipped). A record that is a plain string (config `seasonName`) has { _s, _h }. A leaf whose Chinese source no longer hashes the same — data/*.json was // array entries by index, `_h` itself skipped). A record that is a plain string (config `seasonName`) has { _s, _h }. A leaf whose Chinese source no longer hashes the same — data/*.json was
// rebuilt after the overlay — is not applied: the player sees the current Chinese text rather than a stale translation. // rebuilt after the overlay — is not applied: the player sees the current Chinese text rather than a stale translation.
//
// Language packs (shared/i18nPacks.js): a language's game texts may come from several overlays — the pack's own, its
// base's, a fallback's (English) — applied per text: `applyFileOverlay(json, overlay, onto)` checks each leaf against
// the Chinese `json` and writes it over `onto` (what the overlays of lower priority made), so applying them from the
// last of the chain to the first leaves, for every text, the best overlay that has it (public/js/data.js).
export const OVERLAY_VERSION = 1; export const OVERLAY_VERSION = 1;
@@ -106,77 +111,83 @@ export function buildRecordOverlay(leaves) {
/** /**
* Apply a record overlay to a Chinese record: a copy that shares every untouched branch (copy-on-write), with each leaf * Apply a record overlay to a Chinese record: a copy that shares every untouched branch (copy-on-write), with each leaf
* whose Chinese source still matches its hash replaced. The input record is never modified. * whose Chinese source still matches its hash replaced. The input record is never modified. `onto` (default: the
* record itself) is what the replaced leaves are written over — the record as overlays of lower priority left it — while
* the hashes are always checked against the Chinese `base`.
* @param {any} base * @param {any} base
* @param {any} ov * @param {any} ov
* @param {any} [onto]
* @returns {{ value: any, applied: number, stale: number }} * @returns {{ value: any, applied: number, stale: number }}
*/ */
export function applyRecordOverlay(base, ov) { export function applyRecordOverlay(base, ov, onto = base) {
if (isObj(ov) && typeof ov._s === 'string') { if (isObj(ov) && typeof ov._s === 'string') {
const ok = typeof base === 'string' && textHash2(base) === ov._h; const ok = typeof base === 'string' && textHash2(base) === ov._h;
return { value: ok ? ov._s : base, applied: ok ? 1 : 0, stale: ok ? 0 : 1 }; return { value: ok ? ov._s : onto, applied: ok ? 1 : 0, stale: ok ? 0 : 1 };
} }
if (!isObj(ov) || base == null || typeof base !== 'object') return { value: base, applied: 0, stale: 0 }; if (!isObj(ov) || base == null || typeof base !== 'object') return { value: onto, applied: 0, stale: 0 };
const hashes = typeof ov._h === 'string' ? ov._h : ''; const hashes = typeof ov._h === 'string' ? ov._h : '';
let i = 0; let i = 0;
let applied = 0; let applied = 0;
let stale = 0; let stale = 0;
const rec = (b, o, depth) => { // b: the Chinese at this path (hash check); t: the target at this path (same shape: overlays replace strings only)
const rec = (b, t, o, depth) => {
if (typeof o === 'string') { if (typeof o === 'string') {
const want = hashes.slice(i * 2, i * 2 + 2); const want = hashes.slice(i * 2, i * 2 + 2);
i++; i++;
if (typeof b === 'string' && want && textHash2(b) === want) { applied++; return o; } if (typeof b === 'string' && want && textHash2(b) === want) { applied++; return o; }
stale++; stale++;
return b; return t;
} }
if (Array.isArray(o)) { if (Array.isArray(o)) {
if (!Array.isArray(b)) { walkOverlay(o, () => { i++; stale++; }); return b; } if (!Array.isArray(b) || !Array.isArray(t)) { walkOverlay(o, () => { i++; stale++; }); return t; }
let out = b; let out = t;
for (let k = 0; k < o.length; k++) { for (let k = 0; k < o.length; k++) {
if (o[k] == null) continue; if (o[k] == null) continue;
const nv = rec(b[k], o[k], depth + 1); const nv = rec(b[k], t[k], o[k], depth + 1);
if (nv !== b[k]) { if (out === b) out = b.slice(); out[k] = nv; } if (nv !== t[k]) { if (out === t) out = t.slice(); out[k] = nv; }
} }
return out; return out;
} }
if (isObj(o)) { if (isObj(o)) {
if (b == null || typeof b !== 'object') { walkOverlay(o, () => { i++; stale++; }); return b; } if (b == null || typeof b !== 'object' || t == null || typeof t !== 'object') { walkOverlay(o, () => { i++; stale++; }); return t; }
// an array record's overlay is keyed by index (see buildRecordOverlay) // an array record's overlay is keyed by index (see buildRecordOverlay)
const arr = Array.isArray(b); const arr = Array.isArray(b);
let out = b; let out = t;
for (const k of Object.keys(o)) { for (const k of Object.keys(o)) {
if (depth === 0 && k === '_h') continue; if (depth === 0 && k === '_h') continue;
const at = arr ? (/^\d+$/.test(k) ? Number(k) : -1) : k; const at = arr ? (/^\d+$/.test(k) ? Number(k) : -1) : k;
const cur = at === -1 ? undefined : b[at]; const cur = at === -1 ? undefined : b[at];
const nv = rec(cur, o[k], depth + 1); const tcur = at === -1 ? undefined : t[at];
if (at !== -1 && nv !== cur) { if (out === b) out = arr ? b.slice() : { ...b }; out[at] = nv; } const nv = rec(cur, tcur, o[k], depth + 1);
if (at !== -1 && nv !== tcur) { if (out === t) out = Array.isArray(t) ? t.slice() : { ...t }; out[at] = nv; }
} }
return out; return out;
} }
return b; return t;
}; };
const value = rec(base, ov, 0); const value = rec(base, onto, ov, 0);
return { value, applied, stale }; return { value, applied, stale };
} }
/** /**
* Apply a file's overlay to a whole data file (the `{ id: record }` maps of data/*.json; other shapes are returned * Apply a file's overlay to a whole data file (the `{ id: record }` maps of data/*.json; other shapes are returned
* unchanged). Untouched records are shared with the input. * unchanged). Untouched records are shared with the input. `onto`: see applyRecordOverlay (a whole file).
* @param {any} json the Chinese file * @param {any} json the Chinese file
* @param {Record<string, any> | null | undefined} fileOverlay `files[<name>]` of the overlay * @param {Record<string, any> | null | undefined} fileOverlay `files[<name>]` of the overlay
* @param {any} [onto] the file as overlays of lower priority left it (default: the Chinese file)
* @returns {{ value: any, applied: number, stale: number }} * @returns {{ value: any, applied: number, stale: number }}
*/ */
export function applyFileOverlay(json, fileOverlay) { export function applyFileOverlay(json, fileOverlay, onto = json) {
if (!isObj(json) || !isObj(fileOverlay)) return { value: json, applied: 0, stale: 0 }; if (!isObj(json) || !isObj(fileOverlay) || !isObj(onto)) return { value: onto, applied: 0, stale: 0 };
let out = json; let out = onto;
let applied = 0; let applied = 0;
let stale = 0; let stale = 0;
for (const [id, ov] of Object.entries(fileOverlay)) { for (const [id, ov] of Object.entries(fileOverlay)) {
if (!Object.prototype.hasOwnProperty.call(json, id)) continue; if (!Object.prototype.hasOwnProperty.call(json, id)) continue;
const r = applyRecordOverlay(json[id], ov); const r = applyRecordOverlay(json[id], ov, onto[id]);
applied += r.applied; applied += r.applied;
stale += r.stale; stale += r.stale;
if (r.value !== json[id]) { if (out === json) out = { ...json }; out[id] = r.value; } if (r.value !== onto[id]) { if (out === onto) out = { ...onto }; out[id] = r.value; }
} }
return { value: out, applied, stale }; return { value: out, applied, stale };
} }
+171
View File
@@ -0,0 +1,171 @@
// shared/i18nPacks.js — the language pack type ("lang") of the content packs (shared/packs.js, docs/PACKS.md;
// translating: docs/I18N.md "Adding a language"): language codes, the language fields of a pack manifest, the fallback
// chain t() walks, and the script family the title screen lays out by. No Node builtin: the browser, the server and the
// tools share it.
//
// A language pack is files in the language folders, nothing else (the owner's decision of 2026-10-07):
//
// public/i18n/<code>.json the UI strings: { "_meta": { …manifest… }, "<Chinese msgid>": "<translation>", … }
// data/i18n/<code>.json optional: the game texts (the overlay of shared/i18nData.js, tools/build-i18n.mjs)
//
// — or the same two files in a pack folder, packs/<id>/ with a pack.json manifest (shared/packs.js), the layout every
// pack type shares. <code> is a BCP 47 tag in its usual case: a 2–3 letter language, then an optional script (4
// letters), region (2 letters or 3 digits) and variants — en, ja, ko, zh-TW, pt-BR, sr-Latn. Chinese ('zh') is the
// built-in source language (the msgids themselves) and never a pack. English is a pack like any other; it is only the
// complete one.
//
// The language fields of a manifest (`_meta` of a single-file pack, pack.json of a folder pack; all optional):
// lang the language code (a single file: its file name decides; a different value is reported)
// base a pack whose strings fill the gaps first (default: the language of a regional code when there is a
// pack for it — pt-BR → pt; never the Chinese source, so zh-TW has none); null switches it off
// fallback packs tried after the base, before the Chinese msgid (["en"]: untranslated strings show English)
// complete true: `node tools/i18n.mjs check` requires every msgid (English declares it)
// numberUnits two units for numbers counted in 10⁴ / 10⁸ steps (Chinese 万 / 亿; e.g. a Japanese pack ["万", "億"]);
// without them large numbers use K / M / B (ui/gameLogic/format.js fmtNum)
// The common fields (id, version, app, name, englishName, authors, credits, license) are shared/packs.js's.
/** The source language: msgids are its text, it has no pack. */
export const SOURCE_LANG = 'zh';
/**
* A language code in its canonical case ('EN' → 'en', 'pt_br' → 'pt-BR', 'zh-hant-tw' → 'zh-Hant-TW'), or null when
* the input is not one.
* @param {unknown} v
* @returns {string|null}
*/
export function canonicalLang(v) {
if (typeof v !== 'string') return null;
const parts = v.trim().replace(/_/g, '-').split('-');
if (!/^[A-Za-z]{2,3}$/.test(parts[0] || '')) return null;
const out = [parts[0].toLowerCase()];
for (let i = 1; i < parts.length; i++) {
const p = parts[i];
if (!/^[A-Za-z0-9]{2,8}$/.test(p)) return null;
if (i === 1 && /^[A-Za-z]{4}$/.test(p)) out.push(p[0].toUpperCase() + p.slice(1).toLowerCase()); // script
else if (/^[A-Za-z]{2}$/.test(p) || /^\d{3}$/.test(p)) out.push(p.toUpperCase()); // region
else out.push(p.toLowerCase()); // variant
}
return out.join('-');
}
/** Whether a string is a language code in its canonical case (a pack's file name must be one). @param {unknown} v */
export const isLangCode = (v) => typeof v === 'string' && canonicalLang(v) === v;
/** The language part of a code ('pt-BR' → 'pt'). @param {string} code */
export const primaryLang = (code) => String(code).split('-')[0];
/**
* A language's name through Intl.DisplayNames (`code` in the language `inLang`), or the code when unknown.
* @param {string} code
* @param {string} [inLang] default: the language itself (日本語 for ja)
*/
export function languageName(code, inLang = code) {
try {
const DN = /** @type {any} */ (globalThis).Intl?.DisplayNames;
if (DN) {
const n = new DN([inLang], { type: 'language', fallback: 'none' }).of(code);
if (typeof n === 'string' && n && n !== code) return n;
}
} catch { /* an unknown code or no ICU data */ }
return code;
}
const str = (v, max = 200) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : '');
/** Canonical language codes of a string or a list, the source and repeats left out. @param {unknown} v */
export function langList(v) {
const list = Array.isArray(v) ? v : typeof v === 'string' ? [v] : [];
const out = [];
for (const x of list) {
const c = canonicalLang(x);
if (c && c !== SOURCE_LANG && !out.includes(c)) out.push(c);
}
return out;
}
/**
* @typedef {{ lang: string, base: string|null|undefined, fallback: string[], complete: boolean, numberUnits: string[]|null }} LangFields
*/
/**
* The language fields of a manifest (see the header), normalized. `base` stays undefined unless the manifest sets it
* (a code or null): computeChain() then takes the language of a regional code.
* @param {string} code the pack's language
* @param {any} m the manifest (`_meta` / pack.json), may be empty
* @returns {LangFields}
*/
export function langFields(code, m) {
const o = m && typeof m === 'object' ? m : {};
let base;
if (o.base === null) base = null;
else if (typeof o.base === 'string') base = canonicalLang(o.base);
if (base === SOURCE_LANG || base === code) base = null;
const units = Array.isArray(o.numberUnits) && o.numberUnits.length === 2 && o.numberUnits.every((u) => str(u, 8)) ? o.numberUnits.map((u) => str(u, 8)) : null;
return { lang: code, base, fallback: langList(o.fallback).filter((c) => c !== code), complete: o.complete === true, numberUnits: units };
}
/**
* @typedef {LangFields & { code: string, name: string, englishName: string, authors: string[], credits?: string,
* version?: string, app?: string, compatible?: boolean, data?: boolean, strings?: number, ui?: string,
* dataUrl?: string, packId?: string, builtin?: boolean }} LangMeta
*/
/**
* What the client knows of a language (shared/i18n.js registry) from a pack's UI file alone — its `_meta`, every field
* optional: the names default to Intl.DisplayNames, no authors, no fallback.
* @param {string} code
* @param {any} json the parsed UI file (or null)
* @returns {LangMeta}
*/
export function packMeta(code, json) {
const m = json && typeof json === 'object' && json._meta && typeof json._meta === 'object' ? json._meta : {};
const authors = (Array.isArray(m.authors) ? m.authors : typeof m.authors === 'string' ? [m.authors] : []).map((a) => str(a)).filter(Boolean).slice(0, 32);
return {
...langFields(code, m),
code,
name: str(m.name, 60) || languageName(code),
englishName: str(m.englishName, 60) || languageName(code, 'en'),
authors,
};
}
/** How many translations a UI file holds (non-empty string values, metadata keys left out). @param {any} json */
export function countStrings(json) {
let n = 0;
if (json && typeof json === 'object') for (const [k, v] of Object.entries(json)) if (!k.startsWith('_') && typeof v === 'string' && v) n++;
return n;
}
/**
* The languages t() tries for `code`, best first (the Chinese msgid comes after all of them): the pack itself, its base
* — `base`, else the language of a regional code (pt-BR → pt) — with the base's own chain, then each `fallback` with its
* chain. Only languages `known` accepts; never the source; no repeats (cycles end).
* @param {string} code
* @param {(code: string) => Partial<LangFields> | null | undefined} metaOf
* @param {(code: string) => boolean} known
* @returns {string[]}
*/
export function computeChain(code, metaOf, known) {
const out = [];
const visit = (c, depth) => {
if (!c || c === SOURCE_LANG || out.includes(c) || depth > 8 || !known(c)) return;
out.push(c);
const m = metaOf(c) || {};
const base = m.base !== undefined ? m.base : (c.includes('-') ? primaryLang(c) : null);
if (base) visit(base, depth + 1);
for (const f of m.fallback || []) visit(f, depth + 1);
};
visit(code, 0);
return out;
}
/** Characters of the wide scripts: CJK ideographs and punctuation, kana, Hangul, full-width forms. */
const WIDE_SCRIPT = /[ᄀ-ᇿ⺀-鿿가-힯豈-﫿＀-￯]/;
/**
* The script family of a text: 'cjk' (ideographs, kana, Hangul) or 'alphabetic' (Latin, Cyrillic, Greek …). The title
* screen and the start button lay out by it (screens/title.js, ui/lang.js applyDocument), so a pack needs no flag.
* @param {unknown} text
* @returns {'cjk' | 'alphabetic'}
*/
export const scriptOf = (text) => (WIDE_SCRIPT.test(String(text ?? '')) ? 'cjk' : 'alphabetic');
+285
View File
@@ -0,0 +1,285 @@
// shared/packs.js — content packs (docs/PACKS.md): one manifest format, one registry, one index for every kind of
// add-on content (the owner's decision of 2026-10-07: the language pack is the first type of a general mechanism). A
// pack is a manifest plus its files. Type "lang" (language packs, shared/i18nPacks.js) is the first and, in 0.2.0, the
// only type that loads; "assets" (art / audio / font replacements, client side) and "data" (data patches the server
// applies at start) are planned: their manifests already parse, a server lists them as not supported yet, and each will
// plug in as its entry in PACK_TYPES plus its loader (docs/PACKS.md "Adding a pack type"). No Node builtin: the
// browser, the server (server/packs.js) and the tools share it.
//
// Two layouts, read by the same registry (server/packs.js) and listed alike:
// packs/<id>/pack.json + its files a folder pack — any type; the manifest names its files (`files`)
// public/i18n/<code>.json a single-file language pack — its manifest is the file's `_meta` block
// (+ data/i18n/<code>.json, its game texts, when present)
//
// Manifest (pack.json, or `_meta` of a single-file language pack; every field but type / lang optional):
// id a folder pack: its folder name; a single-file language pack: its language code (a manifest that names
// another id is reported; the place decides). Letters, digits, '.', '_', '-', ≤ 64; unique across types
// type "lang" (a single-file language pack may leave it out); planned: "assets", "data"
// version the pack's own version (e.g. "1.2.0")
// app the app versions it is made for, a range: ">=0.2.0", ">=0.2.0 <0.3.0", "0.2.x", "^0.2.0", "~0.2.1", "*"
// (a pre-release such as 0.2.0-dev counts as its release). Outside it a language pack still loads —
// what it lacks falls back — and is flagged `compatible: false`
// name the pack's name in its own language (a language pack: the language's own name, shown in the menu)
// englishName its English name
// authors a string or a list; credits free text; license an SPDX id or free text
// files a folder pack: role → path inside the folder (type "lang": { "ui": "ui.json", "data": "data.json" })
// … the type's own fields (type "lang": lang, base, fallback, complete, numberUnits — shared/i18nPacks.js)
//
// Index (GET /packs/index.json — the server's live list; `node tools/packs.mjs index` writes packs/index.json for a
// static host): { version: 1, app: "<the server's app version>", packs: [entry …] } — the packs of supported types,
// sorted by type then id. An entry: id, type, name, englishName, version?, app?, compatible, authors?, credits?,
// license?, files (role → URL on this server) and the type's fields; a language entry: lang, base?, fallback,
// complete?, numberUnits?, strings (how many translations its UI file holds).
import { APP_VERSION } from './constants.js';
import { SOURCE_LANG, canonicalLang, isLangCode, languageName, langFields } from './i18nPacks.js';
export const PACK_INDEX_VERSION = 1;
/** A folder pack's manifest file. */
export const PACK_MANIFEST = 'pack.json';
/** The index file next to the pack folders (packs/index.json, URL /packs/index.json). */
export const PACK_INDEX_FILE = 'index.json';
/** Where pack folders are served (server/http/static.js): /packs/<id>/<file>. */
export const PACKS_URL = '/packs/';
/**
* @typedef {{ required?: boolean, ext: string[] }} FileRole a role of a folder pack's `files`
* @typedef {{ status: 'supported' | 'planned', summary: string, live?: boolean, files?: Record<string, FileRole> }} PackType
* live: a server picks up its changes without a restart (lang: the client loads packs on demand; data will not —
* the simulation's data is fixed at start). files: the roles a folder pack of the type names, with the extensions a
* server may serve for them (nothing else in a pack folder is ever served)
*/
/** @type {Readonly<Record<string, PackType>>} the pack types (docs/PACKS.md "Adding a pack type") */
export const PACK_TYPES = Object.freeze({
lang: Object.freeze({
status: 'supported', live: true, summary: 'a language: UI strings and, optionally, game texts',
files: Object.freeze({ ui: Object.freeze({ required: true, ext: ['.json'] }), data: Object.freeze({ ext: ['.json'] }) }),
}),
assets: Object.freeze({ status: 'planned', summary: 'art, audio and font replacements, client side' }),
data: Object.freeze({ status: 'planned', summary: 'data patches the server applies at start' }),
});
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const str = (v, max = 200) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : '');
/** A pack id: letters, digits, '.', '_', '-' (not first), at most 64. @param {unknown} v */
export const isPackId = (v) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v);
/**
* A path inside a pack folder ('ui.json', 'img/a.png'): relative, '/'-separated, no '.' / '..' / hidden / empty segment,
* plain characters only.
* @param {unknown} rel
*/
export const isPackPath = (rel) => typeof rel === 'string' && rel.length <= 200 && /^[A-Za-z0-9._ -]+(?:\/[A-Za-z0-9._ -]+)*$/.test(rel)
&& rel.split('/').every((s) => s.trim() === s && s && !s.startsWith('.'));
const extOf = (rel) => { const m = /\.[A-Za-z0-9]+$/.exec(rel); return m ? m[0].toLowerCase() : ''; };
// ---- app version ranges -------------------------------------------------------------------------------------------
/** [major, minor, patch] of a version ('0.2.0-dev' → [0, 2, 0]: a pre-release counts as its release), or null. */
const versionCore = (v) => {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? '').trim());
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
};
const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
/** One comparator of a range as [op, version] bounds, or null when it is not one. */
function bounds(c) {
if (c === '*' || c === 'x') return [];
const m = /^(\^|~|>=|<=|>|<|=)?v?(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(c);
if (!m) return null;
const [, op = '', a, b, d] = m;
const wild = (s) => s === undefined || /^[xX*]$/.test(s);
const M = Number(a);
const N = wild(b) ? 0 : Number(b);
const P = wild(d) ? 0 : Number(d);
const partial = wild(b) ? 1 : wild(d) ? 2 : 3; // how many parts were given
const lo = [M, N, P];
if (op === '^') return [['>=', lo], ['<', M > 0 ? [M + 1, 0, 0] : partial >= 2 ? [0, N + 1, 0] : [1, 0, 0]]];
if (op === '~') return [['>=', lo], ['<', partial >= 2 ? [M, N + 1, 0] : [M + 1, 0, 0]]];
if (op === '' || op === '=') {
if (partial === 3) return [['=', lo]];
return [['>=', lo], ['<', partial === 2 ? [M, N + 1, 0] : [M + 1, 0, 0]]];
}
return [[op, lo]];
}
/** Whether `range` is a version range this file understands (an empty one is). @param {unknown} range */
export function isVersionRange(range) {
const r = String(range ?? '').trim();
if (!r) return true;
return r.split('||').every((alt) => alt.trim().split(/\s+/).every((c) => bounds(c) !== null));
}
/**
* Whether an app version is in a pack's `app` range (see the header). An empty range, an unreadable range or version
* counts as a match: the range is advice, the pack's loader decides what an incompatible pack means.
* @param {unknown} range
* @param {unknown} [version] default: this build's APP_VERSION
*/
export function appVersionMatches(range, version = APP_VERSION) {
const v = versionCore(version);
const r = String(range ?? '').trim();
if (!v || !r || !isVersionRange(r)) return true;
return r.split('||').some((alt) => alt.trim().split(/\s+/).every((c) => bounds(c).every(([op, b]) => {
const d = cmp(v, b);
return op === '>=' ? d >= 0 : op === '>' ? d > 0 : op === '<=' ? d <= 0 : op === '<' ? d < 0 : d === 0;
})));
}
// ---- manifests ----------------------------------------------------------------------------------------------------
/**
* @typedef {{ id: string, type: string, name: string, englishName: string, version: string, app: string,
* compatible: boolean, authors: string[], credits: string, license: string, files: Record<string, string>,
* lang?: string, base?: string|null, fallback?: string[], complete?: boolean, numberUnits?: string[]|null }} PackManifest
*/
/**
* Read a manifest: the common fields, the type's fields, a folder pack's file roles. `problems` keep the pack from
* loading (manifest null); `warnings` do not.
* @param {unknown} raw pack.json, or the `_meta` of a single-file language pack (undefined: none)
* @param {{ id: string, type?: string, lang?: string, folder?: boolean, app?: string }} ctx
* id: the folder name / the file's code; type: the type of the layout (a single-file language pack: 'lang');
* lang: a single file's language code; folder: a folder pack (its `files` are read); app: the app version
* @returns {{ manifest: PackManifest | null, problems: string[], warnings: string[] }}
*/
export function normalizeManifest(raw, ctx) {
const problems = [];
const warnings = [];
if (raw !== undefined && !isObj(raw)) problems.push('the manifest is not a JSON object');
const m = isObj(raw) ? /** @type {any} */ (raw) : {};
const type = str(m.type, 20) || ctx.type || '';
const kind = Object.prototype.hasOwnProperty.call(PACK_TYPES, type) ? PACK_TYPES[type] : null;
if (!type) problems.push('no "type"');
else if (!kind) problems.push(`unknown type "${type}" (known: ${Object.keys(PACK_TYPES).join(', ')})`);
if (ctx.type && type !== ctx.type) problems.push(`"type": "${type}" where a ${ctx.type} pack belongs`);
const id = ctx.id;
if (!isPackId(id)) problems.push(`"${id}" is not a pack id (letters, digits, . _ -)`);
if (m.id !== undefined && m.id !== id) warnings.push(`"id": "${m.id}" is not "${id}" (the folder or file name decides)`);
const range = str(m.app, 60);
if (range && !isVersionRange(range)) warnings.push(`"app": "${range}" is not a version range (">=0.2.0", "0.2.x" …)`);
const app = ctx.app ?? APP_VERSION;
const compatible = appVersionMatches(range, app);
if (!compatible) warnings.push(`made for app ${range}; this is ${app}`);
const authors = (Array.isArray(m.authors) ? m.authors : typeof m.authors === 'string' ? [m.authors] : []).map((a) => str(a)).filter(Boolean).slice(0, 32);
/** @type {PackManifest} */
const out = {
id, type, name: str(m.name, 60), englishName: str(m.englishName, 60), version: str(m.version, 40), app: range, compatible,
authors, credits: str(m.credits, 500), license: str(m.license, 100), files: {},
};
if (type === 'lang') {
const code = ctx.lang || canonicalLang(m.lang);
if (ctx.lang && m.lang !== undefined && canonicalLang(m.lang) !== ctx.lang) warnings.push(`"lang": "${m.lang}" is not its file's code ${ctx.lang}`);
if (!code) problems.push(m.lang === undefined ? 'no "lang" (the language code)' : `"lang": "${m.lang}" is not a language code`);
else if (code === SOURCE_LANG) problems.push('"zh" is the source language (the msgids), never a pack');
else {
if (m.lang !== undefined && !isLangCode(m.lang) && !ctx.lang) warnings.push(`"lang": "${m.lang}" read as ${code}`);
Object.assign(out, langFields(code, m));
out.name ||= languageName(code);
out.englishName ||= languageName(code, 'en');
}
}
out.name ||= id;
out.englishName ||= out.name;
if (ctx.folder && kind && kind.files) {
const given = isObj(m.files) ? m.files : {};
for (const [role, spec] of Object.entries(kind.files)) {
const rel = given[role];
if (rel === undefined || rel === null || rel === '') { if (spec.required) problems.push(`no "files.${role}"`); continue; }
if (!isPackPath(rel)) problems.push(`"files.${role}": "${rel}" is not a path inside the pack folder`);
else if (!spec.ext.includes(extOf(rel))) problems.push(`"files.${role}" must be a ${spec.ext.join(' / ')} file`);
else out.files[role] = rel;
}
for (const role of Object.keys(given)) if (!kind.files[role]) warnings.push(`"files.${role}" is not a role of a ${type} pack (ignored)`);
}
return { manifest: problems.length ? null : out, problems, warnings };
}
// ---- the index ----------------------------------------------------------------------------------------------------
/**
* The index entry of a pack (what a client may know): its manifest without the folder paths, `files` as URLs, plus the
* type's extras (a language pack: `strings`).
* @param {PackManifest} manifest
* @param {{ files: Record<string, string>, strings?: number }} extra files: role → URL
*/
export function packIndexEntry(manifest, extra) {
/** @type {Record<string, unknown>} */
const e = { id: manifest.id, type: manifest.type, name: manifest.name, englishName: manifest.englishName };
if (manifest.version) e.version = manifest.version;
if (manifest.app) e.app = manifest.app;
e.compatible = manifest.compatible;
if (manifest.authors.length) e.authors = manifest.authors;
if (manifest.credits) e.credits = manifest.credits;
if (manifest.license) e.license = manifest.license;
if (manifest.type === 'lang') {
e.lang = manifest.lang;
if (manifest.base !== undefined) e.base = manifest.base;
e.fallback = manifest.fallback || [];
if (manifest.complete) e.complete = true;
if (manifest.numberUnits) e.numberUnits = manifest.numberUnits;
e.strings = extra.strings ?? 0;
}
e.files = { ...extra.files };
return e;
}
/**
* The index body from the entries of supported packs (packIndexEntry), sorted by type then id.
* @param {Record<string, unknown>[]} entries
* @param {{ app?: string }} [opts]
*/
export function buildPackIndex(entries, { app = APP_VERSION } = {}) {
const key = (e) => `${e.type}\u0000${e.id}`;
return { version: PACK_INDEX_VERSION, app, packs: [...entries].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)) };
}
/** A URL a client may fetch from an index: a path on this server ('/…', never '//host' or a scheme). */
const isLocalUrl = (u) => typeof u === 'string' && /^\/(?!\/)[^\s\\]*$/.test(u) && u.length <= 300;
/**
* Read a received index (the client): the entries of supported types whose fields check out; anything else is dropped.
* Optionally only one type.
* @param {unknown} json
* @param {string} [type]
* @returns {Record<string, any>[]}
*/
export function readPackIndex(json, type) {
const list = isObj(json) && Array.isArray(/** @type {any} */ (json).packs) ? /** @type {any} */ (json).packs : [];
const out = [];
for (const e of list) {
if (!isObj(e) || !isPackId(e.id) || PACK_TYPES[e.type]?.status !== 'supported' || (type && e.type !== type)) continue;
const files = {};
for (const [role, url] of Object.entries(isObj(e.files) ? e.files : {})) if (isLocalUrl(url)) files[role] = url;
if (e.type === 'lang' && (!isLangCode(e.lang) || e.lang === SOURCE_LANG || !files.ui)) continue;
out.push({ ...e, files });
}
return out;
}
/**
* The language a client registers for a language entry of the index (shared/i18n.js registerLangs).
* @param {Record<string, any>} e a 'lang' entry of readPackIndex
* @returns {import('./i18nPacks.js').LangMeta}
*/
export function langMetaOf(e) {
const f = langFields(e.lang, e);
return {
...f,
code: e.lang,
name: str(e.name, 60) || languageName(e.lang),
englishName: str(e.englishName, 60) || languageName(e.lang, 'en'),
authors: Array.isArray(e.authors) ? e.authors.map((a) => str(a)).filter(Boolean).slice(0, 32) : [],
version: str(e.version, 40),
app: str(e.app, 60),
compatible: e.compatible !== false,
strings: Number.isFinite(e.strings) ? e.strings : undefined,
data: !!e.files?.data,
ui: e.files?.ui,
dataUrl: e.files?.data,
packId: e.id,
};
}
+411
View File
@@ -0,0 +1,411 @@
// Language packs as content packs (docs/I18N.md "Adding a language", docs/PACKS.md): discovery (server/packs.js — the
// language folders and the pack folders, a pack dropped in shows without a restart; GET /packs/index.json and the file
// allowlist), the manifest (shared/packs.js: types, app ranges), the fallback chain (shared/i18n.js: base, fallback, the
// Chinese msgid), placeholder validation, plural categories (Intl.PluralRules: English unchanged, Russian named forms),
// the per-text fallback of game texts (shared/i18nData.js onto, data.js chains), the client loader and menu
// (ui/lang.js), the translator tools (tools/i18n.mjs template / check, tools/packs.mjs index), and that the Chinese and
// English output of t() is what it was before packs. The example packs live only here, in temporary folders ('qaa' …
// 'qtz' are the ISO 639 codes reserved for local use).
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import {
t, tc, tParts, setLang, getLang, addMessages, setMessages, setI18nWarn, registerLangs, getLangs, langChain, langInfo, normalizeLang,
checkTranslation, parsePluralForms, pluralCategory, format,
} from '../shared/i18n.js';
import { canonicalLang, computeChain, scriptOf, langFields } from '../shared/i18nPacks.js';
import { normalizeManifest, appVersionMatches, isVersionRange, readPackIndex, langMetaOf, PACK_TYPES, isPackPath } from '../shared/packs.js';
import { buildRecordOverlay, applyFileOverlay, applyRecordOverlay } from '../shared/i18nData.js';
import { buildOverlay, LANG_SOURCES } from '../tools/build-i18n.mjs';
import { scanPacks, createPackRegistry, packIndexOf } from '../server/packs.js';
import { createDataStore } from '../public/js/data.js';
import { checkPack, packTemplate } from '../tools/i18n.mjs';
import { writePackIndex } from '../tools/packs.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const EN = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
afterEach(() => {
setLang('zh');
setI18nWarn(null);
for (const l of ['en', 'qaa', 'qab', 'pt', 'pt-BR', 'zh-TW', 'ru']) setMessages(l, {});
});
const tmpdir = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `sp-packs-${tag}-`));
const put = (dir, rel, body) => {
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
};
/** A checkout-shaped temporary root with example packs of both layouts and a few broken ones. */
function exampleRoot() {
const dir = tmpdir('root');
put(dir, 'public/i18n/en.json', { _meta: { name: 'English', complete: true }, 开始: 'Start', 设置: 'Settings' });
put(dir, 'public/i18n/qaa.json', { _meta: { name: 'Qaa-Sprache', englishName: 'Test language A', fallback: ['en'], app: '>=0.2.0', authors: 'Tester' }, 开始: 'Los' });
put(dir, 'data/i18n/qaa.json', { version: 1, lang: 'qaa', names: {}, files: {} });
put(dir, 'public/i18n/zh.json', { 开始: '开始' }); // the source language is never a pack
put(dir, 'public/i18n/QAC.json', { 开始: 'x' }); // not in its usual case
put(dir, 'public/i18n/qad.json', '{ not json');
put(dir, 'packs/qab/pack.json', { type: 'lang', lang: 'qab', name: 'Qab', version: '1.0.0', app: '0.1.x', files: { ui: 'ui.json', data: 'texts/game.json' } });
put(dir, 'packs/qab/ui.json', { 开始: 'Qab-Start', 设置: 'Qab-Settings' });
put(dir, 'packs/qab/texts/game.json', { version: 1, lang: 'qab', names: { 阿米娅: 'Qab-Amiya' }, files: {} });
put(dir, 'packs/qab/notes.html', '<script>alert(1)</script>');
put(dir, 'packs/art/pack.json', { type: 'assets', name: 'HD art' });
put(dir, 'packs/dup/pack.json', { type: 'lang', lang: 'qaa', files: { ui: 'ui.json' } });
put(dir, 'packs/dup/ui.json', { 开始: 'dup' });
put(dir, 'packs/broken/pack.json', { type: 'lang', files: { ui: '../escape.json' } });
put(dir, 'packs/nomanifest/ui.json', { 开始: 'x' });
return dir;
}
const dirsOf = (root) => ({ publicDir: path.join(root, 'public'), dataDir: path.join(root, 'data'), packsDir: path.join(root, 'packs') });
test('discovery: single-file language packs and folder packs, one registry; broken, planned and duplicate packs are skipped with a reason', () => {
const root = exampleRoot();
try {
const scan = scanPacks(dirsOf(root), { app: '0.2.0' });
assert.deepEqual(scan.packs.map((p) => [p.id, p.type, p.layout, p.manifest.lang]), [['en', 'lang', 'file', 'en'], ['qaa', 'lang', 'file', 'qaa'], ['qab', 'lang', 'folder', 'qab']]);
const why = Object.fromEntries(scan.skipped.map((s) => [s.where, s.problems.join('; ')]));
assert.match(why['public/i18n/zh.json'], /source language/);
assert.match(why['public/i18n/QAC.json'], /not a language code in its usual case/);
assert.match(why['public/i18n/qad.json'], /not valid JSON/);
assert.match(why['packs/art/'], /type "assets" is planned/);
assert.match(why['packs/dup/'], /language qaa is already provided by public\/i18n\/qaa\.json/);
assert.match(why['packs/broken/'], /no "lang".*"files\.ui": "\.\.\/escape\.json" is not a path inside the pack folder/);
assert.match(why['packs/nomanifest/'], /no pack\.json/);
assert.ok(scan.warnings.some((w) => w.where === 'packs/qab/' && /made for app 0\.1\.x; this is 0\.2\.0/.test(w.warning)));
const index = packIndexOf(scan.packs, { app: '0.2.0' });
const qaa = index.packs.find((p) => p.id === 'qaa');
assert.deepEqual(qaa, {
id: 'qaa', type: 'lang', name: 'Qaa-Sprache', englishName: 'Test language A', app: '>=0.2.0', compatible: true, authors: ['Tester'],
lang: 'qaa', fallback: ['en'], strings: 1, files: { ui: '/i18n/qaa.json', data: '/data/i18n/qaa.json' },
});
const qab = index.packs.find((p) => p.id === 'qab');
assert.equal(qab.compatible, false, 'outside its app range: listed, flagged');
assert.deepEqual(qab.files, { ui: '/packs/qab/ui.json', data: '/packs/qab/texts/game.json' });
assert.equal(index.packs.find((p) => p.id === 'en').files.data, undefined, 'no data/i18n/en.json in this root');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('discovery: a pack dropped into the folder shows on the next look (no restart), a removed one leaves; only named files are servable', () => {
const root = exampleRoot();
try {
const reg = createPackRegistry(dirsOf(root), { recheckMs: 0 });
assert.ok(!reg.index().packs.some((p) => p.id === 'qae'));
put(root, 'public/i18n/qae.json', { _meta: { name: 'Neu' }, 开始: 'Neu-Start' });
assert.ok(reg.index().packs.some((p) => p.id === 'qae' && p.name === 'Neu'), 'appears without a restart');
put(root, 'packs/qaf/pack.json', { type: 'lang', lang: 'qaf', files: { ui: 'ui.json' } });
put(root, 'packs/qaf/ui.json', { 开始: 'F' });
assert.ok(reg.index().packs.some((p) => p.id === 'qaf' && p.files.ui === '/packs/qaf/ui.json'));
fs.rmSync(path.join(root, 'public/i18n/qae.json'));
assert.ok(!reg.index().packs.some((p) => p.id === 'qae'), 'removed');
assert.equal(reg.servable('qab', 'ui.json'), path.join(root, 'packs/qab/ui.json'));
assert.equal(reg.servable('qab', 'texts/game.json'), path.join(root, 'packs/qab/texts/game.json'));
assert.equal(reg.servable('qab', 'notes.html'), null, 'a file the manifest does not name');
assert.equal(reg.servable('qab', 'pack.json'), null);
assert.equal(reg.servable('art', 'pack.json'), null, 'a planned type is never served');
assert.equal(reg.servable('qaa', 'qaa.json'), null, 'a single-file pack is served by the language folders');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('the server: GET /packs/index.json lists the packs live, GET /packs/<id>/<file> serves only what a manifest names', async () => {
const root = exampleRoot();
const { startServer } = await import('../server/index.js');
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(root, 'public'), packsDir: path.join(root, 'packs') });
try {
const get = (u) => fetch(srv.url + u);
const res = await get('/packs/index.json');
assert.equal(res.status, 200);
assert.equal(res.headers.get('cache-control'), 'no-store');
const body = await res.json();
assert.equal(body.version, 1);
assert.deepEqual(readPackIndex(body, 'lang').map((p) => p.id), ['en', 'qaa', 'qab']);
assert.equal((await get('/packs/qab/ui.json')).status, 200);
assert.deepEqual(await (await get('/packs/qab/texts/game.json')).json(), { version: 1, lang: 'qab', names: { 阿米娅: 'Qab-Amiya' }, files: {} });
for (const u of ['/packs/qab/notes.html', '/packs/qab/pack.json', '/packs/art/pack.json', '/packs/qab/', '/packs/nope/ui.json']) {
assert.equal((await get(u)).status, 404, u);
}
// a raw path (fetch would normalize the dots away)
const raw = await new Promise((resolve, reject) => {
http.get({ host: '127.0.0.1', port: srv.port, path: '/packs/qab/../qab/ui.json' }, (r) => { r.resume(); resolve(r.statusCode); }).on('error', reject);
});
assert.equal(raw, 404);
assert.equal((await get('/i18n/qaa.json')).status, 200, 'a single-file pack: the language folder');
put(root, 'public/i18n/qag.json', { 开始: 'G' });
await new Promise((r) => setTimeout(r, 1100)); // the registry looks again after a second
assert.ok((await (await get('/packs/index.json')).json()).packs.some((p) => p.id === 'qag'), 'a dropped pack shows on the next request');
} finally {
await srv.close();
fs.rmSync(root, { recursive: true, force: true });
}
});
test('manifest: common fields, type fields, app ranges; English is a pack like any other', () => {
assert.deepEqual(Object.entries(PACK_TYPES).map(([k, v]) => [k, v.status]), [['lang', 'supported'], ['assets', 'planned'], ['data', 'planned']]);
for (const [range, v, ok] of [['>=0.2.0', '0.2.0-dev', true], ['>=0.2.0', '0.1.4', false], ['0.2.x', '0.2.7', true], ['0.2.x', '0.3.0', false],
['^0.2.1', '0.2.9', true], ['^0.2.1', '0.3.0', false], ['^1.2.0', '1.9.0', true], ['~0.2.1', '0.2.0', false], ['>=0.2.0 <0.3.0', '0.2.5', true],
['>=0.2.0 <0.3.0', '0.3.0', false], ['0.1.x || 0.2.x', '0.2.0', true], ['*', '9.9.9', true], ['', '0.0.1', true], ['0.2.0', '0.2.1', false]]) {
assert.equal(appVersionMatches(range, v), ok, `${range} ∋ ${v}`);
}
assert.equal(isVersionRange('soon'), false);
assert.equal(appVersionMatches('soon', '0.2.0'), true, 'an unreadable range never blocks');
const { manifest, problems, warnings } = normalizeManifest({ type: 'lang', lang: 'pt_br', id: 'other', name: 'Português', fallback: ['pt', 'zh', 'en'], base: 'zh', numberUnits: ['a'] }, { id: 'pt-BR', folder: true, app: '0.2.0' });
assert.deepEqual(problems, ['no "files.ui"']);
assert.equal(manifest, null);
assert.ok(warnings.some((w) => /"id": "other" is not "pt-BR"/.test(w)));
const ok = normalizeManifest({ type: 'lang', lang: 'pt-BR', fallback: ['pt', 'zh', 'en'], base: 'zh', files: { ui: 'ui.json', extra: 'x.json' } }, { id: 'pt-BR', folder: true, app: '0.2.0' });
assert.deepEqual([ok.manifest.lang, ok.manifest.base, ok.manifest.fallback, ok.manifest.files], ['pt-BR', null, ['pt', 'en'], { ui: 'ui.json' }], 'the source is never a base or a fallback');
assert.ok(ok.warnings.some((w) => /files\.extra/.test(w)));
assert.match(normalizeManifest({ type: 'theme' }, { id: 'x', folder: true }).problems.join(), /unknown type "theme"/);
assert.match(normalizeManifest({ type: 'assets' }, { id: 'x', type: 'lang', lang: 'qaa' }).problems.join(), /"type": "assets" where a lang pack belongs/);
assert.deepEqual(['ui.json', 'a/b.json', '../x.json', '/abs.json', 'a//b.json', '.hidden.json', 'a\\b.json'].map(isPackPath), [true, true, false, false, false, false, false]);
// English: a single-file pack whose `_meta` is the manifest — complete, no fallback, nothing special in the client
const en = normalizeManifest(EN._meta, { id: 'en', type: 'lang', lang: 'en', app: '0.2.0' });
assert.deepEqual(en.problems, []);
assert.deepEqual(en.warnings, []);
assert.deepEqual([en.manifest.name, en.manifest.complete, en.manifest.fallback, en.manifest.numberUnits], ['English', true, [], null]);
assert.ok(en.manifest.authors.some((a) => a.includes('@YuriRestia')), 'PR #70 credited');
});
test('fallback chain: the pack, its base (pt-BR → pt; zh-TW none), its fallbacks, then the Chinese msgid; cycles end', () => {
const metas = { 'pt-BR': { fallback: ['en'] }, pt: { fallback: ['es'] }, es: {}, en: {}, 'zh-TW': { fallback: [] }, a: { fallback: ['b'] }, b: { fallback: ['a'] } };
const known = (c) => Object.hasOwn(metas, c);
assert.deepEqual(computeChain('pt-BR', (c) => metas[c], known), ['pt-BR', 'pt', 'es', 'en']);
assert.deepEqual(computeChain('zh-TW', (c) => metas[c], known), ['zh-TW'], 'zh is the source: no base');
assert.deepEqual(computeChain('pt-BR', (c) => metas[c], (c) => c !== 'pt' && known(c)), ['pt-BR', 'en'], 'a base without a pack is skipped');
assert.deepEqual(computeChain('a', (c) => metas[c], known), ['a', 'b']);
assert.deepEqual(computeChain('pt-BR', () => ({ base: null }), known), ['pt-BR'], 'base: null switches it off');
// through t() / tc(): a partial pack falls through per string
registerLangs([{ code: 'pt-BR', name: 'Português (Brasil)', fallback: ['en'] }, { code: 'pt', name: 'Português' }, { code: 'en', name: 'English' }]);
addMessages('en', { 开始: 'Start', 设置: 'Settings', 关闭: 'Close', 'toggle::关闭': 'Off', 准备: 'Ready' });
addMessages('pt', { 设置: 'Configurações', 关闭: 'Fechar' });
addMessages('pt-BR', { 开始: 'Iniciar' });
assert.deepEqual(langChain('pt-BR'), ['pt-BR', 'pt', 'en']);
assert.equal(normalizeLang('PT_br'), 'pt-BR');
assert.equal(normalizeLang('pt-PT'), 'pt', 'a region without a pack takes the language');
setLang('pt-BR');
assert.deepEqual([t('开始'), t('设置'), t('准备'), t('没有的')], ['Iniciar', 'Configurações', 'Ready', '没有的']);
assert.equal(tc('toggle', '关闭'), 'Fechar', 'per language: pt\'s plain msgid before en\'s context key');
setLang('en');
assert.equal(tc('toggle', '关闭'), 'Off');
assert.equal(t('开始'), 'Start');
});
test('placeholders: a translation that drops one or uses one the caller does not pass is skipped (reported once), the chain goes on', () => {
assert.deepEqual(checkTranslation('还剩 {n} 秒', '{n} seconds left'), { ok: true, problems: [], extras: [] });
assert.deepEqual(checkTranslation('还剩 {n} 秒', 'seconds left').problems, ['drops {n}']);
assert.deepEqual(checkTranslation('还剩 {n} 秒', '{n|second|seconds} left').problems, ['drops {n}'], 'a plural form does not show the number');
assert.deepEqual(checkTranslation('{n} 名', '{n} {count|unit|units}').extras, ['count']);
assert.deepEqual(checkTranslation('{n}', '{n} {n|a|b|c}').problems, ['malformed plural {n|a|b|c}']);
registerLangs([{ code: 'qaa', fallback: ['en'] }, { code: 'en' }]);
addMessages('en', { '还剩 {n} 秒': '{n} seconds left', '临时整备区 {n} 个单位待处理': 'Bench: {n} {count|unit|units}' });
addMessages('qaa', { '还剩 {n} 秒': 'nur noch Sekunden', '临时整备区 {n} 个单位待处理': '{n} {count|Einheit|Einheiten} {extra}' });
const seen = [];
setI18nWarn((w) => seen.push(w));
setLang('qaa');
assert.equal(t('还剩 {n} 秒', { n: 3 }), '3 seconds left', 'qaa drops {n}: English shows');
assert.equal(t('还剩 {n} 秒', { n: 4 }), '4 seconds left');
assert.equal(t('临时整备区 {n} 个单位待处理', { n: 2, count: 2 }), 'Bench: 2 units', '{extra} is not passed');
assert.equal(t('临时整备区 {n} 个单位待处理', { n: 2, count: 2, extra: '!' }), '2 Einheiten !', 'passed: fine');
assert.deepEqual(seen.map((w) => [w.lang, w.key, w.problems.join()]), [
['qaa', '还剩 {n} 秒', 'drops {n}'],
['qaa', '临时整备区 {n} 个单位待处理', 'uses {extra}, which the caller does not pass'],
], 'once per language and key');
setMessages('en', {});
assert.equal(t('还剩 {n} 秒', { n: 3 }), '还剩 3 秒', 'nothing fits: the Chinese msgid');
});
test('plurals: {n|one|other} by Intl.PluralRules (English as before); named categories for other languages (Russian)', () => {
assert.deepEqual(parsePluralForms('|second|seconds'), { one: 'second', other: 'seconds' });
assert.deepEqual(parsePluralForms('|one:день|few:дня|many:дней|other:дня'), { one: 'день', few: 'дня', many: 'дней', other: 'дня' });
assert.equal(parsePluralForms('|one:a|few:b'), null, 'named forms need other');
assert.equal(parsePluralForms('|one:a|b'), null, 'no mixing');
assert.equal(parsePluralForms('|a|b|c'), null, 'three plain forms: the category order is not stable');
assert.deepEqual([0, 1, 2, 5, 21, 1.5].map((n) => pluralCategory('en', n)), ['other', 'one', 'other', 'other', 'other', 'other']);
assert.deepEqual([0, 1, 2, 5, 21, 1.5].map((n) => pluralCategory('ru', n)), ['many', 'one', 'few', 'many', 'one', 'other']);
assert.deepEqual([0, 1, 2].map((n) => pluralCategory('qaa', n)), ['other', 'one', 'other'], 'a language the runtime does not know: one for exactly 1');
registerLangs([{ code: 'ru', name: 'Русский' }, { code: 'en' }]);
addMessages('en', { '还剩 {n} 秒': '{n} {n|second|seconds} left' });
addMessages('ru', { '还剩 {n} 秒': 'осталось {n} {n|one:секунда|few:секунды|many:секунд|other:секунды}', '{n} 名干员': '{n} {n|оператор|операторов}' });
setLang('en');
assert.deepEqual([0, 1, 2].map((n) => t('还剩 {n} 秒', { n })), ['0 seconds left', '1 second left', '2 seconds left']);
setLang('ru');
assert.deepEqual([1, 2, 5, 21, 1.5].map((n) => t('还剩 {n} 秒', { n })), ['осталось 1 секунда', 'осталось 2 секунды', 'осталось 5 секунд', 'осталось 21 секунда', 'осталось 1.5 секунды']);
assert.deepEqual([1, 3].map((n) => t('{n} 名干员', { n })), ['1 оператор', '3 операторов'], 'two plain forms: one / the rest');
const b = { type: 'b' }; // a vnode stand-in: no number, so the form of 'other'
assert.deepEqual(tParts('还剩 {n} 秒', { n: b }), ['осталось ', b, ' секунды']);
assert.equal(format('{n|one:a|other:b}', { n: 1 }, { lang: 'ru' }), 'a');
});
test('zh and English output of t() / tc() / tParts() is what it was before language packs (every msgid of public/i18n/en.json)', () => {
// the renderer of 0.2.0 before packs, frozen: one catalog, `{n|one|other}` by Number(v) === 1, lists ', ' / '、'
const OLD = /\{([A-Za-z0-9_$]+)(?:\|([^{}|]*)\|([^{}|]*))?\}/g;
const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
const val = (v, lang) => (v == null ? '' : Array.isArray(v) ? v.map((x) => val(x, lang)).filter((x) => x !== '').join(lang === 'en' ? ', ' : '、') : typeof v === 'object' ? (own(v, 'dn') ? String(v.dn) : '') : String(v));
const oldFormat = (s, params, lang) => (!params || !s.includes('{') ? s : s.replace(OLD, (m, k, one, other) => (!own(params, k) ? m : one !== undefined ? (Number(params[k]) === 1 ? one : other) : val(params[k], lang))));
addMessages('en', EN);
const samples = [0, 1, 2, 5, 21, 'x', ['甲', '乙']];
let compared = 0;
for (const lang of ['zh', 'en']) {
setLang(lang);
for (const [key, tr] of Object.entries(EN)) {
if (key.startsWith('_')) continue;
const sep = key.indexOf('::');
const ctx = sep >= 0 ? key.slice(0, sep) : null;
const msgid = sep >= 0 ? key.slice(sep + 2) : key;
const names = [...new Set([...`${msgid} ${tr}`.matchAll(OLD)].map((m) => m[1]))];
for (let k = 0; k < samples.length; k++) {
const params = names.length ? Object.fromEntries(names.map((nm, i) => [nm, samples[(i + k) % samples.length]])) : undefined;
const want = oldFormat(lang === 'en' ? tr : msgid, params, lang);
const got = ctx ? tc(ctx, msgid, params) : t(msgid, params);
assert.equal(got, want, `${lang} ${key} ${JSON.stringify(params)}`);
compared++;
if (!names.length) break;
}
}
}
assert.ok(compared > 3000, `${compared} renders compared`);
assert.equal(getLang(), 'en');
});
test('game texts fall back per text: the pack\'s overlay, then its fallback\'s, then Chinese (data.js chains, i18nData onto)', async () => {
const zh = { a: { name: '甲', desc: '一', skills: [{ name: '技' }] }, b: { name: '乙' } };
const ov = (lang, leaves) => ({ version: 1, lang, names: {}, files: { chess: leaves } });
const qaa = ov('qaa', { a: buildRecordOverlay([{ path: ['name'], zh: '甲', en: 'A-qaa' }]) });
const en = ov('en', { a: buildRecordOverlay([{ path: ['name'], zh: '甲', en: 'A' }, { path: ['desc'], zh: '一', en: 'one' }, { path: ['skills', 0, 'name'], zh: '技', en: 'Skill' }]), b: buildRecordOverlay([{ path: ['name'], zh: '乙', en: 'B' }]) });
en.names = { 甲: 'A' };
qaa.names = { 乙: 'B-qaa' };
// the chained apply: lowest priority first, each over the last, hashes always against the Chinese
let v = applyFileOverlay(zh, en.files.chess).value;
v = applyFileOverlay(zh, qaa.files.chess, v).value;
assert.deepEqual(v, { a: { name: 'A-qaa', desc: 'one', skills: [{ name: 'Skill' }] }, b: { name: 'B' } });
const stale = applyFileOverlay({ ...zh, a: { ...zh.a, name: '变了' } }, qaa.files.chess, v);
assert.equal(stale.value.a.name, 'A-qaa', 'a stale leaf keeps what the overlays before it left');
assert.equal(stale.stale, 1);
const files = { '/data/chess.json': zh, '/packs/qaa/game.json': qaa, '/data/i18n/en.json': en };
const calls = [];
const d = createDataStore({ retryDelays: [], fetch: async (url) => { calls.push(url); return files[url] ? { ok: true, status: 200, json: async () => files[url] } : { ok: false, status: 404, json: async () => null }; } });
await d.load('chess');
assert.equal(await d.setLocale('qaa', [{ code: 'qaa', url: '/packs/qaa/game.json' }, 'en']), 'qaa');
assert.deepEqual(calls.filter((u) => u.includes('i18n') || u.includes('packs')).sort(), ['/data/i18n/en.json', '/packs/qaa/game.json']);
assert.deepEqual(d.lookup('chess', 'a'), { name: 'A-qaa', desc: 'one', skills: [{ name: 'Skill' }] });
assert.equal(d.lookup('chess', 'b').name, 'B');
assert.deepEqual([d.localeName('乙'), d.localeName('甲'), d.localeName('丙')], ['B-qaa', 'A', '丙'], 'names: the first overlay of the chain that knows one');
assert.deepEqual(d.localeChain(), ['qaa', 'en']);
assert.equal(await d.setLocale('qab', []), 'qab', 'a pack without game texts switches with the Chinese ones');
assert.equal(d.lookup('chess', 'a').name, '甲');
const w = console.warn;
console.warn = () => {};
try {
assert.equal(await d.setLocale('qac', ['qac']), 'qab', 'every overlay of the chain missing: no switch (as before packs)');
} finally { console.warn = w; }
});
test('client: the index registers the language packs; the menu lists them (buttons up to four, a list beyond); packs load by their URLs', async () => {
const lang = await import('../public/js/ui/lang.js');
const index = { version: 1, packs: [
{ id: 'en', type: 'lang', lang: 'en', name: 'English', englishName: 'English', fallback: [], files: { ui: '/i18n/en.json', data: '/data/i18n/en.json' } },
{ id: 'qab', type: 'lang', lang: 'qab', name: 'Qab', englishName: 'Test B', fallback: ['en'], numberUnits: ['W', 'Y'], files: { ui: '/packs/qab/ui.json' } },
{ id: 'art', type: 'assets', name: 'x', files: {} },
{ id: 'evil', type: 'lang', lang: 'qay', name: 'x', files: { ui: 'https://example.com/x.json' } },
] };
const got = [];
const fetchFake = async (url) => {
got.push(url);
const body = url === '/packs/index.json' ? index : url === '/packs/qab/ui.json' ? { 开始: 'Qab-Start' } : url === '/i18n/en.json' ? { 开始: 'Start', 设置: 'Settings' } : null;
return body ? { ok: true, status: 200, json: async () => body } : { ok: false, status: 404, json: async () => null };
};
assert.equal(await lang.loadLangIndex(fetchFake), true);
assert.deepEqual(readPackIndex(index, 'lang').map((e) => e.id), ['en', 'qab'], 'other types and off-site URLs are dropped');
assert.deepEqual(getLangs().map((m) => m.code).filter((c) => ['zh', 'en', 'qab'].includes(c)), ['zh', 'en', 'qab'], 'the source first, then by code');
assert.equal(langInfo('qab').ui, '/packs/qab/ui.json');
const menu = lang.langMenuModel(getLangs().filter((m) => ['zh', 'en', 'qab'].includes(m.code)), 'en');
assert.equal(menu.kind, 'buttons');
assert.deepEqual(menu.items.map((i) => [i.code, i.label, i.htmlLang, i.on]), [['zh', '中文', 'zh-CN', false], ['en', 'English', 'en', true], ['qab', 'Qab', 'qab', false]]);
assert.equal(menu.items[2].title, 'Test B', 'the English name as a tooltip when it reads differently');
const many = ['a1', 'a2', 'a3', 'a4', 'a5'].map((c) => ({ code: c, name: c, englishName: c }));
assert.equal(lang.langMenuModel(many, 'a1').kind, 'select');
assert.equal(await lang.loadLangChain('qab', fetchFake), true);
assert.ok(got.includes('/packs/qab/ui.json') && got.includes('/i18n/en.json'), 'the pack and its fallback, by their URLs');
assert.deepEqual(lang.dataChain('qab'), [{ code: 'en', url: '/data/i18n/en.json' }], 'only packs with game texts');
setLang('qab');
assert.deepEqual([t('开始'), t('设置')], ['Qab-Start', 'Settings']);
const { fmtNum } = await import('../public/js/ui/gameLogic.js');
assert.deepEqual([fmtNum(150_000), fmtNum(2.5e8)], ['15.0W', '2.5Y'], 'the pack\'s own number units');
setLang('en');
assert.deepEqual([fmtNum(150_000), fmtNum(2.5e8)], ['150K', '250M'], 'English: K / M / B as before');
setLang('zh');
assert.deepEqual([fmtNum(150_000), fmtNum(2.5e8)], ['15.0万', '2.5亿'], 'Chinese as before');
assert.deepEqual(lang.initialLang('?lang=QAB', () => null), { lang: 'qab', fromUrl: true });
assert.deepEqual(lang.initialLang('', () => 'qzz'), { lang: 'zh', fromUrl: false }, 'a stored pack that is gone: Chinese');
assert.deepEqual(lang.initialLang('', () => 'qzz', { tentative: true }), { lang: 'qzz', fromUrl: false }, 'without an index: tried by its code');
assert.deepEqual([scriptOf('Stronghold Protocol'), scriptOf('卫戍协议'), scriptOf('堅守協定'), scriptOf('위수 협의'), scriptOf('Протокол')], ['alphabetic', 'cjk', 'cjk', 'cjk', 'alphabetic']);
assert.deepEqual(langMetaOf(readPackIndex(index, 'lang')[1]).numberUnits, ['W', 'Y']);
assert.deepEqual(langFields('qab', { base: 'zh', fallback: 'en' }), { lang: 'qab', base: null, fallback: ['en'], complete: false, numberUnits: null });
assert.equal(canonicalLang('zh-hant-tw'), 'zh-Hant-TW');
});
test('tools: template writes a skeleton (or adds the missing msgids to a pack), check reports coverage and errors per pack; packs index writes the static index', () => {
const used = new Map([['开始', { where: 'a.js:1', params: [] }], ['还剩 {n} 秒', { where: 'b.js:2', params: new Set(['n']) }], ['{n} 名', { where: 'c.js:3', params: new Set(['n', 'count']) }], ['表里的', { where: 'd.js:4', params: null }]]);
const skel = packTemplate('qaa', { msgids: [...used.keys()] });
assert.deepEqual(Object.keys(skel.json), ['_meta', '开始', '还剩 {n} 秒', '{n} 名', '表里的']);
assert.deepEqual([skel.json._meta.type, skel.json._meta.lang, skel.json._meta.fallback, skel.json._meta.app], ['lang', 'qaa', ['en'], '>=0.2.0']);
assert.equal(packTemplate('zh-TW', { msgids: [] }).json._meta.fallback.length, 0, 'a Chinese variant falls back to the Chinese msgid');
assert.equal(skel.json['开始'], '');
const again = packTemplate('qaa', { existing: { _meta: { name: 'Mine' }, 开始: 'Los', 旧的: 'alt' }, msgids: [...used.keys()], fill: { 还剩: 'x' } });
assert.deepEqual([again.json._meta, again.json['开始'], again.json['旧的'], again.kept, again.added], [{ name: 'Mine' }, 'Los', 'alt', 1, 3], 'translations and _meta kept, the rest added');
const r = checkPack({ 开始: 'Los', '还剩 {n} 秒': 'Sekunden', '{n} 名': '{n} {count|Name|Namen} {who}', 表里的: '{any}', 旧的: 'alt', 'x::y': 3 }, used, new Set([...used.keys()]));
assert.deepEqual([r.total, r.translated, r.missing.length, r.obsolete], [4, 2, 0, ['旧的']]);
assert.deepEqual(r.errors.map((e) => e.split(':')[0]), ['not a string', 'drops {n}', 'uses {who}, which no call site passes']);
// the CLI on a temporary checkout's packs (the msgids come from this checkout's code)
const root = tmpdir('cli');
try {
put(root, 'public/i18n/en.json', EN);
const run = (...args) => spawnSync(process.execPath, [path.join(ROOT, 'tools/i18n.mjs'), ...args, '--root', root], { encoding: 'utf8' });
const tpl = run('template', 'qaa');
assert.equal(tpl.status, 0, tpl.stderr);
assert.match(tpl.stdout, /wrote public\/i18n\/qaa\.json: \d+ msgids — 0 translations kept, \d+ added \(empty\)/);
const pack = JSON.parse(fs.readFileSync(path.join(root, 'public/i18n/qaa.json'), 'utf8'));
assert.equal(Object.keys(pack).length - 1, Object.keys(EN).filter((k) => !k.startsWith('_')).length, 'every msgid of the complete pack');
pack['开始'] = 'Los';
fs.writeFileSync(path.join(root, 'public/i18n/qaa.json'), JSON.stringify(pack));
const ok = run('check', 'qaa', '--strict');
assert.equal(ok.status, 0, `${ok.stdout}${ok.stderr}`);
assert.match(ok.stdout, /public\/i18n\/qaa\.json \(qaa, qaa\): \d+ msgids used, 1 translated \([\d.]+ %\), \d+ missing, 0 errors/);
pack['还剩 {n} 秒'] = 'x';
pack['第 {r} 回合 · 最终攻势'] = 'Runde';
fs.writeFileSync(path.join(root, 'public/i18n/qaa.json'), JSON.stringify(pack));
const bad = run('check', 'qaa', '--strict');
assert.equal(bad.status, 1);
assert.match(bad.stdout, /error {4}public\/i18n\/qaa\.json: drops \{r\}: 第 \{r\} 回合 · 最终攻势 {2}→ {2}Runde/);
const all = run('check', '--all');
assert.match(all.stdout, /public\/i18n\/en\.json \(English, en, complete\): (\d+) msgids used, \1 translated \(100 %\), 0 missing, 0 errors/);
const body = writePackIndex(root);
assert.deepEqual(body.packs.map((p) => p.id), ['en', 'qaa']);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'packs/index.json'), 'utf8')), body);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('build-i18n --lang: another official client (a text equal to the Chinese is no translation, no English composites); --dict for a language without one', () => {
assert.deepEqual(Object.keys(LANG_SOURCES), ['en', 'ja', 'ko', 'zh-TW']);
assert.match(LANG_SOURCES.ja.sources.assets.url, /ArknightsAssets\/ArknightsGamedata\/master\/jp\/gamedata\/$/);
const zh = [{ chars: { char_x: { name: '测试员', description: '攻击造成法术伤害' } } }];
const ja = [{ chars: { char_x: { name: 'テスター', description: '攻击造成法术伤害' } } }];
const data = { chess: { chess_x_a: { charId: 'char_x', name: '测试员', trait: { desc: '攻击造成法术伤害' } } }, stages: { m1: { name: '战场#05(下半) 测试员' } } };
const { overlay, report } = buildOverlay({ zh, en: ja, data, lang: 'ja' });
assert.equal(overlay.lang, 'ja');
const c = applyRecordOverlay(data.chess.chess_x_a, overlay.files.chess.chess_x_a).value;
assert.deepEqual([c.name, c.trait.desc], ['テスター', '攻击造成法术伤害'], 'the JP text equal to the Chinese one stays untranslated (falls back at run time)');
assert.equal(overlay.files.stages, undefined, 'the remake\'s stage-name composite is English wording only');
assert.equal(overlay.names['测试员'], 'テスター');
assert.equal(report.coverage.operators.translated, 1);
const fr = buildOverlay({ zh: [], en: [], data, lang: 'fr', fallback: { dict: { 测试员: 'Testeur', 攻击造成法术伤害: 'Inflige des dégâts magiques' } } });
const f = applyRecordOverlay(data.chess.chess_x_a, fr.overlay.files.chess.chess_x_a).value;
assert.deepEqual([fr.overlay.lang, f.name, f.trait.desc, fr.overlay.names['测试员']], ['fr', 'Testeur', 'Inflige des dégâts magiques', 'Testeur']);
assert.deepEqual([fr.report.coverage.operators.dict, fr.report.coverage.traits.dict], [1, 1], 'counted as the dictionary\'s');
});
+37
View File
@@ -261,6 +261,43 @@ test('a --no-install build of the temporary checkout zips exactly the plan in on
} }
}); });
test('content packs ship: every committed pack of both layouts, never one only on this machine; the stage gets packs/index.json listing exactly them', { skip: !hasZipTool && 'no zip / tar' }, () => {
const { dir, put } = fakeCheckout();
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-out-'));
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
try {
const PACK_FILES = ['public/i18n/en.json', 'public/i18n/qaa.json', 'data/i18n/qaa.json', 'packs/README.md', 'packs/qab/pack.json', 'packs/qab/ui.json'];
put('public/i18n/en.json', JSON.stringify({ _meta: { name: 'English', complete: true }, 开始: 'Start' }));
put('public/i18n/qaa.json', JSON.stringify({ _meta: { name: 'Qaa', fallback: ['en'] }, 开始: 'Los' }));
put('data/i18n/qaa.json', JSON.stringify({ version: 1, lang: 'qaa', names: {}, files: {} }));
put('packs/README.md', '# packs\n');
put('packs/qab/pack.json', JSON.stringify({ type: 'lang', lang: 'qab', name: 'Qab', files: { ui: 'ui.json' } }));
put('packs/qab/ui.json', JSON.stringify({ 开始: 'Q' }));
assert.equal(git('add', ...PACK_FILES).status, 0);
// installed here, never committed: stays out; a stale index on disk is never shipped as it is
put('packs/local/pack.json', JSON.stringify({ type: 'lang', lang: 'qac', files: { ui: 'ui.json' } }));
put('packs/local/ui.json', JSON.stringify({ 开始: 'L' }));
put('packs/index.json', '{ "stale": true }\n');
const dry = runTool(['--root', dir, '--dry-run', '--list', '--lite', '--allow-dirty']);
assert.equal(dry.status, 0, dry.stdout + dry.stderr);
assert.deepEqual(listed(dry.stdout).sort(), [...SHIPPED_TRACKED, ...PACK_FILES, 'packs/index.json'].sort());
assert.match(dry.stdout, /^generated: packs\/index\.json \(the pack index of the shipped packs, for a static host\)$/m);
const r = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty', '--lite', '--keep-stage']);
assert.equal(r.status, 0, r.stdout + r.stderr);
const stage = path.join(out, `${FOLDER}-v9.9.9-lite`, FOLDER);
const index = JSON.parse(fs.readFileSync(path.join(stage, 'packs/index.json'), 'utf8'));
assert.deepEqual(index.packs.map((x) => [x.id, x.type, x.files]), [
['en', 'lang', { ui: '/i18n/en.json' }],
['qaa', 'lang', { ui: '/i18n/qaa.json', data: '/data/i18n/qaa.json' }],
['qab', 'lang', { ui: '/packs/qab/ui.json' }],
]);
assert.ok(!fs.existsSync(path.join(stage, 'packs/local')), 'a pack only on this machine is not in the zip');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(out, { recursive: true, force: true });
}
});
test('the personal scan reads bytes, never reports the matched text, and skips files it cannot read', () => { test('the personal scan reads bytes, never reports the matched text, and skips files it cannot read', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-'));
try { try {
+59 -3
View File
@@ -4,15 +4,25 @@
// //
// The title screen opens in Chinese; 中文 | English → English in place (no reload): the title, the start button, the // The title screen opens in Chinese; 中文 | English → English in place (no reload): the title, the start button, the
// connection line, <html lang>, the tab title; the choice is kept across a reload (localStorage sp.pref.lang) and the // connection line, <html lang>, the tab title; the choice is kept across a reload (localStorage sp.pref.lang) and the
// game-data overlay (data/i18n/en.json) is applied; `?lang=zh` switches back and leaves the address bar. No console / // game-data overlay (data/i18n/en.json) is applied; `?lang=zh` switches back and leaves the address bar. Language packs
// page / request errors. docs/I18N.md. // (docs/I18N.md "Adding a language", docs/PACKS.md): a pack file dropped into public/i18n/ and a pack folder dropped
// into packs/ while the server runs show in the menu and switch, with English filling what they lack; a removed pack
// sends a stored choice back to Chinese. No console / page / request errors. docs/I18N.md.
import { test, describe, before, after } from 'node:test'; import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CHROME, hasChrome, sleep } from '../e2e/client.mjs'; import { CHROME, hasChrome, sleep } from '../e2e/client.mjs';
import { NAME_MAX_LEN } from '../../shared/constants.js'; import { NAME_MAX_LEN } from '../../shared/constants.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome(); const ENABLED = process.env.SP_E2E === '1' && hasChrome();
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// example packs, only for this test ('qaa' … 'qtz' are the ISO 639 codes reserved for local use); removed afterwards
const PACK_FILE = path.join(ROOT, 'public/i18n/qaa.json');
const PACK_DIR = path.join(ROOT, 'packs/qab');
const removePacks = () => { fs.rmSync(PACK_FILE, { force: true }); fs.rmSync(PACK_DIR, { recursive: true, force: true }); };
describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=1 (Chrome)' }, () => { describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=1 (Chrome)' }, () => {
let srv; let srv;
@@ -25,7 +35,7 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
base = `http://127.0.0.1:${srv.port}`; base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] }); browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
}); });
after(async () => { await browser?.close(); await srv?.close(); }); after(async () => { removePacks(); await browser?.close(); await srv?.close(); });
const text = (page, sel) => page.$eval(sel, (el) => el.textContent.replace(/\s+/g, ' ').trim()); const text = (page, sel) => page.$eval(sel, (el) => el.textContent.replace(/\s+/g, ' ').trim());
@@ -69,4 +79,50 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
assert.deepEqual(problems, []); assert.deepEqual(problems, []);
await page.close(); await page.close();
}); });
test('a pack dropped into the language folder or into packs/ shows in the menu and switches; English fills its gaps', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400 && !/fonts\.(googleapis|gstatic)/.test(r.url())) problems.push(`http ${r.status()}: ${r.url()}`); });
try {
fs.writeFileSync(PACK_FILE, JSON.stringify({ _meta: { name: 'Testisch', englishName: 'Test language', fallback: ['en'] }, 卫戍协议: 'Testprotokoll', 盟约: 'Bund', 开始: 'Los' }));
fs.mkdirSync(PACK_DIR, { recursive: true });
fs.writeFileSync(path.join(PACK_DIR, 'pack.json'), JSON.stringify({ type: 'lang', lang: 'qab', name: 'Qabisch', files: { ui: 'ui.json' } }));
fs.writeFileSync(path.join(PACK_DIR, 'ui.json'), JSON.stringify({ 开始: 'Qab-Start' }));
await sleep(1100); // the server's registry looks at the folders again after a second
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.title-screen [data-testid="lang-toggle"] button[data-lang="qaa"]');
assert.deepEqual(await page.$$eval('[data-testid="lang-toggle"] button', (bs) => bs.map((b) => [b.dataset.lang, b.textContent.trim()])),
[['zh', '中文'], ['en', 'English'], ['qaa', 'Testisch'], ['qab', 'Qabisch']]);
await page.click('[data-testid="lang-toggle"] button[data-lang="qaa"]');
await page.waitForFunction(() => document.querySelector('.title-login .btn--primary')?.textContent.includes('Los'), { timeout: 8000 });
assert.equal(await text(page, '.title-cn'), 'Testprotokoll: Bund', 'an alphabetic title: the display face, an ASCII colon');
assert.match(await text(page, '.title-tag'), /^Allocate Funds and Operators/, 'untranslated: English, the pack\'s fallback');
assert.deepEqual(await page.evaluate(() => ({ lang: document.documentElement.lang, script: document.documentElement.dataset.script, pref: localStorage.getItem('sp.pref.lang') })),
{ lang: 'qaa', script: 'alphabetic', pref: '"qaa"' });
// no game texts of its own: English's through the chain
await page.waitForFunction(() => globalThis.__SP__?.data?.locale() === 'qaa', { timeout: 8000 });
assert.equal(await page.evaluate(() => globalThis.__SP__.data.get('config').modes.mode_multi_abyss.name), 'Ultimate Simulation');
await page.click('[data-testid="lang-toggle"] button[data-lang="qab"]');
await page.waitForFunction(() => document.querySelector('.title-login .btn--primary')?.textContent.includes('Qab-Start'), { timeout: 8000 });
assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约', 'no fallback declared: the Chinese msgid');
await page.click('[data-testid="lang-toggle"] button[data-lang="qaa"]');
await page.waitForFunction(() => document.querySelector('.title-login .btn--primary')?.textContent.includes('Los'), { timeout: 8000 });
removePacks();
await sleep(1100);
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('.title-screen .title-cn');
assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约', 'the stored pack is gone: Chinese');
assert.deepEqual(await page.$$eval('[data-testid="lang-toggle"] button', (bs) => bs.map((b) => b.dataset.lang)), ['zh', 'en']);
assert.deepEqual(problems, []);
} finally {
removePacks();
await page.close();
}
});
}); });
+99 -48
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data. // tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data (and, with --lang, the
// game texts of a language pack from another official client: ja, ko, zh-TW — docs/I18N.md "Adding a language").
// //
// The game texts of data/*.json (operators, skills, talents, modules, traits, enemies, bonds, items, effects, bands, // The game texts of data/*.json (operators, skills, talents, modules, traits, enemies, bonds, items, effects, bands,
// 特质, 机变 cards, modes, stages, tokens …) are the official zh_CN texts (tools/build-data.mjs). This tool writes their // 特质, 机变 cards, modes, stages, tokens …) are the official zh_CN texts (tools/build-data.mjs). This tool writes their
@@ -21,27 +22,47 @@
// Rich-text tags and placeholders of the official EN texts are the same as in zh_CN, so the client's rich-text // Rich-text tags and placeholders of the official EN texts are the same as in zh_CN, so the client's rich-text
// formatting (ui/richText.js) works unchanged; `desc` / `text` (plain) are the stripped `descRaw` / `textRaw`. // formatting (ui/richText.js) works unchanged; `desc` / `text` (plain) are the stripped `descRaw` / `textRaw`.
// //
// Usage: node tools/build-i18n.mjs [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] // Usage: node tools/build-i18n.mjs [--lang <code>] [--refresh | --offline] [--source assets|yostar] [--cache <dir>]
// [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet] // [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]
// --source EN tables: 'assets' (default) = ArknightsAssets/ArknightsGamedata en (current, has the 盟约 seasons // --lang the target language (default en): one with an official client in LANG_SOURCES — en, ja (the JP
// act1autochess / act2autochess), 'yostar' = Kengxxiao/ArknightsGameData_YoStar en_US (archived in // client), ko (KR), zh-TW (TW); all three carry both 盟约 seasons (verified 2026-10-07, data 51.x). The
// 2025-11: no 盟约 season, only the 2025 test event act1vautochess) // output goes to data/i18n/<code>.json, the game texts of the language pack <code> (it needs the pack's
// --refresh re-download the EN tables; --offline never download (fail when a table is missing) // UI file public/i18n/<code>.json to show in the menu). Any other code needs --dict.
// --dict a community dictionary { "<Chinese data text>": "<translation>" } (a language without an official
// client: the only source besides the pack's UI file; with a client: tried after it). Matching is exact
// (a text with its numbers resolved needs its own entry); the rest falls back per text at run time.
// --source tables of the language: 'assets' (default) = ArknightsAssets/ArknightsGamedata (en / jp / kr / tw;
// current, has the 盟约 seasons act1autochess / act2autochess); for en also 'yostar' =
// Kengxxiao/ArknightsGameData_YoStar en_US (archived in 2025-11: no 盟约 season, only the 2025 test event
// act1vautochess)
// --refresh re-download the target tables; --offline never download (fail when a table is missing)
// --cache zh_CN cache (default <repo>/.cache/gamedata, shared with build-data; missing tables are downloaded) // --cache zh_CN cache (default <repo>/.cache/gamedata, shared with build-data; missing tables are downloaded)
// --cache-en EN cache (default <repo>/.cache/gamedata-en for 'assets', .cache/gamedata-en-yostar for 'yostar') // --cache-en target cache (default <repo>/.cache/gamedata-en for en 'assets', .cache/gamedata-en-yostar for 'yostar',
// --data the data/*.json to localize (default <repo>/data); --out default <repo>/data/i18n/en.json // .cache/gamedata-<code> for the others)
// --report default <repo>/.cache/build-i18n-report.json (coverage per kind, untranslated samples) // --data the data/*.json to localize (default <repo>/data); --out default <repo>/data/i18n/<code>.json
// --report default <repo>/.cache/build-i18n-report.json (en; build-i18n-report.<code>.json for the others): coverage
// per kind, untranslated samples
// --check build in memory and exit 1 when the output differs from --out (nothing written) // --check build in memory and exit 1 when the output differs from --out (nothing written)
// In the code below "en" names the target side of the walk, whatever the language (the matching is the same).
// Determinism: the output depends only on the inputs (stable key order, no timestamps). // Determinism: the output depends only on the inputs (stable key order, no timestamps).
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'; import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { dirname, join, resolve, relative } from 'node:path'; import { basename, dirname, join, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { buildRecordOverlay, applyFileOverlay, OVERLAY_VERSION } from '../shared/i18nData.js'; import { buildRecordOverlay, applyFileOverlay, OVERLAY_VERSION } from '../shared/i18nData.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const USAGE = 'usage: node tools/build-i18n.mjs [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]'; const USAGE = 'usage: node tools/build-i18n.mjs [--lang en|ja|ko|zh-TW | --lang <code> --dict <file.json>] [--dict <file.json>] [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]';
const ASSETS_HOME = 'https://github.com/ArknightsAssets/ArknightsGamedata';
/** The official client tables of a language other than English (ArknightsAssets/ArknightsGamedata <server>/gamedata). */
const assetsSource = (server, code) => Object.freeze({
label: `ArknightsAssets/ArknightsGamedata (${server})`,
home: ASSETS_HOME,
url: `https://raw.githubusercontent.com/ArknightsAssets/ArknightsGamedata/master/${server}/gamedata/`,
cache: `.cache/gamedata-${code}`,
});
export const EN_SOURCES = Object.freeze({ export const EN_SOURCES = Object.freeze({
assets: { assets: {
@@ -59,6 +80,21 @@ export const EN_SOURCES = Object.freeze({
}); });
const ZH_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/'; const ZH_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
/**
* The official clients by target language, each with its table sources. `untranslated`: how a target text that is not
* a translation shows — 'cjk' (it still has Chinese characters: en, ko) or 'same' (it is the Chinese text itself: ja and
* zh-TW write with Han characters). `composite`: the remake-made stage names are assembled (English wording only).
*/
export const LANG_SOURCES = Object.freeze({
en: Object.freeze({ sources: EN_SOURCES, untranslated: 'cjk', composite: true }),
ja: Object.freeze({ sources: Object.freeze({ assets: assetsSource('jp', 'ja') }), untranslated: 'same', composite: false }),
ko: Object.freeze({ sources: Object.freeze({ assets: assetsSource('kr', 'ko') }), untranslated: 'cjk', composite: false }),
'zh-TW': Object.freeze({ sources: Object.freeze({ assets: assetsSource('tw', 'zh-TW') }), untranslated: 'same', composite: false }),
});
/** The test "this target text is no translation of that Chinese one" of a language (LANG_SOURCES `untranslated`). */
const untranslatedTest = (lang) => ((LANG_SOURCES[lang]?.untranslated ?? 'same') === 'same' ? (zh, t) => t === zh : (zh, t) => hasCjk(t));
/** Official tables walked side by side (zh_CN ↔ EN), with the sub-trees that hold texts the remake uses. */ /** Official tables walked side by side (zh_CN ↔ EN), with the sub-trees that hold texts the remake uses. */
const TABLES = [ const TABLES = [
{ rel: 'excel/activity_table.json', pick: (j) => ({ act2: j?.activity?.AUTOCHESS_SEASON?.act2autochess, act1: j?.activity?.AUTOCHESS_SEASON?.act1autochess, ac: j?.autoChessData, info: { act2autochess: j?.basicInfo?.act2autochess, act1autochess: j?.basicInfo?.act1autochess } }) }, { rel: 'excel/activity_table.json', pick: (j) => ({ act2: j?.activity?.AUTOCHESS_SEASON?.act2autochess, act1: j?.activity?.AUTOCHESS_SEASON?.act1autochess, ac: j?.autoChessData, info: { act2autochess: j?.basicInfo?.act2autochess, act1autochess: j?.basicInfo?.act1autochess } }) },
@@ -151,12 +187,11 @@ const hasPlaceholder = (s) => typeof s === 'string' && /\{-?[A-Za-z_@][^{}:]*(?:
function parseArgs(argv) { function parseArgs(argv) {
const opts = { const opts = {
refresh: false, offline: false, quiet: false, check: false, source: 'assets', refresh: false, offline: false, quiet: false, check: false, source: 'assets', lang: 'en',
cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'), cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'), out: null, report: null,
out: join(ROOT, 'data', 'i18n', 'en.json'), report: join(ROOT, '.cache', 'build-i18n-report.json'),
}; };
const flags = { '--refresh': 'refresh', '--offline': 'offline', '--quiet': 'quiet', '--check': 'check' }; const flags = { '--refresh': 'refresh', '--offline': 'offline', '--quiet': 'quiet', '--check': 'check' };
const vals = { '--source': 'source', '--cache': 'cache', '--cache-en': 'cacheEn', '--data': 'data', '--out': 'out', '--report': 'report' }; const vals = { '--source': 'source', '--lang': 'lang', '--dict': 'dict', '--cache': 'cache', '--cache-en': 'cacheEn', '--data': 'data', '--out': 'out', '--report': 'report' };
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const eq = argv[i].indexOf('='); const eq = argv[i].indexOf('=');
const [name, inline] = eq > 0 ? [argv[i].slice(0, eq), argv[i].slice(eq + 1)] : [argv[i], null]; const [name, inline] = eq > 0 ? [argv[i].slice(0, eq), argv[i].slice(eq + 1)] : [argv[i], null];
@@ -165,14 +200,19 @@ function parseArgs(argv) {
if (vals[name]) { if (vals[name]) {
const v = inline ?? argv[++i]; const v = inline ?? argv[++i];
if (!v || v.startsWith('--')) throw new Error(`${name} needs a value\n${USAGE}`); if (!v || v.startsWith('--')) throw new Error(`${name} needs a value\n${USAGE}`);
opts[vals[name]] = name === '--source' ? v : resolve(v); opts[vals[name]] = name === '--source' || name === '--lang' ? v : resolve(v);
continue; continue;
} }
throw new Error(`unknown option ${argv[i]}\n${USAGE}`); throw new Error(`unknown option ${argv[i]}\n${USAGE}`);
} }
if (opts.refresh && opts.offline) throw new Error(`--refresh and --offline are mutually exclusive\n${USAGE}`); if (opts.refresh && opts.offline) throw new Error(`--refresh and --offline are mutually exclusive\n${USAGE}`);
if (!EN_SOURCES[opts.source]) throw new Error(`--source must be one of ${Object.keys(EN_SOURCES).join(', ')}`); const lang = LANG_SOURCES[opts.lang];
if (!opts.cacheEn) opts.cacheEn = join(ROOT, EN_SOURCES[opts.source].cache); if (!/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(opts.lang) || opts.lang === 'zh') throw new Error(`--lang ${opts.lang}: a language code (en, ja, zh-TW …; not zh, the source)`);
if (!lang && !opts.dict) throw new Error(`--lang ${opts.lang} has no official client here (${Object.keys(LANG_SOURCES).join(', ')}): give its game texts with --dict <file.json> (docs/I18N.md §2)`);
if (lang && !lang.sources[opts.source]) throw new Error(`--source for ${opts.lang} must be one of ${Object.keys(lang.sources).join(', ')}`);
if (!opts.cacheEn && lang) opts.cacheEn = join(ROOT, lang.sources[opts.source].cache);
if (!opts.out) opts.out = join(ROOT, 'data', 'i18n', `${opts.lang}.json`);
if (!opts.report) opts.report = join(ROOT, '.cache', opts.lang === 'en' ? 'build-i18n-report.json' : `build-i18n-report.${opts.lang}.json`);
return opts; return opts;
} }
@@ -234,7 +274,9 @@ const isDict = (o) => {
* index (shape key → [{ zh, en, scope }]) for texts with placeholders. * index (shape key → [{ zh, en, scope }]) for texts with placeholders.
*/ */
class PairIndex { class PairIndex {
constructor() { /** @param {(zh: string, target: string) => boolean} [untranslated] a target text that is no translation (skipped) */
constructor(untranslated = (zh, t) => hasCjk(t)) {
this.untranslated = untranslated;
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */ /** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
this.exact = new Map(); this.exact = new Map();
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */ /** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
@@ -256,7 +298,7 @@ class PairIndex {
add(zhRaw, enRaw, scope) { add(zhRaw, enRaw, scope) {
const zh = unescapeNewlines(zhRaw); const zh = unescapeNewlines(zhRaw);
const en = unescapeNewlines(enRaw); const en = unescapeNewlines(enRaw);
if (!en.trim() || hasCjk(en)) return; if (!en.trim() || this.untranslated(zh, en)) return;
this.pairs++; this.pairs++;
PairIndex.add(this.exact, zh, en, scope); PairIndex.add(this.exact, zh, en, scope);
// build-data trims some texts (a tip ends with '\\n' in the table, not in data/config.json) // build-data trims some texts (a tip ends with '\\n' in the table, not in data/config.json)
@@ -394,9 +436,10 @@ class Translator {
* @param {PairIndex} index * @param {PairIndex} index
* @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference * @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference
*/ */
constructor(index, fallbacks) { constructor(index, fallbacks, { composites = true } = {}) {
this.index = index; this.index = index;
this.fallbacks = fallbacks; this.fallbacks = fallbacks;
this.composites = composites;
} }
/** /**
@@ -414,7 +457,7 @@ class Translator {
} }
const tpl = this.template(zh, ctx); const tpl = this.template(zh, ctx);
if (tpl) return tpl; if (tpl) return tpl;
const comp = this.composite(zh, ctx); const comp = this.composites ? this.composite(zh, ctx) : null;
if (comp) return comp; if (comp) return comp;
for (const { name, map } of this.fallbacks) { for (const { name, map } of this.fallbacks) {
const fb = map.get(zh); const fb = map.get(zh);
@@ -555,7 +598,7 @@ function translateScalar(file, id, text, tr, stats, samples) {
class Stats { class Stats {
constructor() { this.kinds = {}; this.missing = new Map(); } constructor() { this.kinds = {}; this.missing = new Map(); }
row(kind) { return this.kinds[kind] || (this.kinds[kind] = { texts: 0, exact: 0, template: 0, composite: 0, remake: 0, pr70: 0, ui: 0, missing: 0, notes: 0 }); } row(kind) { return this.kinds[kind] || (this.kinds[kind] = { texts: 0, exact: 0, template: 0, composite: 0, remake: 0, pr70: 0, ui: 0, dict: 0, missing: 0, notes: 0 }); }
hit(kind, how) { const r = this.row(kind); r.texts++; r[how]++; } hit(kind, how) { const r = this.row(kind); r.texts++; r[how]++; }
miss(kind, text) { miss(kind, text) {
const r = this.row(kind); const r = this.row(kind);
@@ -570,21 +613,23 @@ class Stats {
// ===== main ========================================================================================================== // ===== main ==========================================================================================================
const dictMap = (d) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !hasCjk(v))); const dictMap = (d, untranslated = (zh, t) => hasCjk(t)) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !untranslated(k, v)));
/** /**
* Build the overlay. Exported for tests (they pass in-memory tables). * Build the overlay. Exported for tests (they pass in-memory tables).
* @param {{ zh: object[], en: object[], data: Record<string, any>, fallback?: { remake?: object, pr70?: object, ui?: object }, source?: object }} input * @param {{ zh: object[], en: object[], data: Record<string, any>, fallback?: { remake?: object, pr70?: object, ui?: object }, source?: object }} input
* zh / en: the picked sub-trees of TABLES, in order * zh / en: the picked sub-trees of TABLES, in order
*/ */
export function buildOverlay({ zh, en, data, fallback = {}, source = null }) { export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang = 'en' }) {
const index = new PairIndex(); const untranslated = untranslatedTest(lang);
const index = new PairIndex(untranslated);
for (let i = 0; i < zh.length; i++) index.walk(zh[i], en[i], []); for (let i = 0; i < zh.length; i++) index.walk(zh[i], en[i], []);
const tr = new Translator(index, [ const tr = new Translator(index, [
{ name: 'remake', map: dictMap(fallback.remake) }, ...(fallback.dict ? [{ name: 'dict', map: dictMap(fallback.dict, untranslated) }] : []),
{ name: 'pr70', map: dictMap(fallback.pr70) }, { name: 'remake', map: dictMap(fallback.remake, untranslated) },
{ name: 'ui', map: dictMap(fallback.ui) }, { name: 'pr70', map: dictMap(fallback.pr70, untranslated) },
]); { name: 'ui', map: dictMap(fallback.ui, untranslated) },
], { composites: LANG_SOURCES[lang]?.composite ?? false });
const stats = new Stats(); const stats = new Stats();
const samples = {}; const samples = {};
const files = {}; const files = {};
@@ -602,7 +647,7 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
// record-level names (and the stand-ins' operator names): what server messages name // record-level names (and the stand-ins' operator names): what server messages name
const top = l.path.length === 1 const top = l.path.length === 1
|| (file === 'backups' && ((id === 'units' && l.path.length === 2) || (id === 'diy' && l.path[0] === 'operators' && l.path.length === 3))); || (file === 'backups' && ((id === 'units' && l.path.length === 2) || (id === 'diy' && l.path[0] === 'operators' && l.path.length === 3)));
if (!top || !NAME_KEYS.has(l.key) || hasCjk(l.en)) continue; if (!top || !NAME_KEYS.has(l.key) || untranslated(l.zh, l.en)) continue;
const prio = NAME_PRIORITY.indexOf(file); const prio = NAME_PRIORITY.indexOf(file);
const list = nameVotes.get(l.zh) || []; const list = nameVotes.get(l.zh) || [];
list.push({ en: l.en, prio: prio < 0 ? 99 : prio }); list.push({ en: l.en, prio: prio < 0 ? 99 : prio });
@@ -622,17 +667,19 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
const coverage = {}; const coverage = {};
let total = 0, translated = 0, official = 0; let total = 0, translated = 0, official = 0;
for (const [kind, r] of Object.entries(stats.kinds).sort()) { for (const [kind, r] of Object.entries(stats.kinds).sort()) {
const ok = r.exact + r.template + r.composite + r.remake + r.pr70 + r.ui; const ok = r.exact + r.template + r.composite + r.remake + r.pr70 + r.ui + r.dict;
coverage[kind] = { ...r, translated: ok, pct: r.texts ? Math.round((ok / r.texts) * 1000) / 10 : 100 }; coverage[kind] = { ...r, translated: ok, pct: r.texts ? Math.round((ok / r.texts) * 1000) / 10 : 100 };
total += r.texts; translated += ok; official += r.exact + r.template + r.composite; total += r.texts; translated += ok; official += r.exact + r.template + r.composite;
} }
const overlay = { const overlay = {
version: OVERLAY_VERSION, version: OVERLAY_VERSION,
lang: 'en', lang,
meta: { meta: {
generator: 'tools/build-i18n.mjs', generator: 'tools/build-i18n.mjs',
source: source || null, source: source || null,
fallback: 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json', fallback: lang === 'en'
? 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json'
: `public/i18n/${lang}.json (the pack's UI strings)`,
coverage: Object.fromEntries(Object.entries(coverage).map(([k, v]) => [k, { texts: v.texts, translated: v.translated, official: v.exact + v.template + v.composite, pct: v.pct }])), coverage: Object.fromEntries(Object.entries(coverage).map(([k, v]) => [k, { texts: v.texts, translated: v.translated, official: v.exact + v.template + v.composite, pct: v.pct }])),
totals: { texts: total, translated, official, pct: total ? Math.round((translated / total) * 1000) / 10 : 100 }, totals: { texts: total, translated, official, pct: total ? Math.round((translated / total) * 1000) / 10 : 100 },
}, },
@@ -648,11 +695,13 @@ async function main() {
let opts; let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(`build-i18n: ${e.message}`); process.exit(2); } try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(`build-i18n: ${e.message}`); process.exit(2); }
const log = (...a) => { if (!opts.quiet) console.log(...a); }; const log = (...a) => { if (!opts.quiet) console.log(...a); };
const src = EN_SOURCES[opts.source]; const client = LANG_SOURCES[opts.lang];
log(`build-i18n: EN tables from ${src.label}`); // (the dictionary by its file name only: a path outside the repository must not end up in the output)
const src = client ? client.sources[opts.source] : { label: `the dictionary ${basename(opts.dict)}`, home: null, url: null };
log(`build-i18n: ${opts.lang} tables from ${src.label}`);
const zh = []; const zh = [];
const en = []; const en = [];
for (const t of TABLES) { for (const t of client ? TABLES : []) {
const zj = await loadTable(opts.cache, ZH_URL, t.rel, { refresh: false, offline: opts.offline, log }); const zj = await loadTable(opts.cache, ZH_URL, t.rel, { refresh: false, offline: opts.offline, log });
const ej = await loadTable(opts.cacheEn, src.url, t.rel, { refresh: opts.refresh, offline: opts.offline, log }); const ej = await loadTable(opts.cacheEn, src.url, t.rel, { refresh: opts.refresh, offline: opts.offline, log });
zh.push(t.pick ? t.pick(zj) : zj); zh.push(t.pick ? t.pick(zj) : zj);
@@ -660,26 +709,28 @@ async function main() {
} }
const seasonEn = en[0]?.act2 ? 'act2autochess' : en[0]?.act1 ? 'act1autochess' : 'none (no 盟约 season in this EN build)'; const seasonEn = en[0]?.act2 ? 'act2autochess' : en[0]?.act1 ? 'act1autochess' : 'none (no 盟约 season in this EN build)';
let version = null; let version = null;
const versionFile = join(opts.cacheEn, 'excel', 'data_version.txt'); const versionFile = client ? join(opts.cacheEn, 'excel', 'data_version.txt') : '';
if (!opts.offline && (opts.refresh || !existsSync(versionFile))) { if (client && !opts.offline && (opts.refresh || !existsSync(versionFile))) {
try { try {
const res = await fetch(`${src.url}excel/data_version.txt`, { signal: AbortSignal.timeout(30_000) }); const res = await fetch(`${src.url}excel/data_version.txt`, { signal: AbortSignal.timeout(30_000) });
if (res.ok) { await mkdir(dirname(versionFile), { recursive: true }); await writeFile(versionFile, await res.text()); } if (res.ok) { await mkdir(dirname(versionFile), { recursive: true }); await writeFile(versionFile, await res.text()); }
} catch { /* optional */ } } catch { /* optional */ }
} }
try { version = (await readFile(versionFile, 'utf8')).trim(); } catch { /* optional */ } try { version = versionFile ? (await readFile(versionFile, 'utf8')).trim() : null; } catch { /* optional */ }
const data = {}; const data = {};
for (const f of DATA_FILES) { for (const f of DATA_FILES) {
const abs = join(opts.data, `${f}.json`); const abs = join(opts.data, `${f}.json`);
if (existsSync(abs)) data[f] = JSON.parse(await readFile(abs, 'utf8')); if (existsSync(abs)) data[f] = JSON.parse(await readFile(abs, 'utf8'));
} }
const readDict = async (abs) => (existsSync(abs) ? JSON.parse(await readFile(abs, 'utf8')) : {}); const readDict = async (abs) => (existsSync(abs) ? JSON.parse(await readFile(abs, 'utf8')) : {});
const fallback = { // the remake's own strings and PR #70's are English; another language falls back to its pack's UI strings only
const fallback = opts.lang === 'en' ? {
remake: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-remake.json')), remake: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-remake.json')),
pr70: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-pr70.json')), pr70: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-pr70.json')),
ui: await readDict(join(ROOT, 'public', 'i18n', 'en.json')), ui: await readDict(join(ROOT, 'public', 'i18n', 'en.json')),
}; } : { ui: await readDict(join(ROOT, 'public', 'i18n', `${opts.lang}.json`)) };
const { overlay, report } = buildOverlay({ zh, en, data, fallback, source: { id: opts.source, label: src.label, home: src.home, season: seasonEn } }); if (opts.dict) fallback.dict = JSON.parse(await readFile(opts.dict, 'utf8'));
const { overlay, report } = buildOverlay({ zh, en, data, fallback, lang: opts.lang, source: client ? { id: opts.source, label: src.label, home: src.home, season: seasonEn } : { id: 'dict', label: src.label } });
// self-check: applying the overlay keeps every record's shape (strings replace strings only) // self-check: applying the overlay keeps every record's shape (strings replace strings only)
let applied = 0; let applied = 0;
@@ -692,13 +743,13 @@ async function main() {
const rep = { ...report, source: overlay.meta.source, enDataVersion: version, applied, bytes: Buffer.byteLength(text) }; const rep = { ...report, source: overlay.meta.source, enDataVersion: version, applied, bytes: Buffer.byteLength(text) };
await mkdir(dirname(opts.report), { recursive: true }); await mkdir(dirname(opts.report), { recursive: true });
await writeFile(opts.report, `${JSON.stringify(rep, null, 1)}\n`); await writeFile(opts.report, `${JSON.stringify(rep, null, 1)}\n`);
log(` season in the EN build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`); if (client) log(` season in the ${opts.lang} build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`);
log(` ${report.pairs} zh→en source pairs; coverage per kind (translated / texts):`); log(` ${report.pairs} zh→${opts.lang} source pairs; coverage per kind (translated / texts):`);
for (const [kind, c] of Object.entries(report.coverage)) { for (const [kind, c] of Object.entries(report.coverage)) {
const fb = [c.remake && `remake ${c.remake}`, c.pr70 && `PR #70 ${c.pr70}`, c.ui && `UI ${c.ui}`].filter(Boolean).join(' · '); const fb = [c.remake && `remake ${c.remake}`, c.pr70 && `PR #70 ${c.pr70}`, c.ui && `UI ${c.ui}`, c.dict && `dictionary ${c.dict}`].filter(Boolean).join(' · ');
log(` ${kind.padEnd(10)} ${String(c.translated).padStart(5)} / ${String(c.texts).padEnd(5)} ${String(c.pct).padStart(5)} % official ${c.exact + c.template + c.composite} (exact ${c.exact} · template ${c.template} · composite ${c.composite})${fb ? ` · ${fb}` : ''}${c.notes ? ` · notes skipped ${c.notes}` : ''}`); log(` ${kind.padEnd(10)} ${String(c.translated).padStart(5)} / ${String(c.texts).padEnd(5)} ${String(c.pct).padStart(5)} % official ${c.exact + c.template + c.composite} (exact ${c.exact} · template ${c.template} · composite ${c.composite})${fb ? ` · ${fb}` : ''}${c.notes ? ` · notes skipped ${c.notes}` : ''}`);
} }
log(` total ${report.totals.translated} / ${report.totals.texts} (${report.totals.pct} %), official ${report.totals.official}; ${Object.keys(overlay.names).length} names (${report.nameConflicts.length} with several English forms)`); log(` total ${report.totals.translated} / ${report.totals.texts} (${report.totals.pct} %), official ${report.totals.official}; ${Object.keys(overlay.names).length} names (${report.nameConflicts.length} with several ${opts.lang} forms)`);
if (opts.check) { if (opts.check) {
const cur = existsSync(opts.out) ? await readFile(opts.out, 'utf8') : ''; const cur = existsSync(opts.out) ? await readFile(opts.out, 'utf8') : '';
if (cur !== text) { console.error(`build-i18n --check: ${relative(ROOT, opts.out)} is out of date (run node tools/build-i18n.mjs)`); process.exit(1); } if (cur !== text) { console.error(`build-i18n --check: ${relative(ROOT, opts.out)} is out of date (run node tools/build-i18n.mjs)`); process.exit(1); }
+221 -30
View File
@@ -5,9 +5,19 @@
// per file: the msgids passed to t() / tc() / tParts() / N_() / msg(), and the Chinese literals still shown untranslated // per file: the msgids passed to t() / tc() / tParts() / N_() / msg(), and the Chinese literals still shown untranslated
// (string literals, template literals, html`` text and attribute values outside those calls; comments, // (string literals, template literals, html`` text and attribute values outside those calls; comments,
// console.* and Error messages are ignored). --list prints each literal with its line. // console.* and Error messages are ignored). --list prints each literal with its line.
// node tools/i18n.mjs check [paths…] [--lang en] [--strict] [--stale] // node tools/i18n.mjs check [<code>… | --all] [paths…] [--strict] [--stale] [--list]
// msgids the code uses that public/i18n/<lang>.json lacks (exit 1 with --strict when any); --stale also lists // per language pack (default en; --all: every pack the registry finds, server/packs.js): coverage of the msgids
// entries no scanned code uses (not an error: phase 2 wraps the rest of the UI later). // the code uses, and the errors — a translation that drops a placeholder of its msgid, uses one no call site
// passes, or has a broken plural form (shared/i18n.js checkTranslation), a value that is not a string, a manifest
// problem. Missing strings are listed for a pack that declares `complete` (English) or with --list; --stale
// lists entries no code uses and no complete pack has (obsolete). --strict exits 1 on an error, or when a
// complete pack misses a msgid. A partial pack is fine: what it lacks falls back (docs/I18N.md).
// node tools/i18n.mjs template <code> [--fill <code>]
// write a pack skeleton public/i18n/<code>.json: a `_meta` manifest to fill in and every msgid with an empty
// value (--fill: the values of another pack, e.g. --fill en). An existing pack keeps its translations and
// `_meta`; only the msgids it lacks are added (run it again after an update of the game).
// node tools/i18n.mjs index → node tools/packs.mjs index (the pack index for a static host)
// check / template take --root <dir>: the packs of another checkout (the msgids are always this checkout's code).
// node tools/i18n.mjs codemod <files…> [--write] // node tools/i18n.mjs codemod <files…> [--write]
// wrap Chinese literals: html`` text runs / attribute values → ${t('…')}, plain strings → t('…'), template // wrap Chinese literals: html`` text runs / attribute values → ${t('…')}, plain strings → t('…'), template
// literals → t('…{name}…', { name }); adds the import. Module-level literals (evaluated once, before a language // literals → t('…{name}…', { name }); adds the import. Module-level literals (evaluated once, before a language
@@ -24,9 +34,13 @@
// msgid (the client translates the text it receives), as is the first argument of msg(). Message ids: the Chinese text itself; a template literal's expressions become named params (paramName()). // msgid (the client translates the text it receives), as is the first argument of msg(). Message ids: the Chinese text itself; a template literal's expressions become named params (paramName()).
// Needs the dev dependencies (acorn, which eslint brings). // Needs the dev dependencies (acorn, which eslint brings).
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { checkTranslation } from '../shared/i18n.js';
import { canonicalLang, isLangCode, languageName, SOURCE_LANG } from '../shared/i18nPacks.js';
import { APP_VERSION } from '../shared/constants.js';
import { scanPacks, LANG_DIR } from '../server/packs.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const CJK = /[\u3400-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff60]/; const CJK = /[\u3400-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff60]/;
@@ -319,11 +333,34 @@ function htmlSegments(tpl) {
// ===== scanning a file =============================================================================================== // ===== scanning a file ===============================================================================================
/**
* The param names a call passes (its params argument): an object literal's keys, an array literal's indexes, none
* without the argument; null when they cannot be read (a variable, a spread, a computed key) — `check` then trusts the
* translation's extra placeholders.
* @param {any} node
* @returns {string[] | null}
*/
export function paramNames(node) {
if (!node) return [];
if (node.type === 'ObjectExpression') {
const out = [];
for (const p of node.properties) {
if (p.type !== 'Property' || p.computed) return null;
if (p.key.type === 'Identifier') out.push(p.key.name);
else if (p.key.type === 'Literal') out.push(String(p.key.value));
else return null;
}
return out;
}
if (node.type === 'ArrayExpression') return node.elements.some((e) => !e || e.type === 'SpreadElement') ? null : node.elements.map((_, i) => String(i));
return null;
}
/** /**
* Scan a source file. * Scan a source file.
* @param {string} src * @param {string} src
* @param {string} file label * @param {string} file label
* @returns {Promise<{ msgids: { msgid: string, line: number }[], literals: any[] }>} * @returns {Promise<{ msgids: { msgid: string, line: number, via: string, params: string[] | null }[], literals: any[] }>}
* literals: { kind: 'str'|'tpl'|'text'|'attr', start, end, line, msgid, params, module: boolean, reason: string|null } * literals: { kind: 'str'|'tpl'|'text'|'attr', start, end, line, msgid, params, module: boolean, reason: string|null }
*/ */
export async function scanSource(src, file = '<src>') { export async function scanSource(src, file = '<src>') {
@@ -350,14 +387,16 @@ export async function scanSource(src, file = '<src>') {
const fileIgnored = /\/\/[^\n]*i18n-ignore-file/.test(src); const fileIgnored = /\/\/[^\n]*i18n-ignore-file/.test(src);
walk(ast, (node, ancestors) => { walk(ast, (node, ancestors) => {
if (node.type === 'CallExpression' && fnName(node.callee) === 'tc') { if (node.type === 'CallExpression' && fnName(node.callee) === 'tc') {
const [c, a] = node.arguments; const [c, a, p] = node.arguments;
if (c?.type === 'Literal' && a?.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: `${c.value}::${a.value}`, line: lineOf(a.start), via: 'tc' }); if (c?.type === 'Literal' && a?.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: `${c.value}::${a.value}`, line: lineOf(a.start), via: 'tc', params: paramNames(p) });
} }
if (node.type === 'CallExpression' && MSGID_CALLS.has(fnName(node.callee))) { if (node.type === 'CallExpression' && MSGID_CALLS.has(fnName(node.callee))) {
const a = node.arguments[0]; const a = node.arguments[0];
const via = fnName(node.callee); const via = fnName(node.callee);
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via }); // N_() only marks a msgid: its params come where it is shown, unknown here
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via }); const params = via === 'N_' ? null : paramNames(node.arguments[1]);
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via, params });
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via, params });
} }
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') { if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
for (const seg of htmlSegments(node.quasi)) { for (const seg of htmlSegments(node.quasi)) {
@@ -377,7 +416,7 @@ export async function scanSource(src, file = '<src>') {
while (k >= 0 && ((ancestors[k].type === 'ConditionalExpression' && ancestors[k].test !== arg) || ancestors[k].type === 'LogicalExpression')) { arg = ancestors[k]; k--; } while (k >= 0 && ((ancestors[k].type === 'ConditionalExpression' && ancestors[k].test !== arg) || ancestors[k].type === 'LogicalExpression')) { arg = ancestors[k]; k--; }
const call = ancestors[k]; const call = ancestors[k];
if (call && call.type === 'CallExpression' && call.callee.type === 'MemberExpression' && SERVER_TEXT_CALLS.has(calleeName(call)) && call.arguments.includes(arg)) { if (call && call.type === 'CallExpression' && call.callee.type === 'MemberExpression' && SERVER_TEXT_CALLS.has(calleeName(call)) && call.arguments.includes(arg)) {
msgids.push({ msgid: node.value, line: lineOf(node.start), via: 'server' }); msgids.push({ msgid: node.value, line: lineOf(node.start), via: 'server', params: [] });
return true; return true;
} }
const line = lineOf(node.start); const line = lineOf(node.start);
@@ -527,26 +566,39 @@ function parseFlags(argv) {
if (a.startsWith('--')) { if (a.startsWith('--')) {
const [k, v] = a.slice(2).split('='); const [k, v] = a.slice(2).split('=');
if (v !== undefined) flags[k] = v; if (v !== undefined) flags[k] = v;
else if (['from', 'lang'].includes(k)) flags[k] = argv[++i]; else if (['from', 'lang', 'fill', 'root'].includes(k)) flags[k] = argv[++i];
else flags[k] = true; else flags[k] = true;
} else flags._.push(a); } else flags._.push(a);
} }
return flags; return flags;
} }
const catalogPath = (lang) => path.join(ROOT, 'public', 'i18n', `${lang}.json`); const catalogPath = (lang, root = ROOT) => path.join(root, 'public', 'i18n', `${lang}.json`);
function readCatalog(lang) { function readCatalog(lang) {
const p = catalogPath(lang); const p = catalogPath(lang);
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {}; return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
} }
function writeCatalog(lang, cat) { function writeCatalog(lang, cat, root = ROOT) {
const meta = Object.entries(cat).filter(([k]) => k.startsWith('_')); const meta = Object.entries(cat).filter(([k]) => k.startsWith('_'));
const rest = Object.entries(cat).filter(([k]) => !k.startsWith('_')); const rest = Object.entries(cat).filter(([k]) => !k.startsWith('_'));
writeFileSync(catalogPath(lang), `${JSON.stringify(Object.fromEntries([...meta, ...rest]), null, 2)}\n`); mkdirSync(path.dirname(catalogPath(lang, root)), { recursive: true });
writeFileSync(catalogPath(lang, root), `${JSON.stringify(Object.fromEntries([...meta, ...rest]), null, 2)}\n`);
} }
/**
* The msgids the code uses: msgid → { where: first "file:line", params: the param names its call sites pass, null when a
* call site's params cannot be read (a variable, N_(), a msgid table) }.
* @param {string[]} files
* @returns {Promise<Map<string, { where: string, params: Set<string> | null }>>}
*/
async function usedMsgids(files) { async function usedMsgids(files) {
const used = new Map(); // msgid → first "file:line" const used = new Map();
const note = (msgid, where, params) => {
const u = used.get(msgid);
if (!u) { used.set(msgid, { where, params: params ? new Set(params) : null }); return; }
if (!params) u.params = null;
else if (u.params) for (const k of params) u.params.add(k);
};
for (const f of files) { for (const f of files) {
const { msgids } = await scanSource(readFileSync(f, 'utf8'), rel(f)); const { msgids } = await scanSource(readFileSync(f, 'utf8'), rel(f));
// server code never translates: its t() / N_() are other helpers (blackboard lookups in the sim); only msg() and // server code never translates: its t() / N_() are other helpers (blackboard lookups in the sim); only msg() and
@@ -554,13 +606,13 @@ async function usedMsgids(files) {
const server = rel(f).startsWith('server/'); const server = rel(f).startsWith('server/');
for (const m of msgids) { for (const m of msgids) {
if (server && m.via !== 'msg' && m.via !== 'server') continue; if (server && m.via !== 'msg' && m.via !== 'server') continue;
if (!used.has(m.msgid)) used.set(m.msgid, `${rel(f)}:${m.line}`); note(m.msgid, `${rel(f)}:${m.line}`, m.params);
} }
} }
for (const [file, names] of MSGID_TABLES) { for (const [file, names] of MSGID_TABLES) {
const abs = path.join(ROOT, file); const abs = path.join(ROOT, file);
if (!existsSync(abs)) continue; if (!existsSync(abs)) continue;
for (const m of await tableMsgids(readFileSync(abs, 'utf8'), names)) if (!used.has(m)) used.set(m, file); for (const m of await tableMsgids(readFileSync(abs, 'utf8'), names)) note(m, file, null);
} }
return used; return used;
} }
@@ -588,18 +640,156 @@ async function cmdExtract(flags) {
+ ` (${ready} of them already translated in public/i18n/${flags.lang || 'en'}.json, ready for the codemod)`); + ` (${ready} of them already translated in public/i18n/${flags.lang || 'en'}.json, ready for the codemod)`);
} }
async function cmdCheck(flags) { // ===== language packs: check, template ===============================================================================
const lang = flags.lang || 'en';
const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS, { sim: true }); /** The language packs of a checkout (server/packs.js scanPacks): lang → { pack, json }, plus what was skipped. */
const used = await usedMsgids(files); function langPacks(root = ROOT) {
const cat = readCatalog(lang); const scan = scanPacks({ publicDir: path.join(root, 'public'), dataDir: path.join(root, 'data'), packsDir: path.join(root, 'packs') });
const missing = [...used].filter(([m]) => !(typeof cat[m] === 'string' && cat[m])); const byLang = new Map();
for (const [m, where] of missing) console.log(`missing ${where.padEnd(40)} ${m.replace(/\n/g, '⏎')}`); for (const p of scan.packs) {
if (flags.stale) { if (p.type !== 'lang') continue;
for (const k of Object.keys(cat)) if (!k.startsWith('_') && !used.has(k)) console.log(`unused ${k.replace(/\n/g, '⏎')}`); byLang.set(p.manifest.lang, { pack: p, json: JSON.parse(readFileSync(p.files.ui.abs, 'utf8')) });
} }
console.log(`${used.size} msgids used, ${used.size - missing.length} translated in public/i18n/${lang}.json, ${missing.length} missing`); return { byLang, skipped: scan.skipped, warnings: scan.warnings };
if (flags.strict && missing.length) process.exitCode = 1; }
/**
* Check one language pack against the msgids the code uses (see the header).
* @param {Record<string, unknown>} json the pack's UI strings
* @param {Map<string, { where: string, params: Set<string> | null }>} used
* @param {Set<string>} known msgids no pack is obsolete for (used ∪ the keys of the complete packs)
* @returns {{ total: number, translated: number, missing: [string, string][], errors: string[], obsolete: string[] }}
*/
export function checkPack(json, used, known) {
const missing = [];
const errors = [];
const obsolete = [];
let translated = 0;
for (const [k, v] of Object.entries(json || {})) {
if (k.startsWith('_')) continue;
if (typeof v !== 'string') errors.push(`not a string: ${k}`);
else if (!used.has(k) && !known.has(k)) obsolete.push(k);
}
for (const [msgid, u] of used) {
const v = json?.[msgid];
if (typeof v !== 'string' || !v) { missing.push([msgid, u.where]); continue; }
const sep = msgid.indexOf('::');
const c = checkTranslation(sep >= 0 ? msgid.slice(sep + 2) : msgid, v);
const stray = u.params ? c.extras.filter((x) => !u.params.has(x)) : [];
if (!c.ok || stray.length) {
errors.push(`${[...c.problems, ...stray.map((x) => `uses {${x}}, which no call site passes`)].join('; ')}: ${msgid.replace(/\n/g, '⏎')} → ${v.replace(/\n/g, '⏎')} (${u.where})`);
continue;
}
translated++;
}
return { total: used.size, translated, missing, errors, obsolete };
}
async function cmdCheck(flags) {
const paths = flags._.filter((a) => !isLangArg(a));
const files = listFiles(paths.length ? paths : DEFAULT_ROOTS, { sim: true });
const used = await usedMsgids(files);
const packRoot = flags.root ? path.resolve(flags.root) : ROOT;
const { byLang, skipped, warnings } = langPacks(packRoot);
let codes = flags._.filter(isLangArg).map((a) => canonicalLang(a));
if (flags.lang) codes.push(canonicalLang(flags.lang));
if (flags.all) codes = [...byLang.keys()];
if (!codes.length) codes = ['en'];
const known = new Set(used.keys());
for (const { pack, json } of byLang.values()) if (pack.manifest.complete) for (const k of Object.keys(json)) if (!k.startsWith('_')) known.add(k);
let failed = false;
for (const s of skipped) {
if (!flags.all && !codes.some((c) => s.where.includes(`/${c}`))) continue;
console.log(`error ${s.where}: not loaded — ${s.problems.join('; ')}`);
failed = true;
}
for (const code of codes) {
const entry = byLang.get(code);
if (!entry) {
if (!skipped.some((s) => s.where.includes(`/${code}`))) console.log(`error no language pack "${code}" (public/${LANG_DIR}/${code}.json or packs/<id>/ with "lang": "${code}"; node tools/i18n.mjs template ${code} makes one)`);
failed = true;
continue;
}
const { pack, json } = entry;
const where = pack.layout === 'file' ? `public/${LANG_DIR}/${code}.json` : `${pack.where}${pack.files.ui.rel}`;
for (const w of warnings) if (w.where === pack.where) console.log(`warning ${w.where}: ${w.warning}`);
const r = checkPack(json, used, known);
const complete = pack.manifest.complete;
for (const e of r.errors) console.log(`error ${where}: ${e}`);
if (complete || flags.list) for (const [m, w] of r.missing) console.log(`missing ${w.padEnd(40)} ${m.replace(/\n/g, '⏎')}`);
if (flags.stale) for (const k of r.obsolete) console.log(`unused ${k.replace(/\n/g, '⏎')}`);
const pct = r.total ? Math.floor((r.translated / r.total) * 1000) / 10 : 100;
console.log(`${where} (${pack.manifest.name}, ${code}${complete ? ', complete' : ''}): ${r.total} msgids used, ${r.translated} translated (${pct} %), ${r.missing.length} missing`
+ `, ${r.errors.length} errors${r.obsolete.length ? `, ${r.obsolete.length} unused` : ''}${pack.manifest.compatible ? '' : `; made for app ${pack.manifest.app}`}`);
if (r.errors.length || (complete && r.missing.length)) failed = true;
}
if (flags.strict && failed) process.exitCode = 1;
}
/** A positional argument of `check` that names a language (a code that is not a path of the checkout). */
const isLangArg = (a) => !!canonicalLang(a) && !existsSync(path.resolve(ROOT, a));
/** The `app` range a new pack targets: this release and later ('>=0.2.0' on 0.2.0-dev). */
const appRange = () => `>=${(/^\d+\.\d+\.\d+/.exec(APP_VERSION) || ['0.0.0'])[0]}`;
/**
* A pack skeleton (or an existing pack with the msgids it lacks added, empty): the `_meta` manifest first, then every
* msgid in the order of the complete packs (English), then the code's msgids they lack.
* @param {string} code
* @param {{ existing?: Record<string, unknown> | null, msgids: string[], fill?: Record<string, unknown> | null }} opts
* @returns {{ json: Record<string, unknown>, added: number, kept: number }}
*/
export function packTemplate(code, { existing = null, msgids, fill = null }) {
const meta = existing && typeof existing._meta === 'object' && existing._meta ? existing._meta : {
type: 'lang',
lang: code,
name: languageName(code),
englishName: languageName(code, 'en'),
version: '0.1.0',
app: appRange(),
authors: [],
// untranslated strings show English first; a Chinese variant (zh-TW …) falls back to the Chinese msgid instead
fallback: code.startsWith(`${SOURCE_LANG}-`) ? [] : ['en'],
};
/** @type {Record<string, unknown>} */
const json = { _meta: meta };
for (const [k, v] of Object.entries(existing || {})) if (k.startsWith('_') && k !== '_meta') json[k] = v;
let added = 0;
let kept = 0;
for (const m of msgids) {
const have = existing?.[m];
if (typeof have === 'string') { json[m] = have; kept++; continue; }
const f = fill?.[m];
json[m] = typeof f === 'string' ? f : '';
added++;
}
// translations of msgids the code no longer uses stay (check --stale lists them)
for (const [k, v] of Object.entries(existing || {})) if (!k.startsWith('_') && !Object.prototype.hasOwnProperty.call(json, k)) json[k] = v;
return { json, added, kept };
}
async function cmdTemplate(flags) {
const code = canonicalLang(flags._[0]);
if (!code || !isLangCode(code)) throw new Error('template needs a language code (ja, ko, zh-TW, pt-BR …)');
if (code === SOURCE_LANG) throw new Error('"zh" is the source language (the msgids), it needs no pack');
const used = await usedMsgids(listFiles(DEFAULT_ROOTS, { sim: true }));
const packRoot = flags.root ? path.resolve(flags.root) : ROOT;
const { byLang } = langPacks(packRoot);
const order = [];
const seen = new Set();
for (const { pack, json } of byLang.values()) {
if (!pack.manifest.complete) continue;
for (const k of Object.keys(json)) if (!k.startsWith('_') && !seen.has(k)) { seen.add(k); order.push(k); }
}
for (const k of used.keys()) if (!seen.has(k)) { seen.add(k); order.push(k); }
const target = byLang.get(code);
if (target && target.pack.layout !== 'file') throw new Error(`${code} is a folder pack (${target.pack.where}); edit ${target.pack.files.ui.rel} there`);
const fillCode = flags.fill ? canonicalLang(flags.fill) : null;
if (flags.fill && !byLang.has(fillCode)) throw new Error(`--fill ${flags.fill}: no such language pack`);
const { json, added, kept } = packTemplate(code, { existing: target ? target.json : null, msgids: order, fill: fillCode ? byLang.get(fillCode).json : null });
writeCatalog(code, json, packRoot);
console.log(`${target ? 'updated' : 'wrote'} public/${LANG_DIR}/${code}.json: ${order.length} msgids — ${kept} translations kept, ${added} added${fillCode ? ` (values from ${fillCode})` : ' (empty)'}`);
if (!target) console.log(`next: fill in "_meta" (name, authors), translate the values, then node tools/i18n.mjs check ${code}`);
} }
async function cmdCodemod(flags) { async function cmdCodemod(flags) {
@@ -640,9 +830,10 @@ async function cmdSeed(flags) {
async function main() { async function main() {
const [cmd, ...rest] = process.argv.slice(2); const [cmd, ...rest] = process.argv.slice(2);
const flags = parseFlags(rest); const flags = parseFlags(rest);
const cmds = { extract: cmdExtract, check: cmdCheck, codemod: cmdCodemod, seed: cmdSeed }; const cmdIndex = async () => { const { main: packs } = await import('./packs.mjs'); process.exitCode = await packs(['index', ...rest]); };
const cmds = { extract: cmdExtract, check: cmdCheck, codemod: cmdCodemod, seed: cmdSeed, template: cmdTemplate, index: cmdIndex };
if (!cmds[cmd]) { if (!cmds[cmd]) {
console.log('usage: node tools/i18n.mjs extract|check|codemod|seed … (see the header of tools/i18n.mjs)'); console.log('usage: node tools/i18n.mjs extract|check|template|codemod|seed|index … (see the header of tools/i18n.mjs)');
process.exitCode = cmd ? 2 : 0; process.exitCode = cmd ? 2 : 0;
return; return;
} }
+23 -6
View File
@@ -9,6 +9,8 @@
// //
// Both zips hold one folder, Stronghold-Protocol/, with only what a player runs — an allowlist over `git ls-files`, so // Both zips hold one folder, Stronghold-Protocol/, with only what a player runs — an allowlist over `git ls-files`, so
// untracked work, caches, logs and per-machine config never get in: server/, shared/, data/, public/ (not public/dev/), // untracked work, caches, logs and per-machine config never get in: server/, shared/, data/, public/ (not public/dev/),
// packs/ (the content packs that ship with the repository, docs/PACKS.md; a pack installed on this machine and not
// committed stays out),
// the start scripts, the tools a player runs (setup, vendor = the postinstall, fetch-assets + tools/assets, doctor, and // the start scripts, the tools a player runs (setup, vendor = the postinstall, fetch-assets + tools/assets, doctor, and
// what setup starts: tools/local-extract, crop-board-atlas), the research tables the Node server (server/sim/ // what setup starts: tools/local-extract, crop-board-atlas), the research tables the Node server (server/sim/
// nodeData.js fallback) and fetch-assets read, package.json / package-lock.json, LICENSE / NOTICE.md / // nodeData.js fallback) and fetch-assets read, package.json / package-lock.json, LICENSE / NOTICE.md /
@@ -26,7 +28,9 @@
// data/assets.json and data/local-assets.json list; no shipped file carries a home-directory path (/Users/…, // data/assets.json and data/local-assets.json list; no shipped file carries a home-directory path (/Users/…,
// C:\Users\…, /home/…) or this machine's account name (text and binary, art included; node_modules only for the name); // C:\Users\…, /home/…) or this machine's account name (text and binary, art included; node_modules only for the name);
// no shipped tracked file has an uncommitted change (--allow-dirty skips that). A build also checks that the stage holds // no shipped tracked file has an uncommitted change (--allow-dirty skips that). A build also checks that the stage holds
// exactly the plan before zipping. // exactly the plan before zipping. Generated into the stage: packs/index.json, the pack index of the shipped packs
// (tools/packs.mjs writePackIndex — what the server's GET /packs/index.json answers, for a static host), when any pack
// ships (a language file of public/i18n/, a packs/<id>/pack.json).
// The account name comes from the OS at run time (never written in the repository): SP_PACKAGE_SCAN_USER=0 skips it // The account name comes from the OS at run time (never written in the repository): SP_PACKAGE_SCAN_USER=0 skips it
// (a name that is a common word), SP_PACKAGE_SCAN_NAMES=a,b adds more names to refuse. // (a name that is a common word), SP_PACKAGE_SCAN_NAMES=a,b adds more names to refuse.
// //
@@ -39,6 +43,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { findSpecifiers } from './check-imports.mjs'; import { findSpecifiers } from './check-imports.mjs';
import { writePackIndex } from './packs.mjs';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/** The one folder inside both zips (DEPLOY §1.1: "解压后把里面的 Stronghold-Protocol 文件夹放到…"). */ /** The one folder inside both zips (DEPLOY §1.1: "解压后把里面的 Stronghold-Protocol 文件夹放到…"). */
@@ -58,7 +63,11 @@ export const PLAYER_TOOLS = ['tools/crop-board-atlas.mjs', 'tools/doctor.mjs', '
/** Whole tool directories: fetch-assets' modules, the local-client extraction setup runs. */ /** Whole tool directories: fetch-assets' modules, the local-client extraction setup runs. */
export const PLAYER_TOOL_DIRS = ['tools/assets/', 'tools/local-extract/']; export const PLAYER_TOOL_DIRS = ['tools/assets/', 'tools/local-extract/'];
/** Whole runtime directories (their tracked files). */ /** Whole runtime directories (their tracked files). */
export const RUNTIME_DIRS = ['server/', 'shared/', 'data/', 'public/']; export const RUNTIME_DIRS = ['server/', 'shared/', 'data/', 'public/', 'packs/'];
/** Written into the stage, never taken from the checkout: the pack index of the shipped packs. */
export const GENERATED_PACK_INDEX = 'packs/index.json';
/** Whether shipped files include a content pack (then the stage gets GENERATED_PACK_INDEX). @param {string[]} files */
export const shipsPacks = (files) => files.some((f) => /^public\/i18n\/[^/]+\.json$/.test(f) || /^packs\/[^/]+\/pack\.json$/.test(f));
/** Never from the tracked list: the dev pages, and what only the art plan adds (or npm ci writes). */ /** Never from the tracked list: the dev pages, and what only the art plan adds (or npm ci writes). */
const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'public/vendor/']; const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'public/vendor/'];
/** The npm scripts a player runs: each `node <file>` of them must ship. */ /** The npm scripts a player runs: each `node <file>` of them must ship. */
@@ -102,7 +111,7 @@ export function isJunk(rel) {
/** Whether a tracked file belongs in the player package. */ /** Whether a tracked file belongs in the player package. */
export function isPlayerFile(rel) { export function isPlayerFile(rel) {
const p = posixRel(rel); const p = posixRel(rel);
if (!p || isRefused(p) || isJunk(p) || p === 'data/local-assets.json') return false; if (!p || isRefused(p) || isJunk(p) || p === 'data/local-assets.json' || p === GENERATED_PACK_INDEX) return false;
if (NOT_TRACKED_SHIP.some((d) => p.startsWith(d))) return false; if (NOT_TRACKED_SHIP.some((d) => p.startsWith(d))) return false;
if (RUNTIME_DIRS.some((d) => p.startsWith(d)) || PLAYER_TOOL_DIRS.some((d) => p.startsWith(d))) return true; if (RUNTIME_DIRS.some((d) => p.startsWith(d)) || PLAYER_TOOL_DIRS.some((d) => p.startsWith(d))) return true;
return ROOT_FILES.includes(p) || PLAYER_DOCS.includes(p) || RUNTIME_RESEARCH.includes(p) || PLAYER_SCRIPTS.includes(p) || PLAYER_TOOLS.includes(p); return ROOT_FILES.includes(p) || PLAYER_DOCS.includes(p) || RUNTIME_RESEARCH.includes(p) || PLAYER_SCRIPTS.includes(p) || PLAYER_TOOLS.includes(p);
@@ -322,7 +331,8 @@ export function plan(root, opts = {}) {
const art = artPlan(root, { lite }); const art = artPlan(root, { lite });
const pkg = readJson(path.join(root, 'package.json')) || {}; const pkg = readJson(path.join(root, 'package.json')) || {};
const lock = readJson(path.join(root, 'package-lock.json')) || {}; const lock = readJson(path.join(root, 'package-lock.json')) || {};
const files = [...keep, ...art.files]; const generated = shipsPacks(keep) ? [GENERATED_PACK_INDEX] : [];
const files = [...keep, ...art.files, ...generated];
const problems = []; const problems = [];
for (const f of files) if (isRefused(f) || isJunk(f)) problems.push(`refused: ${f}`); for (const f of files) if (isRefused(f) || isJunk(f)) problems.push(`refused: ${f}`);
const byCase = new Map(); const byCase = new Map();
@@ -359,7 +369,7 @@ export function plan(root, opts = {}) {
const vendorBytes = opts.measure !== false ? sumBytes(root, listTree(root, 'public/vendor')) : 0; const vendorBytes = opts.measure !== false ? sumBytes(root, listTree(root, 'public/vendor')) : 0;
const dropBytes = Object.values(dropped).reduce((n, g) => n + g.bytes, 0); const dropBytes = Object.values(dropped).reduce((n, g) => n + g.bytes, 0);
return { return {
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, files, dropped, orphans: art.orphans, version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans,
localArt: art.local, problems, names: names.length, scanned, localArt: art.local, problems, names: names.length, scanned,
bytes: { bytes: {
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes, tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
@@ -380,6 +390,7 @@ export function formatSummary(p) {
if (!p.lite) { if (!p.lite) {
lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`); lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`);
} }
if (p.generated?.length) lines.push(`generated: ${p.generated.join(', ')} (the pack index of the shipped packs, for a static host)`);
lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`); lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`);
lines.push(`uncompressed: ${MB(p.bytes.total)}; a 0.1.x whole-tree copy would add ${MB(p.bytes.leftOut)}`); lines.push(`uncompressed: ${MB(p.bytes.total)}; a 0.1.x whole-tree copy would add ${MB(p.bytes.leftOut)}`);
const groups = Object.entries(p.dropped).sort((a, b) => b[1].bytes - a[1].bytes); const groups = Object.entries(p.dropped).sort((a, b) => b[1].bytes - a[1].bytes);
@@ -421,14 +432,20 @@ export function build(root, p, { out, install = true, force = false, keepStage =
if (!force) throw new Error(`${t} already exists (--force replaces it)`); if (!force) throw new Error(`${t} already exists (--force replaces it)`);
fs.rmSync(t, { recursive: true, force: true }); fs.rmSync(t, { recursive: true, force: true });
} }
log(`copying ${p.files.length} files → ${stage}`); const generated = new Set(p.generated || []);
log(`copying ${p.files.length - generated.size} files → ${stage}`);
for (const rel of p.files) { for (const rel of p.files) {
if (generated.has(rel)) continue;
const src = path.join(root, rel); const src = path.join(root, rel);
const to = path.join(stage, rel); const to = path.join(stage, rel);
fs.mkdirSync(path.dirname(to), { recursive: true }); fs.mkdirSync(path.dirname(to), { recursive: true });
fs.copyFileSync(src, to, fs.constants.COPYFILE_FICLONE); fs.copyFileSync(src, to, fs.constants.COPYFILE_FICLONE);
fs.chmodSync(to, fs.statSync(src).mode & 0o777); fs.chmodSync(to, fs.statSync(src).mode & 0o777);
} }
if (generated.has(GENERATED_PACK_INDEX)) {
const index = writePackIndex(stage, path.join(stage, GENERATED_PACK_INDEX));
log(`${GENERATED_PACK_INDEX}: ${index.packs.map((x) => `${x.type} ${x.id}`).join(', ') || 'no pack'}`);
}
if (install) { if (install) {
log('npm ci --omit=dev …'); log('npm ci --omit=dev …');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// tools/packs.mjs — the content packs of a checkout (docs/PACKS.md; server/packs.js is the registry the server runs,
// shared/packs.js the format):
//
// node tools/packs.mjs list [--json] the packs by type (folder packs in packs/, the language packs of
// public/i18n/), what was skipped and why, the warnings
// node tools/packs.mjs index [--out <file>] write the pack index — what GET /packs/index.json answers — to
// packs/index.json (git-ignored), for a static host that cannot run the
// server's live list; tools/package.mjs writes it into the release zips
// node tools/packs.mjs check [--strict] list, and exit 1 with --strict when a pack is skipped or warned about
// --root <dir> another checkout or an unpacked release (default: this checkout)
//
// The strings of a language pack are checked by node tools/i18n.mjs check <code> (docs/I18N.md).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { scanPacks, packIndexOf } from '../server/packs.js';
import { PACK_TYPES, PACK_INDEX_FILE } from '../shared/packs.js';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const USAGE = 'usage: node tools/packs.mjs list [--json] | index [--out <file>] | check [--strict] [--root <dir>]';
/** The pack folders of a checkout. @param {string} root */
export const packDirs = (root) => ({ publicDir: path.join(root, 'public'), dataDir: path.join(root, 'data'), packsDir: path.join(root, 'packs') });
/**
* Write the pack index of a checkout (default <root>/packs/index.json); returns its body.
* @param {string} root
* @param {string} [out]
*/
export function writePackIndex(root, out = path.join(root, 'packs', PACK_INDEX_FILE)) {
const { packs } = scanPacks(packDirs(root));
const body = packIndexOf(packs);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${JSON.stringify(body, null, 2)}\n`);
return body;
}
/**
* The CLI (exported for tools/i18n.mjs index).
* @param {string[]} argv
* @returns {Promise<number>} the exit code
*/
export async function main(argv) {
const [cmd, ...rest] = argv;
let root = REPO;
let out = null;
const flags = new Set();
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a === '--root' || a === '--out') {
const v = rest[++i];
if (!v || v.startsWith('--')) { console.error(`packs: ${a} needs a path\n${USAGE}`); return 2; }
if (a === '--root') root = path.resolve(v); else out = path.resolve(v);
} else if (a.startsWith('--')) flags.add(a.slice(2));
else { console.error(`packs: unexpected ${a}\n${USAGE}`); return 2; }
}
if (cmd === 'index') {
const file = out || path.join(root, 'packs', PACK_INDEX_FILE);
const body = writePackIndex(root, file);
console.log(`wrote ${path.relative(process.cwd(), file) || file}: ${body.packs.length} pack(s) — ${body.packs.map((p) => `${p.type} ${p.id}`).join(', ') || 'none'}`);
return 0;
}
if (cmd !== 'list' && cmd !== 'check') { console.log(USAGE); return cmd ? 2 : 0; }
const scan = scanPacks(packDirs(root));
if (flags.has('json')) { console.log(JSON.stringify({ index: packIndexOf(scan.packs), skipped: scan.skipped, warnings: scan.warnings }, null, 2)); return 0; }
for (const type of Object.keys(PACK_TYPES)) {
const list = scan.packs.filter((p) => p.type === type);
console.log(`${type} (${PACK_TYPES[type].status}: ${PACK_TYPES[type].summary}): ${list.length ? '' : 'none'}`);
for (const p of list) {
const m = p.manifest;
const lang = type === 'lang' ? ` ${m.lang}${m.fallback?.length ? ` → ${m.fallback.join(' → ')}` : ''}` : '';
const extra = [m.version && `v${m.version}`, m.app && `app ${m.app}${m.compatible ? '' : ' (not this version)'}`, p.strings !== undefined && `${p.strings} strings`, p.files.data && 'game texts'].filter(Boolean).join(', ');
console.log(` ${p.id.padEnd(16)} ${m.name}${m.englishName !== m.name ? ` (${m.englishName})` : ''}${lang} — ${p.where}${extra ? `; ${extra}` : ''}`);
}
}
for (const k of scan.skipped) console.log(`skipped ${k.where}: ${k.problems.join('; ')}`);
for (const w of scan.warnings) console.log(`warning ${w.where}: ${w.warning}`);
return cmd === 'check' && flags.has('strict') && (scan.skipped.length || scan.warnings.length) ? 1 : 0;
}
const invoked = (() => { try { return pathToFileURL(fs.realpathSync(process.argv[1] || '')).href; } catch { return null; } })();
if (invoked === import.meta.url) main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(`packs: ${e.message}`); process.exitCode = 2; });