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:
commit
66cdf493fc
30 files changed
+2670
-288
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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; });
|
||||||
Reference in new issue
Block a user