diff --git a/.gitignore b/.gitignore index 6721a54d..767b4166 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ venv/ __pycache__/ *.py[cod] +# --- content packs (docs/PACKS.md): the pack index for a static host, generated by `node tools/packs.mjs index` +# (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) ----------- +packs/index.json + # --- tests & logs ---------------------------------------------------------------------------------- test/e2e/out/ logs/ 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 3ceb2b9e..b84ed00d 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -34,7 +34,8 @@ redeploy, 赫默's drone at full SP, the countdown summons' 无敌 / 禁疗 / ti DoT damage, 伺夜's 狼影 and the 吟游者 trait answered) → §25.16; the community reports of 2026-10-06, stream CB4 (enemies, bosses, waves, movement: the 突袭 landing on the 临时整备区 row and the other half of the 联防 map, a silenced 萨卡兹枯朽战士's death pollution, 萨卡兹穿刺手's speed after a stun and its first hit, 自制投石机 blocked on the 排气格栅; the briefing types' 7–9 waves, 新硎's 断刃, a freed prisoner in 联防 and 鸭爵's two swaps in one wave already answered; the moving leaders' routes -checked; 野鬃, 锏's pulls, 昆图斯's footprint and the leaders' attack rate not reproduced) → §25.18. +checked; 野鬃, 锏's pulls, 昆图斯's footprint and the leaders' attack rate not reproduced) → §25.18; the rebindable shortcuts +→ §25.15; language packs, the first type of content packs (a language is a file in the language folder) → §25.16. Decisions of the owner: 2026-10-04 — the server stays on plain `node:http` + `ws`, no web framework (§25.1); 2026-10-05 — the 补位 plan (§25.3); the 自选编队 rules, the collab operators out of the pool, ISW-A refused (§25.4); @@ -47,7 +48,8 @@ a melee 突袭 member lands where it can block (§25.11); 华法琳's granted tr lock (§25.13.2); a newly merged elite 拉普兰德 counts as a new 拉普兰德 (§25.13.3); the leader pool = bloodPoint × the players 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` @@ -1505,3 +1507,62 @@ briefing types' share of the waves). 卢西恩 attacks only its blocker and blinks past it. A leader and a situation from the reporter would show what differs. - Golden: unchanged. Pinned: `test/sim/feedback5-cb4-pins.test.js` (the four interval leaders at ≥ max − 1 attacks). +### 25.16 Language packs: a language is a file in the language folder, the first type of content packs (W5AD; the owner's decisions of 2026-10-07) — `shared/packs.js`, `shared/i18nPacks.js`, `shared/i18n.js`, `shared/i18nData.js`, `server/packs.js`, `server/http/static.js`, `public/js/ui/lang.js`, `public/js/data.js`, `tools/i18n.mjs`, `tools/packs.mjs`, `tools/build-i18n.mjs`, `tools/package.mjs`, docs/I18N.md §5, docs/PACKS.md + +- **Seen**: after §25.2 English was wired in by name — the language list `['zh', 'en']`, the switch's labels, English + checks in the title screen, `fmtNum`, the tab title, a CSS rule, the overlay URL pattern — so another language meant + editing code, and players asked to translate into their own languages. +- **Decision**: the owner's decision of 2026-10-07 — putting an adapted language file into the language folder is all + it takes for the game to offer that language, so the community can add languages themselves; the same day, design the + language pack as the first type of a general content-pack mechanism (later: resource packs for art / audio, then + server-side data packs) with one manifest, one registry and one index. +- **Now** (docs/I18N.md §5 for translators, docs/PACKS.md for the mechanism): + - **A pack** is a manifest plus its files, in one of two layouts the same registry reads: the single file + `public/i18n/.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/packs/README.md b/packs/README.md new file mode 100644 index 00000000..586e6112 --- /dev/null +++ b/packs/README.md @@ -0,0 +1,28 @@ +# packs/ — content packs + +Put a content pack here as its own folder: `packs//pack.json` (the manifest) next to the files it names. The +server lists every pack it finds at `/packs/index.json` and picks up a new or changed pack on the next page load — no +restart, no build step. `node tools/packs.mjs list` shows what was found, what was skipped and why. + +In this version one type loads: **`lang`**, a language for the interface (and, optionally, the game texts): + +```json +{ + "id": "ja", + "type": "lang", + "lang": "ja", + "name": "日本語", + "englishName": "Japanese", + "version": "1.0.0", + "app": ">=0.2.0", + "authors": ["your name"], + "fallback": ["en"], + "files": { "ui": "ui.json", "data": "data.json" } +} +``` + +A language can also be one file, `public/i18n/.json`, with the same fields in its `_meta` block — that is how +English ships. Types `assets` (art, audio, fonts) and `data` (data patches) are planned; this version lists such a pack +as not supported and does not load it. + +The format, the rules and how later types plug in: [docs/PACKS.md](../docs/PACKS.md). Translating: [docs/I18N.md](../docs/I18N.md). diff --git a/public/css/screens/title.css b/public/css/screens/title.css index 108f6f27..ed29a4ab 100644 --- a/public/css/screens/title.css +++ b/public/css/screens/title.css @@ -192,7 +192,7 @@ animation: rise-in 600ms 140ms var(--ease-out) backwards; } .title-cn__colon { color: #f4f6f5; } -/* English title (ui/lang.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */ +/* a title in an alphabetic script (English …; screens/title.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */ .title-cn--latin { font-family: var(--font-display); font-size: .6rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; } .title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); } .title-tag { @@ -243,10 +243,12 @@ .title-invite .icon { width: .2rem; height: .2rem; } .title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; } .title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); } -/* language switch (中文 | English) in the top-right corner */ +/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */ .title-lang { display: inline-flex; margin-bottom: .1rem; } .title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; } -html[data-lang="en"] .title-login .btn--xl { letter-spacing: .3em; } +.lang-select select { height: .38rem; min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: .16rem; cursor: pointer; } +.title-lang.lang-select select { height: .3rem; font-size: .14rem; } +html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; } .title-conn .ping { height: .26rem; font-size: .15rem; } .title-foot { diff --git a/public/i18n/en.json b/public/i18n/en.json index 3779fccd..487f74e7 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1,4 +1,18 @@ { + "_meta": { + "type": "lang", + "lang": "en", + "name": "English", + "englishName": "English", + "version": "0.2.0", + "app": ">=0.2.0", + "authors": [ + "@YuriRestia (GitHub PR #70, the seed of these strings)", + "the Stronghold Protocol contributors" + ], + "credits": "Game texts (data/i18n/en.json): the official EN client data (© Hypergryph / Yostar) via ArknightsAssets/ArknightsGamedata.", + "complete": true + }, "_about": "English UI strings: Chinese msgid → English (shared/i18n.js t(); docs/I18N.md). Placeholders {name} and {n|one|other} as in the msgid; a 'context::msgid' key is a tc() variant. Seeded from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (NOTICE.md). Game texts are not here: data/i18n/en.json (tools/build-i18n.mjs).", "你已在其他同盟中,请先离开当前同盟": "You are already in another Alliance. Leave it first.", "你已不在该同盟中": "You are no longer in this Alliance", @@ -116,6 +130,7 @@ "请输入博士代号": "Please enter your Doctor callsign", "卫戍协议": "Stronghold Protocol", "盟约": "Alliance", + "卫戍协议:盟约 · STRONGHOLD PROTOCOL": "Stronghold Protocol: Alliance · Web Simulation", "调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。": "Allocate Funds and Operators, coordinate defenses with your allies, hold off wave after wave, and defeat the Enemy Leader.", "收到同盟邀请": "Alliance invite received", "· 输入代号后将自动加入": "· You'll join automatically after entering your callsign", diff --git a/public/js/data.js b/public/js/data.js index 71630939..f3b725b0 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -18,15 +18,18 @@ // Synchronous getters (getChess, getBond, …) return null until the file has loaded; use // `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive. // -// Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale('en')` downloads the overlay -// data/i18n/en.json once (tools/build-i18n.mjs: the official EN texts by record id and field, shared/i18nData.js) and -// from then on `get`, `lookup` and `list` — and every getter built on them — return the records with their English -// texts (a copy per file, built on first use; untranslated fields keep the Chinese text). `setLocale('zh')` goes back. -// Each switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps -// a Chinese game-data name to the current language (server messages name operators / items in Chinese). +// Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale(lang, chain)` downloads (once each) the +// game-text overlays data/i18n/.json of the chain — the language's own, its base's, a fallback's (ui/lang.js +// passes the languages of the pack's chain that have one; default: the language alone) — built by tools/build-i18n.mjs +// (the official texts by record id and field, shared/i18nData.js). From then on `get`, `lookup` and `list` — and every +// getter built on them — return the records with their texts in that language (a copy per file, built on first use): +// each text from the first overlay of the chain that has it, else the Chinese one. `setLocale('zh')` goes back. Each +// switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps a +// Chinese game-data name to the current language (server messages name operators / items in Chinese). import { useEffect, useReducer } from '../vendor/hooks.module.js'; import { applyFileOverlay } from '../../shared/i18nData.js'; +import { canonicalLang } from '../../shared/i18nPacks.js'; /** Known data files (name → URL basename). Unknown names are allowed too (`/data/.json`). */ export const DATA_FILES = Object.freeze({ @@ -134,6 +137,10 @@ export function createDataStore(opts = {}) { const warned = new Set(); /** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */ let locale = 'zh'; + /** The languages whose overlays apply, best first (a subset of the chain setLocale got: those that downloaded). */ + let chain = []; + /** setLocale calls so far: a slower earlier call never overrides a later one. */ + let switches = 0; /** @type {Map>} */ const overlayLoads = new Map(); /** @type {Map} */ @@ -237,16 +244,22 @@ export function createDataStore(opts = {}) { return entry.promise; } - /** The localized copy of a ready file (null in Chinese or without an overlay for the file). */ + /** + * The localized copy of a ready file (null in Chinese or without an overlay for the file): the overlays of the chain + * applied from the last to the first, each text checked against the Chinese file, so the best overlay wins per text. + */ function localizedEntry(name) { - if (locale === 'zh') return null; + if (locale === 'zh' || !chain.length) return null; const e = entries.get(name); - const fileOverlay = overlays.get(locale)?.files?.[name]; - if (!e || e.status !== 'ready' || !fileOverlay) return null; + if (!e || e.status !== 'ready') return null; + const fileOverlays = chain.map((c) => overlays.get(c)?.files?.[name]).filter(Boolean); + if (!fileOverlays.length) return null; let l = localized.get(name); if (!l || l.lang !== locale || l.base !== e.value) { let value = e.value; - try { value = applyFileOverlay(e.value, fileOverlay).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); } + for (const fo of fileOverlays.reverse()) { + try { value = applyFileOverlay(e.value, fo, value).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); } + } l = { lang: locale, base: e.value, value, index: null }; localized.set(name, l); } @@ -265,13 +278,16 @@ export function createDataStore(opts = {}) { return e.index; } - /** Download (once per language) the overlay data/i18n/.json; resolves to it, or null when unavailable. */ - function loadOverlay(lang) { + /** + * Download (once per language) a game-text overlay — `url` (a pack folder's file, from the pack index) or the language + * folder's data/i18n/.json; resolves to it, or null when unavailable. + */ + function loadOverlay(lang, url = `${base}i18n/${lang}.json`) { if (overlayLoads.has(lang)) return overlayLoads.get(lang); const p = (async () => { for (let attempt = 0; ; attempt++) { try { - const res = await doFetch(`${base}i18n/${lang}.json`, { cache: 'no-cache' }); + const res = await doFetch(url, { cache: 'no-cache' }); if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null }); const json = await res.json(); if (!json || typeof json !== 'object' || !json.files) throw Object.assign(new Error('not an overlay'), { badJson: true }); @@ -279,7 +295,7 @@ export function createDataStore(opts = {}) { return json; } catch (err) { if (transientFailure(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); continue; } - console.warn(`[data] ${base}i18n/${lang}.json unavailable (${err?.message || err}); game texts stay Chinese`); + console.warn(`[data] ${url} unavailable (${err?.message || err}); its game texts are not applied`); overlayLoads.delete(lang); // a later switch may try again return null; } @@ -333,33 +349,52 @@ export function createDataStore(opts = {}) { listeners.add(fn); return () => listeners.delete(fn); }, - /** Current game-text locale ('zh' or a language whose overlay is applied). */ + /** Current game-text locale ('zh' or the language of the last switch). */ locale: () => locale, + /** The languages whose overlays apply now, best first (empty in Chinese). */ + localeChain: () => [...chain], /** - * Switch the game texts' language: 'zh' at once; another language once its overlay has downloaded (it stays Chinese - * when the overlay is unavailable). Every loaded file's subscribers are notified. Resolves to the locale in effect. + * Switch the game texts' language: 'zh' at once; another language once the overlays of `chain` (default: the + * language alone) have downloaded — those that fail are left out. When every overlay of a non-empty chain fails the + * locale does not change (the texts stay as they are); an empty chain (a pack without game texts) switches with + * the Chinese texts. Every loaded file's subscribers are notified. Resolves to the locale in effect. * @param {string} lang + * @param {(string | { code: string, url?: string })[]} [chainIn] overlay languages, best first (with the URL of + * each overlay when it is not the language folder's data/i18n/.json) * @returns {Promise} */ - async setLocale(lang) { - const want = typeof lang === 'string' && /^[a-z]{2}$/.test(lang) ? lang : 'zh'; - if (want !== 'zh' && !(await loadOverlay(want))) return locale; - if (want === locale) return locale; + async setLocale(lang, chainIn) { + const code = canonicalLang(lang); + const want = code && code === lang ? code : 'zh'; + const items = (Array.isArray(chainIn) ? chainIn : [want]).map((c) => (typeof c === 'string' ? { code: c, url: undefined } : c)); + const seen = new Set(); + const asked = want === 'zh' ? [] : items.filter((c) => c && canonicalLang(c.code) === c.code && c.code !== 'zh' && !seen.has(c.code) && seen.add(c.code)); + const ask = asked.map((c) => c.code); + const seq = ++switches; + const got = await Promise.all(asked.map((c) => loadOverlay(c.code, typeof c.url === 'string' && c.url ? c.url : undefined))); + if (seq !== switches) return locale; // a later switch is under way + const next = ask.filter((_, i) => got[i]); + if (ask.length && !next.length) return locale; + if (want === locale && next.join('|') === chain.join('|')) return locale; locale = want; + chain = next; localized.clear(); for (const name of [...entries.keys()]) notify(name); return locale; }, /** - * A Chinese game-data name (operator, item, bond, token …) in the current locale (itself in Chinese or when the - * overlay does not know it). + * A Chinese game-data name (operator, item, bond, token …) in the current locale: the first overlay of the chain + * that knows it, else the name itself (also in Chinese). * @param {string} zh * @returns {string} */ localeName(zh) { if (locale === 'zh' || typeof zh !== 'string') return zh; - const names = overlays.get(locale)?.names; - return (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) || zh; + for (const c of chain) { + const names = overlays.get(c)?.names; + if (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) return names[zh]; + } + return zh; }, }; } diff --git a/public/js/screens/title.js b/public/js/screens/title.js index 9d3f3917..2ff7873d 100644 --- a/public/js/screens/title.js +++ b/public/js/screens/title.js @@ -1,5 +1,6 @@ -// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language switch -// (中文 | English, ui/lang.js; in English the big title is the English one and the small wordmark above it hides). +// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language menu +// (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and +// the small wordmark above it hides). // // Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it, // marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which @@ -20,6 +21,7 @@ import { data, useData } from '../data.js'; import { FullscreenButton, detectFeatures } from '../ui/device.js'; import { LangToggle, useLang } from '../ui/lang.js'; import { t, N_ } from '../../../shared/i18n.js'; +import { scriptOf } from '../../../shared/i18nPacks.js'; import { GIcon } from '../ui/gameComponents.js'; import { SettingsModal } from '../ui/settings.js'; @@ -187,7 +189,7 @@ const STATUS_TEXT = { export function TitleScreen() { const conn = useStore((s) => s.connection, shallowEqual); const pendingJoin = useStore((s) => s.ui.pendingJoin); - const lang = useLang(); + useLang(); // re-render on a language switch const [name, setName] = useState(() => store.get().me.name || identity.loadName() || ''); const [settingsOpen, setSettingsOpen] = useState(false); const assetsSettled = useData('assets'); @@ -215,6 +217,9 @@ export function TitleScreen() { // touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view) const touchUi = useMemo(() => detectFeatures().coarse, []); + // a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it + // hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag) + const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic'; return html`