feedback5 WAD: docs for language packs and content packs — docs/PACKS.md (the manifest, the two layouts, discovery and the index, what is served, the types and how a later type plugs in), docs/I18N.md §5 "Adding a language" (step by step for a translator) with the fallback chain, placeholder validation and plural categories, CONTRIBUTING / README pointers, DEPLOY §7 (packs/ and packs/index.json in the zips), ARCHITECTURE, docs/history/0.2.0.md §25.16; build-i18n --dict (a community dictionary for a language without an official client); the browser check of a pack file and a pack folder dropped in while the server runs (test/ui/i18n.e2e.test.js); golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 02:39:11 +08:00
1 parent 25b2a87b26
commit 1ee39a79b5
11 files changed
+466 -64

No files matched your search

+6 -1
View File
@@ -72,6 +72,9 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里
`test/docs-consistency.test.js` 固定了文档里的一些句子,改文档后跑一下。
- 代码和注释用英文;玩家文档(README、PLAYING、DEPLOY)用简体中文,技术文档用英文。界面上的文字写中文并用 `t('…')`
包起来,英文放进 `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 发布。本项目坚持非商业,请不要加入广告、付费、打赏等任何变现功能。
## 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
sources, the `[ASSUMED]` items, the tests and the golden scenarios that moved; update the docs a rule change makes
wrong. Code and comments in English; player-facing text in Chinese through `t('…')` with the English in
`public/i18n/en.json`. Code is GPL-3.0-or-later; no monetisation features.
`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
[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
+1 -1
View File
@@ -185,7 +185,7 @@ npm start # 启动服务器:http://localhost:3000
| [docs/META.md](docs/META.md) | 对局与经济引擎(英文):回合流程、商店、联防、最终攻势的实现细节 |
| [docs/DATA.md](docs/DATA.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/research/](docs/research/00-INDEX.md) | 官方规则、数据与界面的调研记录 |
+12 -8
View File
@@ -77,6 +77,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `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/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` |
@@ -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/bonds/` | the 23 bonds: `core.js` the 8 core bonds (both sides), `server/sim/content/bonds/addon/` the 15 add-on bonds (`battle.js`, `meta.js`) |
| `server/sim/content/` (the rest) | `tokens.js` (summons), `devices.js` (terrain and stage devices), `generic.js` (the kit built from a skill's data when a chess has none), `choices.js` (机变 cards in battle) |
| `shared/` | imported by the server and the browser: `protocol.js`, `constants.js`, `i18n.js` and `i18nData.js`, `standIn.js` (补位), `diy.js` (自选编队), `highGround.js`, `loadoutRecord.js` |
| `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/`)
@@ -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/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
| `public/js/render/` | the field view: `app.js` with `public/js/render/app/`, `units.js` and `spine.js` (models), `tiles.js`, `projection.js`, `interp.js`, `pick.js`, `drag.js`, `public/js/render/fx/` (effects; `kinds.js` maps the fx kinds), `public/js/render/board3d/` (the official 3D board) |
| `public/css/`, `public/i18n/en.json` | the styles; the English UI strings |
| `public/css/`, `public/i18n/<code>.json` | the styles; the UI strings of each language pack (English ships) |
### Data, tools, tests
| path | what |
|---|---|
| `data/*.json` | generated by `tools/build-data.mjs` and committed — never edited by hand (DATA.md); `data/backups.json` holds the 补位 and 自选 data, `data/i18n/en.json` the English game texts, `data/assets.json` the art manifest |
| `tools/` | `build-data.mjs`, `build-i18n.mjs`, `i18n.mjs` (UI strings), `setup.mjs` / `fetch-assets.mjs` / `tools/assets/` (art and audio), `tools/local-extract/` (art from a local game client), `vendor.mjs`, `golden.mjs`, `check-imports.mjs`, `package.mjs` (the release zip), `doctor.mjs`; sweeps: `matchrun.mjs`, `simrun.mjs`, `botbench.mjs`, `balance.mjs` |
| `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 |
| `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) |
| `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 |
@@ -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.
- **In the browser**, `public/js/data.js` fetches the same files once per page for the UI, and the battle runner loads
its own frozen copies for the sim (`loadBrowserSim` → `setSimData`), so both sides simulate from the same data.
- **Languages.** Chinese is the source language. The English game texts come from the official EN client
(`tools/build-i18n.mjs` → `data/i18n/en.json`, applied by `public/js/data.js`); UI strings are wrapped in `t('…')`
and translated in `public/i18n/en.json` (`tools/i18n.mjs` lists what is missing); server messages travel as message
ids. I18N.md.
- **Languages.** Chinese is the source language; every other language is a pack — `public/i18n/<code>.json` (UI
strings) and optionally `data/i18n/<code>.json` (game texts), listed by the server at `/packs/index.json`
(`server/packs.js`; PACKS.md). The English game texts come from the official EN client (`tools/build-i18n.mjs` →
`data/i18n/en.json`, applied by `public/js/data.js`); UI strings are wrapped in `t('…')` and translated in 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`,
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.
@@ -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/` |
| 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 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` |
Before changing a rule, read the DESIGN section that owns it (module headers cite their sections; the table in
+1 -1
View File
@@ -300,7 +300,7 @@ npm run package -- --out <目录> # 完整包 Stronghold-Protocol-v<版
npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版本>-lite.zip
```
- **打进去的**:`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。
- **打包前的检查**(`--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` 保留打包用的目录供检查。
+107 -23
View File
@@ -1,17 +1,22 @@
# I18N.md — languages (中文 / English)
# I18N.md — languages (中文, English, language packs)
Chinese is the default; English is a switch (the owner's decision of 2026-10-05): an English browser still opens in
Chinese until the player picks English on the title screen (top right) or in 设置 / Settings. The choice is kept in
`localStorage` (`sp.pref.lang`); `?lang=en` / `?lang=zh` in the address bar sets it once and is then removed. A switch
re-renders the page in place (no reload).
Chinese is the default; every other language is a switch (the owner's decision of 2026-10-05): an English browser still
opens in Chinese until the player picks a language on the title screen (top right) or in 设置 / Settings. Every
language but Chinese is a **language pack** — a file in the language folder, nothing else (the owner's decision of
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:
| 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`) |
| 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 |
| Server messages (m.toast, m.ticker, error codes) | the same `public/i18n/en.json` | the server sends msgid + params, the client translates |
| 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/<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/<code>.json` | the server sends msgid + params, the client translates |
## 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
```
- **Placeholders** are the same in the msgid and the translation: `{name}`, positional `{0}`, and one plural form for
English, `{n|one|other}` (`'{n} {n|round|rounds}'`). A value that is an array renders as a list (`、` / `, `). A
missing value leaves the placeholder as it is (official templates such as `{0}博士…` pass through untouched).
- **Placeholders** are the same in the msgid and the translation: `{name}`, positional `{0}`, and plural forms. Two
plain forms `{n|one|other}` (`'{n} {n|round|rounds}'`) pick `one` when the language's plural category of the number
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
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
@@ -46,10 +67,13 @@ html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">$
### Translating
Edit `public/i18n/en.json` (`"msgid": "English"`, two-space JSON; `_about` is metadata). Keep every `{placeholder}`;
`test/i18n.test.js` checks that, that no Chinese is left in a value, and that every msgid the code uses is present. A
translation may use more params than its msgid (`'{n} {count|unit|units}'` with `count` passed beside a vnode `n`). Use
the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts):
Edit the pack, e.g. `public/i18n/en.json` (`"msgid": "English"`, two-space JSON; `_meta` is its manifest, `_about` a
note; keys starting with `_` are never strings). Keep every `{placeholder}` (above); `test/i18n.test.js` checks that,
that no Chinese is left in an English value, and that every msgid the code uses is in English — English declares
`"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 |
|---|---|---|---|
@@ -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
literals still shown untranslated; the total says how many of those already have a translation (ready for the
codemod).
- `check [paths] [--strict] [--stale]` — msgids the code uses that `public/i18n/en.json` lacks (`--strict` exits 1;
`--stale` lists entries no code uses yet).
- `check [<code>… | --all] [paths] [--strict] [--stale] [--list]` — per language pack (default `en`): how many of the
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 →
`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
@@ -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
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 --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 --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
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`:
[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 自选
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`.
- **Client**: `data.setLocale(lang)` downloads the overlay once (~1.5 MB, ~190 KB gzipped) and from then on `get`,
`lookup` and `list` (and every getter built on them) return localized copies; `data.getRaw(name)` keeps the Chinese
file; `data.localeName(zh)` translates a name.
- **Other official clients** (`--lang`): ArknightsAssets/ArknightsGamedata also mirrors the JP, KR and TW clients
(`jp/`, `kr/`, `tw/gamedata`), each with both 盟约 seasons (checked 2026-10-07): `--lang ja`, `ko` and `zh-TW` build the
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
@@ -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,
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
The English UI strings were seeded from GitHub PR #70 (branch `en-translation`) by **@YuriRestia**, reused with credit
+164
View File
@@ -0,0 +1,164 @@
# PACKS.md — content packs
A **content pack** is add-on content a server picks up from a folder: a manifest plus its files. The owner's decision of
2026-10-07 made the language pack the first type of one general mechanism, so that later resource packs (art, audio,
fonts) and server-side data packs reuse the same manifest, registry and index instead of each inventing its own. In
0.2.0 one type loads, **`lang`** (a language: docs/I18N.md "Adding a language"); `assets` and `data` are planned.
| File | Role |
|---|---|
| `shared/packs.js` | the format: the manifest, the pack types (`PACK_TYPES`), app version ranges, the index (browser, server and tools) |
| `shared/i18nPacks.js` | the `lang` type: language codes, the language fields, the fallback chain, the script family |
| `server/packs.js` | the registry: discovery, validation, the index, which pack files may be served |
| `server/http/static.js` | `GET /packs/index.json` (the live index) and `GET /packs/<id>/<file>` |
| `public/js/ui/lang.js` | the client of the `lang` entries: the language menu and the loader |
| `tools/packs.mjs` | `list`, `index` (the index file for a static host), `check` |
| `tools/package.mjs` | ships the committed packs and writes `packs/index.json` into the release zips |
## 1. Layouts
Two layouts, read by the same registry and listed alike:
```
packs/<id>/pack.json + its files a folder pack — any type; the manifest names its files
public/i18n/<code>.json a single-file language pack — its manifest is the file's `_meta` block
data/i18n/<code>.json (+ its game texts, when present)
```
The single file is the quickest way for a translator (one file, the format English has always had, and how English
ships); the folder is the layout every pack type shares — a pack distributed as one folder with its own readme and
licence, and the only layout a resource or data pack will have. A language can come either way; the menu does not tell
them apart.
The id of a folder pack is its folder name; the id of a single-file language pack is its language code. Ids are
letters, digits, `.`, `_` and `-` (at most 64), unique across types and without regard to case (Windows and macOS
would see one folder).
## 2. The manifest
`packs/<id>/pack.json`, or the `_meta` block of `public/i18n/<code>.json` (the same fields):
```json
{
"type": "lang",
"lang": "ja",
"name": "日本語",
"englishName": "Japanese",
"version": "1.0.0",
"app": ">=0.2.0",
"authors": ["…"],
"credits": "…",
"license": "CC-BY-4.0",
"fallback": ["en"],
"files": { "ui": "ui.json", "data": "data.json" }
}
```
| Field | Meaning |
|---|---|
| `id` | optional: the folder or file name decides; a manifest that names another id gets a warning |
| `type` | `lang` (a single-file language pack may leave it out); planned: `assets`, `data` |
| `version` | the pack's own version |
| `app` | the app versions it is made for, a range: `>=0.2.0`, `>=0.2.0 <0.3.0`, `0.2.x`, `^0.2.0`, `~0.2.1`, `*`, alternatives with `||`. A pre-release (0.2.0-dev) counts as its release. Outside the range a language pack still loads — what it lacks falls back — and is flagged `compatible: false` (the server log, `tools/packs.mjs list`); a later type decides what an incompatible pack means for it |
| `name`, `englishName` | the name in its own language (a language pack: the language's own name, shown in the menu) and in English (the menu's tooltip) |
| `authors`, `credits`, `license` | who made it; free text (`license`: an SPDX id or text) |
| `files` | a folder pack: role → path inside the folder. Each type lists its roles and their extensions (`lang`: `ui` required, `data` optional, both `.json`) |
| the type's fields | `lang`: `lang`, `base`, `fallback`, `complete`, `numberUnits` (docs/I18N.md) |
A **problem** keeps a pack from loading (unknown type, no `lang`, a file missing or outside the folder, broken JSON, an
id or a language already taken — the language folders' files come first, then the folders by id); a **warning** does
not (an id that differs, an app range this version is outside of, an unknown file role). Both are printed when the
server starts or notices a change, and by `node tools/packs.mjs list`.
## 3. Discovery and the index
The server's registry (`server/packs.js`) scans `public/i18n/`, `data/i18n/` and `packs/` when it starts and again
whenever something there changes: on a request for the index or a pack file it compares the names, sizes and times of
the files it reads (at most once a second, a few `stat` calls) and scans again if they differ. A pack dropped into a
folder shows on the next page load — no restart, no build step; a removed one leaves (a player whose stored language is
gone gets Chinese).
`GET /packs/index.json` (never cached) answers the packs of supported types:
```json
{ "version": 1, "app": "0.2.0", "packs": [
{ "id": "en", "type": "lang", "name": "English", "englishName": "English", "version": "0.2.0", "app": ">=0.2.0",
"compatible": true, "authors": ["…"], "lang": "en", "fallback": [], "complete": true, "strings": 1017,
"files": { "ui": "/i18n/en.json", "data": "/data/i18n/en.json" } } ] }
```
`files` holds URLs on this server, so a client never builds a path itself. A client reads the entries of its type
(`readPackIndex(json, 'lang')`: an entry of another type, with a bad id or a URL that is not a path on this server is
dropped).
**A static host** cannot list folders: `node tools/packs.mjs index` writes the same body to `packs/index.json`
(git-ignored), and `tools/package.mjs` writes it into the release zips. The live server answers `/packs/index.json`
itself and ignores that file. Serving the game from plain files needs the server's URL layout: `public/` at `/`,
`data/` at `/data/`, `shared/` at `/shared/`, `server/sim/` at `/sim/` and `packs/` at `/packs/` (the game itself still
needs the Node server for its rooms; DEPLOY.md).
**What is served.** `GET /packs/<id>/<file>` answers only a file that the manifest of a loaded folder pack names under a
role of its type, with that role's extension, inside the folder once links are resolved — `pack.json`, a readme, an
HTML or script file in the folder are never served; a pack of a planned type serves nothing. A single-file language
pack's files are the language folders' own (`/i18n/<code>.json`, `/data/i18n/<code>.json`).
## 4. The types
| Type | Status | Where it applies | Loader |
|---|---|---|---|
| `lang` | supported (0.2.0) | the client: UI strings, game texts | `public/js/ui/lang.js` (the menu, the strings: `shared/i18n.js`; the game texts: `public/js/data.js`) |
| `assets` | planned | the client: art, audio, font replacements | — |
| `data` | planned | the server: data patches applied at start | — |
A pack of a planned type is parsed and listed as not supported ("type "assets" is planned …, not loaded by this
version"); it is never served and never changes anything.
### Adding a pack type
1. **The type** — its entry in `PACK_TYPES` (`shared/packs.js`): `status`, `summary`, `live` (whether a running server
may take changes without a restart) and `files` (the roles a folder pack names, with the extensions that may be
served for each). Its own manifest fields get a normalizer beside `langFields` and a branch in `normalizeManifest`;
its index fields a branch in `packIndexEntry`.
2. **The loader** — reads the registry by type: `createPackRegistry(…).list('assets')` on the server, or the index
entries of the type in the browser (`readPackIndex(json, 'assets')`), the way `ui/lang.js` reads `lang`. A
server-side type (`data`) loads once at start (`live: false`): the simulation's code and data are fixed for the life
of the process, and a battle must run the same data on the server and in every browser.
3. **The checks** — what `tools/packs.mjs check` and the tests verify for the type (a data pack: the same validation
the data builder applies; a resource pack: the files exist and are of the allowed kinds), and its documentation
here.
Things later types must keep (the maintainers' study of mods, 2026-10-07):
- **Server-side only for gameplay.** A data pack is installed on the server, so every player of a room uses the same
content and the server can still check or recompute a battle. A player never installs gameplay content of their own.
- **A content hash in the handshake** before data packs ship (`welcome`, `/healthz`, the BattleSpec), so a client and
a server can tell they run the same content, and a "modded" mark in the UI and in bug reports.
- **No third-party code.** The manifest and its files are data (JSON, images, audio, fonts); a pack type that runs code
is a separate decision of the owner's.
- **The golden results stay on the original content**; a pack never changes them.
## 5. Tools
```
node tools/packs.mjs list [--json] the packs by type, what was skipped and why, the warnings
node tools/packs.mjs index [--out <file>] write packs/index.json (the static index)
node tools/packs.mjs check [--strict] list; --strict exits 1 when a pack is skipped or warned about
node tools/i18n.mjs template <code> a new language pack (docs/I18N.md)
node tools/i18n.mjs check <code> | --all a language pack's strings: coverage, placeholders
```
`--root <dir>` points `tools/packs.mjs` at another checkout or an unpacked release.
## 6. Shipping
`tools/package.mjs` ships the committed packs (`public/i18n/`, `data/i18n/`, `packs/` — tracked files only: a pack
installed on this machine and not committed stays out) and writes `packs/index.json` into the stage from the packs
that ship. `test/package.test.js` checks both layouts and that a local pack stays out.
## Tests
`test/i18n-packs.test.js` (discovery of both layouts, broken / planned / duplicate packs, a pack dropped in or removed
while the server runs, the file allowlist, the manifest and app ranges, the fallback chain, placeholders, plurals,
the per-text game-text fallback, the client loader and menu, the tools, build-i18n `--lang` / `--dict`),
`test/package.test.js`, `test/ui/i18n.e2e.test.js`
(`SP_E2E=1`: a pack file and a pack folder dropped in while the server runs show in the menu and switch).
+64 -2
View File
@@ -25,7 +25,8 @@ settled (泥岩's 闭锁; 忍冬, 深靛, 阿) → §25.11; release packaging an
with no Spine model) → §25.12; round 18 — 拟态物质 at the copy cap (GitHub #207), 华法琳's 7 / 14 (GitHub #175, PR #192),
a merged elite 拉普兰德 as a new one (GitHub #169), the leader pool per player alive (PR #209), the flying lift and two
model quirks (PR #211) → §25.13; the community reports of 2026-10-06 (the 孤岛风云 prisoners drawn freed, 忍冬's trigger, a
bounty burned to death by the 活性源石, 枯朽之种 paying a bounty) → §25.14.
bounty burned to death by the 活性源石, 枯朽之种 paying a bounty) → §25.14; 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);
2026-10-05 — the 补位 plan (§25.3); the 自选编队 rules, the collab operators out of the pool, ISW-A refused (§25.4);
@@ -38,7 +39,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
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
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`
@@ -1092,3 +1094,63 @@ fixed (W5AA's commits name every golden scenario moved); two were already fixed
`test/ui/gameLogic.test.js` (sanitizeSettings), `test/ui/hotkeys.e2e.test.js` (SP_E2E=1: 撤退 rebound to W with the
keyboard only in the mock match — Q then does nothing, W retreats, the choice survives a reload; phone width). Docs:
docs/PLAYING.md §11, README 操作, DESIGN §10 (design/client.md).
### 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.
+5 -4
View File
@@ -3,9 +3,10 @@
// 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.
// Chinese ('zh') is the default language; every other language is a pack (shared/i18nPacks.js): a file in
// public/i18n/ that the client lists from /i18n/index.json and loads on demand (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.
// public/i18n/ (or a pack folder, shared/packs.js) that the client lists from /packs/index.json and loads on demand
// (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
@@ -81,7 +82,7 @@ const notifyLangs = () => {
};
/**
* Register languages (the client: the entries of /i18n/index.json; a pack's own `_meta` when it loads). A known code
* 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
+21 -1
View File
@@ -21,7 +21,8 @@ import {
} 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 } from '../shared/i18nData.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';
@@ -389,3 +390,22 @@ test('tools: template writes a skeleton (or adds the missing msgids to a pack),
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');
});
+59 -3
View File
@@ -4,15 +4,25 @@
//
// The title screen opens in Chinese; 中文 | English → English in place (no reload): the title, the start button, the
// 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 /
// page / request errors. docs/I18N.md.
// game-data overlay (data/i18n/en.json) is applied; `?lang=zh` switches back and leaves the address bar. Language packs
// (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 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 { NAME_MAX_LEN } from '../../shared/constants.js';
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)' }, () => {
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}`;
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());
@@ -69,4 +79,50 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
assert.deepEqual(problems, []);
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();
}
});
});
+26 -20
View File
@@ -27,9 +27,10 @@
// --lang the target language (default en): one with an official client in LANG_SOURCES — en, ja (the JP
// client), ko (KR), zh-TW (TW); all three carry both 盟约 seasons (verified 2026-10-07, data 51.x). The
// output goes to data/i18n/<code>.json, the game texts of the language pack <code> (it needs the pack's
// UI file public/i18n/<code>.json to show in the menu). A language with no official client gets its game
// texts another way (docs/I18N.md): the pack's UI file fills the data texts equal to one of its msgids,
// the rest falls back per text (the pack's chain, then Chinese).
// 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
@@ -47,12 +48,12 @@
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
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 { buildRecordOverlay, applyFileOverlay, OVERLAY_VERSION } from '../shared/i18nData.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const USAGE = 'usage: node tools/build-i18n.mjs [--lang en|ja|ko|zh-TW] [--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). */
@@ -92,7 +93,7 @@ export const LANG_SOURCES = Object.freeze({
});
/** 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' ? (zh, t) => t === zh : (zh, t) => hasCjk(t));
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. */
const TABLES = [
@@ -190,7 +191,7 @@ function parseArgs(argv) {
cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'), out: null, report: null,
};
const flags = { '--refresh': 'refresh', '--offline': 'offline', '--quiet': 'quiet', '--check': 'check' };
const vals = { '--source': 'source', '--lang': 'lang', '--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++) {
const eq = argv[i].indexOf('=');
const [name, inline] = eq > 0 ? [argv[i].slice(0, eq), argv[i].slice(eq + 1)] : [argv[i], null];
@@ -206,9 +207,10 @@ function parseArgs(argv) {
}
if (opts.refresh && opts.offline) throw new Error(`--refresh and --offline are mutually exclusive\n${USAGE}`);
const lang = LANG_SOURCES[opts.lang];
if (!lang) throw new Error(`--lang must be one of ${Object.keys(LANG_SOURCES).join(', ')} (a language with an official client; docs/I18N.md says how a pack without one gets its game texts)`);
if (!lang.sources[opts.source]) throw new Error(`--source for ${opts.lang} must be one of ${Object.keys(lang.sources).join(', ')}`);
if (!opts.cacheEn) opts.cacheEn = join(ROOT, lang.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;
@@ -596,7 +598,7 @@ function translateScalar(file, id, text, tr, stats, samples) {
class Stats {
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]++; }
miss(kind, text) {
const r = this.row(kind);
@@ -623,6 +625,7 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang
const index = new PairIndex(untranslated);
for (let i = 0; i < zh.length; i++) index.walk(zh[i], en[i], []);
const tr = new Translator(index, [
...(fallback.dict ? [{ name: 'dict', map: dictMap(fallback.dict, untranslated) }] : []),
{ name: 'remake', map: dictMap(fallback.remake, untranslated) },
{ name: 'pr70', map: dictMap(fallback.pr70, untranslated) },
{ name: 'ui', map: dictMap(fallback.ui, untranslated) },
@@ -664,7 +667,7 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang
const coverage = {};
let total = 0, translated = 0, official = 0;
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 };
total += r.texts; translated += ok; official += r.exact + r.template + r.composite;
}
@@ -692,11 +695,13 @@ async function main() {
let opts;
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 src = LANG_SOURCES[opts.lang].sources[opts.source];
const client = LANG_SOURCES[opts.lang];
// (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 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 ej = await loadTable(opts.cacheEn, src.url, t.rel, { refresh: opts.refresh, offline: opts.offline, log });
zh.push(t.pick ? t.pick(zj) : zj);
@@ -704,14 +709,14 @@ async function main() {
}
const seasonEn = en[0]?.act2 ? 'act2autochess' : en[0]?.act1 ? 'act1autochess' : 'none (no 盟约 season in this EN build)';
let version = null;
const versionFile = join(opts.cacheEn, 'excel', 'data_version.txt');
if (!opts.offline && (opts.refresh || !existsSync(versionFile))) {
const versionFile = client ? join(opts.cacheEn, 'excel', 'data_version.txt') : '';
if (client && !opts.offline && (opts.refresh || !existsSync(versionFile))) {
try {
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()); }
} catch { /* optional */ }
}
try { version = (await readFile(versionFile, 'utf8')).trim(); } catch { /* optional */ }
try { version = versionFile ? (await readFile(versionFile, 'utf8')).trim() : null; } catch { /* optional */ }
const data = {};
for (const f of DATA_FILES) {
const abs = join(opts.data, `${f}.json`);
@@ -724,7 +729,8 @@ async function main() {
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', `${opts.lang}.json`)) };
const { overlay, report } = buildOverlay({ zh, en, data, fallback, lang: opts.lang, 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)
let applied = 0;
@@ -737,10 +743,10 @@ async function main() {
const rep = { ...report, source: overlay.meta.source, enDataVersion: version, applied, bytes: Buffer.byteLength(text) };
await mkdir(dirname(opts.report), { recursive: true });
await writeFile(opts.report, `${JSON.stringify(rep, null, 1)}\n`);
log(` season in the ${opts.lang} 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→${opts.lang} source pairs; coverage per kind (translated / texts):`);
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(` 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)`);