diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af59dbc4..fe73e468 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `, + translate, `node tools/i18n.mjs check ` (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-.js` keyed by the charId, registered in `OPERATOR_KIT_FILES`, a test `test/content/op_.test.js`, the fidelity checklist in the pull diff --git a/README.md b/README.md index 5a6e2df4..b30a23b6 100644 --- a/README.md +++ b/README.md @@ -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) | 官方规则、数据与界面的调研记录 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 425dda40..bb8cd47f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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/.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/.json` the game texts of a language pack (English), `data/assets.json` the art manifest | +| `packs/` | content packs installed as folders, `packs//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/.json` (UI + strings) and optionally `data/i18n/.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/.json` (`node tools/i18n.mjs template `), 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 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index a4fc61d3..9dec6b02 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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` 保留打包用的目录供检查。 diff --git a/docs/I18N.md b/docs/I18N.md index 2da6ecd0..d308c7e0 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -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=` 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/.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/.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/.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`${tParts('第 {r} 回合 · 最终攻势', { r: html`${r}` })}`; // 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 ``, a ``): `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`${tParts('第 {r} 回合 · 最终攻势', { r: html`$ ### 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 [… | --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 ` reads the packs of another checkout. +- `template [--fill ]` — writes `public/i18n/.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 [--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/.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/.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/.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 `{ "": "" }`; 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 --dict `: it writes + `data/i18n/.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 ` writes `public/i18n/.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 ` — 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 ` writes + `data/i18n/.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//pack.json` naming `ui.json` and `data.json`: docs/PACKS.md). After a game update, run `template ` + 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 diff --git a/docs/PACKS.md b/docs/PACKS.md new file mode 100644 index 00000000..f6d29462 --- /dev/null +++ b/docs/PACKS.md @@ -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//` | +| `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//pack.json + its files a folder pack — any type; the manifest names its files +public/i18n/.json a single-file language pack — its manifest is the file's `_meta` block +data/i18n/.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//pack.json`, or the `_meta` block of `public/i18n/.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//` 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/.json`, `/data/i18n/.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 ] 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 a new language pack (docs/I18N.md) +node tools/i18n.mjs check | --all a language pack's strings: coverage, placeholders +``` + +`--root ` 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). diff --git a/docs/history/0.2.0.md b/docs/history/0.2.0.md index c986e7b3..38d9c61f 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -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/.json` (manifest: its `_meta` block; optional game texts `data/i18n/.json`) — the + translator's way and how English ships — or a folder `packs//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//` (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=` 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 `` 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 ` (a skeleton: `_meta` and every msgid; on an existing pack only + the msgids it lacks), `check | --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. diff --git a/shared/i18n.js b/shared/i18n.js index db7beec4..e521abb9 100644 --- a/shared/i18n.js +++ b/shared/i18n.js @@ -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[]} entries each with a `code` * @returns {number} languages registered diff --git a/test/i18n-packs.test.js b/test/i18n-packs.test.js index 6bcd2db7..5b23715f 100644 --- a/test/i18n-packs.test.js +++ b/test/i18n-packs.test.js @@ -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'); +}); diff --git a/test/ui/i18n.e2e.test.js b/test/ui/i18n.e2e.test.js index f75e3d0f..2c41ed7e 100644 --- a/test/ui/i18n.e2e.test.js +++ b/test/ui/i18n.e2e.test.js @@ -4,15 +4,25 @@ // // The title screen opens in Chinese; 中文 | English → English in place (no reload): the title, the start button, the // connection line, , 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(); + } + }); }); diff --git a/tools/build-i18n.mjs b/tools/build-i18n.mjs index 777e540f..920a0a8c 100644 --- a/tools/build-i18n.mjs +++ b/tools/build-i18n.mjs @@ -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/.json, the game texts of the language pack (it needs the pack's -// UI file public/i18n/.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/.json to show in the menu). Any other code needs --dict. +// --dict a community dictionary { "": "" } (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 ] [--cache-en ] [--data ] [--out ] [--report ] [--check] [--quiet]'; +const USAGE = 'usage: node tools/build-i18n.mjs [--lang en|ja|ko|zh-TW | --lang --dict ] [--dict ] [--refresh | --offline] [--source assets|yostar] [--cache ] [--cache-en ] [--data ] [--out ] [--report ] [--check] [--quiet]'; const ASSETS_HOME = 'https://github.com/ArknightsAssets/ArknightsGamedata'; /** The official client tables of a language other than English (ArknightsAssets/ArknightsGamedata /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 (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)`);