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:
1 parent
25b2a87b26
commit
1ee39a79b5
11 files changed
+466
-64
No files matched your search
+6
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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)`);
|
||||
|
||||
Reference in new issue
Block a user