feedback5: merge W5I — real i18n phase 1: shared/i18n.js (Chinese msgids, params, data names), English game texts from the official EN client data (data/i18n/en.json), the 中文 / English switch on the title screen and in 设置, title / lobby / room / settings converted, server toasts and ticker lines with msgid + params, tools/i18n.mjs, docs/I18N.md (PR #70's translations by @YuriRestia reused, credited)
Conflict: server/match/player/basics.js imports (W5G's checkNotOwned + W5I's msg / dn). data/i18n/en.json rebuilt on the merged data (tools/build-i18n.mjs --offline): the 自选 data adds texts of CN-only operators and modules the EN client lacks, so coverage is 97.6 % (skills 95.9, talents 95.1); the coverage floors in test/i18n-data.test.js and docs/I18N.md say so. Golden unchanged; i18n / lobby / match / ui suites 1144/1144 after the rebuild, i18n e2e 1/1.
This commit is contained in:
commit
a611cb013c
38 files changed
+4043
-194
No files matched your search
@@ -13,6 +13,8 @@ Copyright (C) 2026 Stronghold-Protocol contributors
|
||||
- `tools/local-extract/aklz4.py` 来自 [isHarryh/Ark-Unpacker](https://github.com/isHarryh/Ark-Unpacker),保持 BSD-3-Clause 许可(见 `tools/local-extract/LICENSE-Ark-Unpacker.txt`)。
|
||||
- 通过 npm 安装的第三方库(PixiJS、pixi-spine、Preact、htm、three.js、ws 等)和字体各自保留原许可证,见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
|
||||
|
||||
**英文翻译致谢**:界面英文文本 `public/i18n/en.json` 与游戏文本的补充译文 `tools/i18n/fallback-pr70.json` 以 GitHub PR #70(分支 `en-translation`)中 **@YuriRestia** 的翻译为基础,经项目所有者决定(2026-10-05)署名沿用,随本项目一同以 GPL-3.0-or-later 发布;PR #70 中取自 ak-spa-database.pages.dev(未声明许可)的文本未被采用。英文游戏文本 `data/i18n/en.json` 本身是官方英文服数据(见第 2 节)。
|
||||
|
||||
**附加许可(GPL-3.0 第 7 条)** — Additional permission under GNU GPL version 3 section 7:
|
||||
|
||||
> If you modify this Program, or any covered work, by linking or combining it with the Spine Runtimes (as shipped in
|
||||
@@ -28,7 +30,7 @@ Copyright (C) 2026 Stronghold-Protocol contributors
|
||||
《明日方舟》及「卫戍协议」相关的全部**名称、角色、美术、Spine 模型、界面图、音乐音效、文本与游戏数据**,版权归上海鹰角网络科技有限公司及其授权方(Yostar 等)所有。具体包括:
|
||||
|
||||
- Release 完整包中的 `public/assets/**`(含从官方客户端本地提取的 3D 棋盘模型与贴图 `public/assets/local/**`)和 `public/fonts/**`(字体归各自作者);
|
||||
- 由官方数据表生成的 `data/*.json`,以及含有或派生自游戏数据的 `docs/research/*.json`、`test/fixtures/official-waves.json`、`public/dev/recordings/*.json`;
|
||||
- 由官方数据表生成的 `data/*.json` 与 `data/i18n/*.json`(官方英文服文本),以及含有或派生自游戏数据的 `docs/research/*.json`、`test/fixtures/official-waves.json`、`public/dev/recordings/*.json`;
|
||||
- `docs/img/` 中的游戏截图;
|
||||
- `docs/` 中引用的 PRTS、BWIKI、NGA、巴哈姆特等社区页面的文字(仍按其来源的许可,维基文本为 CC BY-NC-SA)。
|
||||
|
||||
@@ -64,3 +66,5 @@ art, models, audio and data — including everything under `public/assets/` in t
|
||||
Yostar and their licensors, are **not** covered by the GPL, and may be used for study and personal non-commercial
|
||||
purposes only: no selling, paid distribution, paid hosting, ads, donations or any other monetisation. Rights holders
|
||||
can request removal through a GitHub issue and the content will be taken down. No warranty of any kind.
|
||||
The English UI strings were seeded from GitHub PR #70 by @YuriRestia (credited, released with the project under
|
||||
GPL-3.0-or-later); the English game texts (`data/i18n/en.json`) are official EN client data like `data/*.json`.
|
||||
@@ -48,9 +48,22 @@ All names, characters, artwork, Spine models, UI graphics, music, sound effects
|
||||
[fexli/ArknightsResource](https://github.com/fexli/ArknightsResource),
|
||||
[isHarryh/Ark-Models](https://github.com/isHarryh/Ark-Models),
|
||||
[ArknightsAssets/ArknightsAssets2](https://github.com/ArknightsAssets/ArknightsAssets2) — thanks to their maintainers.
|
||||
The English game texts (`data/i18n/en.json`, `tools/build-i18n.mjs`) come from the official EN client tables mirrored by
|
||||
[ArknightsAssets/ArknightsGamedata](https://github.com/ArknightsAssets/ArknightsGamedata) (`en/gamedata`, the default
|
||||
source) and [Kengxxiao/ArknightsGameData_YoStar](https://github.com/Kengxxiao/ArknightsGameData_YoStar) (`en_US`,
|
||||
`--source yostar`); same terms as the rest of the game data.
|
||||
Quotations of PRTS Wiki, BWIKI, NGA, 巴哈姆特 and other community pages in `docs/` stay under the terms of their
|
||||
sources (the wikis' texts are CC BY-NC-SA).
|
||||
|
||||
## Translations
|
||||
|
||||
The English UI strings (`public/i18n/en.json`) and the fallback game-text translations (`tools/i18n/fallback-pr70.json`)
|
||||
are based on GitHub PR #70 (branch `en-translation`) by **@YuriRestia**, reused with credit and released with the project
|
||||
under GPL-3.0-or-later (docs/I18N.md). PR #70 named ak-spa-database.pages.dev and arknights.wiki.gg as references: texts
|
||||
it took from ak-spa-database.pages.dev (no licence) are excluded; arknights.wiki.gg is CC BY-SA 4.0, which may be combined
|
||||
into a GPL-3.0 work (the one-way compatibility Creative Commons declared) — thanks to its contributors. Official game
|
||||
terms and names in those files remain © Hypergryph / Yostar.
|
||||
|
||||
---
|
||||
|
||||
## Licence texts
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2494,"translated":2445,"official":2427,"pct":98},"operators":{"texts":876,"translated":852,"official":844,"pct":97.3},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":4318,"translated":4107,"official":4099,"pct":95.1},"tokens":{"texts":442,"translated":442,"official":441,"pct":100},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":22183,"translated":21649,"official":21555,"pct":97.6}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":"ILine truncated
|
||||
+4
-1
@@ -1,8 +1,11 @@
|
||||
# DATA.md — generated game data (`data/*.json`)
|
||||
|
||||
All files in `data/` except `data/assets.json` are produced by **`node tools/build-data.mjs`** (task F1) from the
|
||||
All files in `data/` except `data/assets.json` and `data/i18n/` are produced by **`node tools/build-data.mjs`** (task F1) from the
|
||||
official zh_CN client data ([Kengxxiao/ArknightsGameData](https://github.com/Kengxxiao/ArknightsGameData)) joined
|
||||
with `docs/research/*.json`. Do not edit them by hand — change the build script and rebuild.
|
||||
`data/i18n/en.json` (the official English texts of these files by record id and field) is written by
|
||||
`node tools/build-i18n.mjs` — rerun it after a build that changes a text (docs/I18N.md §2; `test/i18n-data.test.js`
|
||||
fails while it is stale).
|
||||
`data/assets.json` is written by `tools/fetch-assets.mjs`, which keeps the current file rather than drop entries whose
|
||||
downloads failed on this machine unless `--allow-shrink` (or `--prune`) is passed (docs/ASSETS.md, DESIGN §21.25).
|
||||
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# I18N.md — languages (中文 / English)
|
||||
|
||||
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).
|
||||
|
||||
Three kinds of text, three mechanisms — no i18n framework:
|
||||
|
||||
| Text | Source of the English | 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 |
|
||||
|
||||
## 1. UI strings: `t()`
|
||||
|
||||
gettext style — the Chinese source string is the message id, so an untranslated string shows Chinese, never a key:
|
||||
|
||||
```js
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
t('整备区已满'); // 'Bench is full' in English
|
||||
t('还剩 {n} 秒', { n }); // params: {name}; arrays fill {0}, {1} …
|
||||
t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN });
|
||||
tc('toggle', '关闭'); // context: the key 'toggle::关闭' ("Off"), else '关闭' ("Close")
|
||||
const TABLE = { a: N_('独立模拟') }; // module level: N_ only marks it; call t(TABLE.a) where it is shown
|
||||
```
|
||||
|
||||
- **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).
|
||||
- **Game-data names** inside a message are marked `dn(name)` (`{ dn: '…' }`) and translated through the localized data
|
||||
(`tName(name)` does the same for a bare name). **Player names are never translated** — pass them as plain strings.
|
||||
- **Data texts are not msgids**: `getChess(id).name`, `getMode(id).desc`, `data.get('config').tips` … already come in the
|
||||
current language (§2). Do not wrap them in `t()`.
|
||||
- **Re-rendering**: `main.js` App subscribes with `useLang()` (`public/js/ui/lang.js`), so the whole tree re-renders on a
|
||||
switch. A component that caches text outside rendering (a `useMemo` returning text, a module-level string) must
|
||||
depend on the language (`useLang()`) or move the `t()` call into rendering.
|
||||
- Font samples, data keys and other Chinese that is not UI text can carry `// i18n-ignore` on the line.
|
||||
|
||||
### 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 of the converted screens is
|
||||
present. Use the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts):
|
||||
|
||||
| 中文 | English | 中文 | English |
|
||||
|---|---|---|---|
|
||||
| 盟约 | Alliance | 调度中心 | Dispatch Center |
|
||||
| 整备区 / 临时整备区 | Bench / temporary Bench | 资金 | Funds |
|
||||
| 休整期 | Rest Phase | 机变阶段 | Improv Phase |
|
||||
| 联防 / 联防阶段 | Unite / Unite Phase | 最终攻势 | Final Assault |
|
||||
| 隐秘核心 | Hidden Core | 目标生命值 | LP |
|
||||
| 联合模拟 / 同盟模拟 | Team Simulation | 独立模拟 | Solo Simulation |
|
||||
| 标准 / 险境 / 绝境 / 终极模拟 | Standard / Perilous / Dire / Ultimate Simulation | 入门协议 | Entry Protocol |
|
||||
| 策略 | Strategy | 特质 | Attribute |
|
||||
| 精锐(干员) | Elite (Operator) | 层数 | stacks |
|
||||
| 同盟密钥 | Alliance Key | 创建者 | Host |
|
||||
|
||||
### Tools: `node tools/i18n.mjs`
|
||||
|
||||
- `extract [paths] [--list]` — per file: the msgids in `t()` / `tc()` / `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).
|
||||
- `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
|
||||
`N_()` and are listed for a manual `t()` where they are shown; compared literals, object keys, string-method
|
||||
arguments, `console.*` and `Error` messages are left alone; a text run with a child that is not a plain value is split
|
||||
into its static pieces. **Review the diff**: join fragments into whole sentences where it reads better (the settings
|
||||
hotkey line is an example), check module-level tables.
|
||||
- `seed --from <map.json> [--all] [--write]` — fills `en.json` from a flat `{ msgid: English }` map for the msgids the
|
||||
code uses (`--all`: also the literals not wrapped yet, under the msgid the codemod will give them).
|
||||
|
||||
Server code is scanned too: the first argument of `msg()` and a Chinese literal handed to `m.toast` / `ctx.toast` /
|
||||
`tickerText` are msgids (`server/sim` only for those — its own `t()` helpers are not translation calls).
|
||||
|
||||
## 2. Game texts: `data/i18n/en.json`
|
||||
|
||||
```
|
||||
node tools/build-i18n.mjs # downloads the EN tables once into .cache/gamedata-en/, writes data/i18n/en.json
|
||||
node tools/build-i18n.mjs --offline # never download (the zh_CN tables come from .cache/gamedata, as for build-data)
|
||||
node tools/build-i18n.mjs --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)
|
||||
```
|
||||
|
||||
**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).
|
||||
|
||||
- **Sources.** The English comes from the official EN client tables. Default `--source assets`:
|
||||
[ArknightsAssets/ArknightsGamedata](https://github.com/ArknightsAssets/ArknightsGamedata) `en/gamedata` — current, and
|
||||
its `activity_table` has both 盟约 seasons (`act1autochess`, `act2autochess`, "Stronghold Protocol: Alliance") with
|
||||
the same ids as zh_CN. `--source yostar`:
|
||||
[Kengxxiao/ArknightsGameData_YoStar](https://github.com/Kengxxiao/ArknightsGameData_YoStar) `en_US` — archived in
|
||||
2025-11, it predates 盟约 (only the 2025 test event `act1vautochess`) and many operators, so most mode texts and new
|
||||
operators stay Chinese with it.
|
||||
- **Matching** (no id table to maintain): the zh_CN and EN tables are walked side by side; a data text equal to a
|
||||
Chinese source takes its English; a text whose blackboard placeholders build-data resolved (`{atk:0%}` → `80%`) is
|
||||
matched against the Chinese templates of the same shape, its numbers are read back and the English template is
|
||||
filled with them (the remake's numbers, not the EN server's); stage names are assembled from their parts. Ties prefer
|
||||
the source on the record's own ids, then the 下半 season (`act2autochess`). Rich-text tags and placeholders are the
|
||||
same as in Chinese, so `ui/richText.js` renders the English unchanged (a few official EN texts leave a tag unclosed;
|
||||
the parser runs it to the end of the text, as the game does).
|
||||
- **Fallbacks**, in order: `tools/i18n/fallback-remake.json` (the remake's own strings: stage-name parts, item notes,
|
||||
the 联防 map names, the season name), `tools/i18n/fallback-pr70.json` (PR #70's translations of texts the EN client
|
||||
lacks: modules of CN-only operators, the 甄选 slots, 结算称号 criteria, emote labels), then `public/i18n/en.json`;
|
||||
anything else stays Chinese. Research notes (`bonds.*.spec`, `implFormula`, `assumed` …) are never translated.
|
||||
- **Format** (`shared/i18nData.js`): `{ version, lang, meta: { source, coverage, totals }, names, files }`. `files[file][id]`
|
||||
mirrors the record — string leaves replace the Chinese at the same path, arrays are index-aligned (null = keep) —
|
||||
with `_h`, a 2-character hash of each replaced Chinese string; `names` maps every record-level Chinese name to English
|
||||
(server messages and ticker lines name operators / items / bonds in Chinese).
|
||||
- **Coverage** (build of 2026-10-05 with the 自选 data, 22 183 texts): 97.6 % translated, 97.2 % from the official EN
|
||||
data. What stays Chinese is CN-only content the EN client does not have yet: some of the 72 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.
|
||||
|
||||
## 3. Server messages
|
||||
|
||||
The server sends a toast or a custom ticker line as a plain string (its own msgid) or as
|
||||
`msg(msgid, params)` (`shared/i18n.js`). Frames keep `text` — the Chinese rendering, what older clients show — and add
|
||||
`msgid` / `params`; data names are `dn()` params. A `config.broadcasts` line (`tickerFor`) also carries its `args`, and
|
||||
the client fills the localized broadcast template (`ui/lang.js tickerText`; `{0}` is the player's name and is never
|
||||
translated). Error frames already carry their ERR code: `ui/toasts.js describeError` translates `ERR_TEXT[code]`. The
|
||||
protocol is backward compatible both ways (new fields are optional).
|
||||
|
||||
```js
|
||||
this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
|
||||
```
|
||||
|
||||
## 4. Phase 2 (after the 0.2.0 feature screens land)
|
||||
|
||||
Phase 1 converted the title screen, the lobby, the room, the settings panel (with the language switch), the app shell
|
||||
(main.js: toasts, close reasons, the crash screen), `ui/toasts.js`, `DifficultyTag` and the server messages. Phase 2:
|
||||
|
||||
1. `node tools/i18n.mjs extract` lists the rest (2026-10-05: 1 033 literals in 56 files — briefing, band draft, game
|
||||
screen and its HUD / panels / overlays, loadout, result, guide, connection banner, components …; 964 of them already
|
||||
have a translation seeded from PR #70).
|
||||
2. Per file (one stream per file group to avoid conflicts): `node tools/i18n.mjs codemod <file> --write`, review the
|
||||
diff (module-level tables: `t()` at the use site; join split sentences; `tc()` where one Chinese text needs two
|
||||
English ones), then `node tools/i18n.mjs check` and translate what is missing, in the official terms above.
|
||||
3. Texts the server puts in `m.public` / `m.private` (机变 card names and descriptions, effect names in views) should be
|
||||
looked up by id on the client (`getEffect` / `data.lookup`) instead of shown as sent.
|
||||
4. `public/js/ui/guide.js` chapter / page titles, the `GuideButton` / `LoadoutButton` default labels and tooltips.
|
||||
5. When the sweep is complete: `node tools/i18n.mjs check --strict` in CI, and `extract` reporting 0.
|
||||
|
||||
## Credits
|
||||
|
||||
The English UI strings were seeded from GitHub PR #70 (branch `en-translation`) by **@YuriRestia**, reused with credit
|
||||
(the owner's decision of 2026-10-05); see NOTICE.md and THIRD-PARTY-NOTICES.md. PR #70's texts taken from
|
||||
ak-spa-database.pages.dev (no licence) are not used. The English game texts are the official EN client data
|
||||
(© Hypergryph / Yostar), via the community mirrors named above.
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
本文数值来自官方数据表(`data/config.json` 等)与 [research](research/00-INDEX.md);游戏内左下角 📖「玩法说明」有官方教程图(随素材一起下载;没下载到时显示文字要点)。操作方式见 [README · 操作](../README.md#操作)。
|
||||
|
||||
界面语言:默认中文;标题画面右上角或「设置」里可切换 **中文 / English**(会记住选择,不用刷新)。英文的干员、技能、盟约、装备、敌人等游戏文本取自官方英文服数据;尚未翻译的界面文字暂时显示中文(docs/I18N.md)。
|
||||
|
||||
## 1. 一局的流程
|
||||
|
||||
```
|
||||
|
||||
@@ -192,6 +192,8 @@
|
||||
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 */
|
||||
.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 {
|
||||
margin-top: .12rem;
|
||||
@@ -241,6 +243,10 @@
|
||||
.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 */
|
||||
.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; }
|
||||
.title-conn .ping { height: .26rem; font-size: .15rem; }
|
||||
|
||||
.title-foot {
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"_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",
|
||||
"创建者已离开,同盟已解散": "The host left. The Alliance has been disbanded.",
|
||||
"由于长时间断开连接,你已离开同盟": "You left the Alliance after being disconnected for too long",
|
||||
"同盟已解散": "The Alliance has been disbanded",
|
||||
"你已被移出同盟": "You were removed from the Alliance",
|
||||
"模拟已结束": "Simulation Over",
|
||||
"同盟已过期": "The Alliance has expired",
|
||||
"服务器维护中,同盟已关闭": "Server maintenance in progress. The Alliance has been closed.",
|
||||
"该身份已在其他页面登录,本页已断开": "This identity signed in on another page. This page has been disconnected.",
|
||||
"同盟已关闭:{reason}": "Alliance closed: {reason}",
|
||||
"同盟已关闭": "Alliance closed",
|
||||
"界面发生错误": "An interface error occurred",
|
||||
"重新加载界面": "Reload Interface",
|
||||
"启动失败,请刷新页面重试": "Failed to start. Please refresh the page and try again.",
|
||||
"时长较短的模拟训练": "A shorter simulation exercise",
|
||||
"可以快速完成作战": "Battles can be completed quickly",
|
||||
"常规奖励": "Standard rewards",
|
||||
"敌方攻击强度较高的模拟训练": "A simulation exercise with high enemy strength",
|
||||
"可使用盟约数增加": "More Alliances available",
|
||||
"大幅增加奖励": "Greatly increased rewards",
|
||||
"敌方攻击强度极高的模拟训练": "A simulation exercise with very high enemy strength",
|
||||
"作战环境困难": "Harsh combat environment",
|
||||
"出现更加危险的敌人": "More dangerous enemies appear",
|
||||
"敌方攻击强度到达极限的模拟训练": "A simulation exercise with enemy strength pushed to the limit",
|
||||
"作战环境无比困难": "Extremely harsh combat environment",
|
||||
"出现极度危险的敌人": "Extremely dangerous enemies appear",
|
||||
"敌方攻击强度较低的模拟训练": "A simulation exercise with low enemy strength",
|
||||
"作战环境较为温和": "Mild combat environment",
|
||||
"战场固定为 {name}": "Fixed Battlefield: {name}",
|
||||
"战场固定": "Fixed Battlefield",
|
||||
"战场随机(共{n}张)": "Random Battlefield ({n} total)",
|
||||
"战场随机(共{stages}张)": "Random Battlefield ({stages} total)",
|
||||
"独立模拟": "Solo Simulation",
|
||||
"独自调配资金与干员,以自己的节奏完成整场模拟。": "Manage your own Funds and Operators and play the whole simulation at your own pace.",
|
||||
"1 名博士": "1 Doctor",
|
||||
"休整期与机变阶段不限时": "No time limit in the Rest Phase and Improv Phase",
|
||||
"同盟模拟": "Team Simulation",
|
||||
"联防阶段 · 最终攻势合并生命值": "Unite Phase · Shared LP from the Final Assault",
|
||||
"联合模拟在选择策略时可以进行一次跳过": "In Team Simulation, you can skip once when choosing a Strategy",
|
||||
"调度中心即使冻结,依然可以主动刷新": "Even when frozen, the Dispatch Center can still be refreshed manually",
|
||||
"两件同名装备可以合成一件更强力的装备": "Two identical pieces of Equipment can be combined into a stronger one",
|
||||
"只有达成完美作战的队友可以进行联防": "Only Teammates who achieve a Perfect Battle can Unite",
|
||||
"作战提示": "Combat Tips",
|
||||
"上一条": "Previous tip",
|
||||
"下一条": "Next tip",
|
||||
"已选定": "Selected",
|
||||
"回合": "Round",
|
||||
"+ 隐秘核心": "+ Hidden Core",
|
||||
"尚未连接到服务器,请稍候": "Not connected to the server yet, please wait",
|
||||
"同盟密钥为 {ROOM_CODE_LEN} 位字母或数字": "The Alliance Key is {ROOM_CODE_LEN} letters or digits",
|
||||
"返回标题": "Back to Title",
|
||||
"返回": "Back",
|
||||
"选择模拟协议": "Select Simulation Protocol",
|
||||
"玩法说明": "How to Play",
|
||||
"干员调配": "Operator Loadout",
|
||||
"博士": "Doctor",
|
||||
"模拟方式": "Mode",
|
||||
"加入同盟": "Join Alliance",
|
||||
"输入同盟密钥 / 粘贴邀请链接": "Enter Alliance Key / paste invite link",
|
||||
"观战": "Spectate",
|
||||
"最近的同盟": "Recent Alliances",
|
||||
"向同伴索取 {ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接": "Ask a teammate for the {ROOM_CODE_LEN}-character Alliance Key, or open the invite link directly",
|
||||
"模拟难度": "Difficulty",
|
||||
"正在连接服务器…": "Connecting to server...",
|
||||
"开始独立模拟": "Start Solo Simulation",
|
||||
"创建同盟": "Create Alliance",
|
||||
"创建后即可开始模拟": "You can start the simulation right after creating it",
|
||||
"创建后可邀请好友或添加 AI 队友": "After creating, invite friends or add AI Teammates",
|
||||
"等待博士加入": "Waiting for a Doctor",
|
||||
"添加 AI 队友": "Add AI Teammate",
|
||||
"空位": "Empty",
|
||||
"创建者": "Host",
|
||||
"AI 队友": "AI Teammate",
|
||||
"你": "You",
|
||||
"已就绪": "Ready",
|
||||
"连接中断": "Disconnected",
|
||||
"待命中": "Standing by",
|
||||
"准备中": "Preparing",
|
||||
"移除该 AI 队友": "Remove this AI Teammate",
|
||||
"移除 AI 队友": "Remove AI Teammate",
|
||||
"已复制同盟密钥 {code}": "Alliance Key {code} copied",
|
||||
"已复制邀请链接": "Invite link copied",
|
||||
"复制失败,请手动复制": "Copy failed, please copy manually",
|
||||
"同盟密钥": "Alliance Key",
|
||||
"同盟密钥 {code}": "Alliance Key {code}",
|
||||
"复制密钥": "Copy Key",
|
||||
"复制链接": "Copy Link",
|
||||
"由创建者选择": "Chosen by the Host",
|
||||
"连接中断,请稍候重试": "Connection lost, please try again shortly",
|
||||
"离开同盟": "Leave Alliance",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "You are the Host of this Alliance. If you leave, host duties will be handed over or the Alliance will disband. Leave anyway?",
|
||||
"离开": "Leave",
|
||||
"连接中断,正在重连…": "Connection lost, reconnecting...",
|
||||
"*模拟协议已就绪,准许进入模拟": "*Protocol ready to deploy",
|
||||
"*同盟人数达标,准许进入模拟": "*Alliance ready to deploy",
|
||||
"等待所有博士准备就绪": "Waiting for all Doctors to be ready",
|
||||
"已就绪 · 等待创建者开始模拟": "Ready · waiting for the Host",
|
||||
"准备就绪后,创建者即可开始模拟": "Get ready so the Host can start",
|
||||
"当前延迟": "Current Ping",
|
||||
"仅限 1 名博士": "1 Doctor only",
|
||||
",满足条件时进入隐秘核心": "; the Hidden Core is entered when conditions are met",
|
||||
"独立模拟中休整期与机变阶段不限时": "In Solo Simulation, the Rest Phase and Improv Phase have no time limit",
|
||||
"仍有博士未准备就绪": "Some Doctors are not ready yet",
|
||||
"开始模拟": "Start Simulation",
|
||||
"准备就绪": "Ready",
|
||||
"准备连接": "Ready to connect",
|
||||
"正在连接服务器": "Connecting to server",
|
||||
"已连接服务器": "Connected to server",
|
||||
"正在验证身份": "Verifying identity",
|
||||
"连接中断,正在重连": "Connection lost, reconnecting",
|
||||
"连接已关闭": "Connection closed",
|
||||
"请输入博士代号": "Please enter your Doctor callsign",
|
||||
"卫戍协议": "Stronghold Protocol",
|
||||
"盟约": "Alliance",
|
||||
"调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。": "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",
|
||||
"博士代号": "Doctor Callsign",
|
||||
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "Callsign (max {NAME_MAX_LEN} chars)",
|
||||
"开始": "Start",
|
||||
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "Unofficial fan remake · Game assets © Hypergryph / Yostar",
|
||||
"高": "High",
|
||||
"中": "Medium",
|
||||
"低": "Low",
|
||||
"刷新": "Refresh",
|
||||
"冻结": "Freeze",
|
||||
"升级": "Upgrade",
|
||||
"设置": "Settings",
|
||||
"完成": "Done",
|
||||
"背景音乐": "Music",
|
||||
"音效": "Sound Effects",
|
||||
"静音": "Mute",
|
||||
"显示伤害数字": "Damage Numbers",
|
||||
"画面质量": "Graphics Quality",
|
||||
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "Touch controls: tap a unit to select it (Retreat / Sell) · long-press a unit or card for details · after dragging to deploy, swipe to choose a direction",
|
||||
"快捷键:": "Hotkeys: ",
|
||||
"发生未知错误": "An unknown error occurred",
|
||||
"标准模拟": "Standard Simulation",
|
||||
"险境模拟": "Perilous Simulation",
|
||||
"绝境模拟": "Dire Simulation",
|
||||
"终极模拟": "Ultimate Simulation",
|
||||
"无效的请求": "Invalid request",
|
||||
"操作过于频繁": "Too many actions, slow down",
|
||||
"你不在房间中": "You are not in a room",
|
||||
"未找到该同盟密钥对应的房间": "No room found for this Alliance Key",
|
||||
"房间已满": "Room is full",
|
||||
"模拟已开始": "Simulation already started",
|
||||
"只有房主可以操作": "Only the host can do that",
|
||||
"仍有玩家未就绪": "Some players are not ready",
|
||||
"当前阶段无法进行该操作": "Cannot do that in this phase",
|
||||
"资金不足": "Insufficient Funds",
|
||||
"整备区已满": "Bench is full",
|
||||
"已达到部署上限": "Deployment limit reached",
|
||||
"无法部署在该位置": "Cannot deploy here",
|
||||
"无效的目标": "Invalid target",
|
||||
"已售出": "Sold Out",
|
||||
"调度中心已达最高等级": "Dispatch Center is at max level",
|
||||
"尚未轮到你": "It is not your turn yet",
|
||||
"已完成该操作": "Already done",
|
||||
"临时整备区不为空": "Temporary Bench is not empty",
|
||||
"你已被淘汰": "You have been eliminated",
|
||||
"服务器内部错误": "Internal server error",
|
||||
"请求超时,请重试": "Request timed out. Please try again.",
|
||||
"未连接到服务器": "Not connected to the server",
|
||||
"连接已断开,请重试": "Connection lost. Please try again.",
|
||||
"该身份已在其他页面登录": "This identity is signed in on another page",
|
||||
"客户端版本与服务器不一致,请刷新页面": "Client version does not match the server. Please refresh the page.",
|
||||
"队友": "Teammate",
|
||||
"该队友已被淘汰,无法查看": "This Teammate has been eliminated and cannot be viewed",
|
||||
"该队友当前没有战场": "This Teammate has no Battlefield right now",
|
||||
"队友与你在同一战场,使用 ‹ › 切换视角": "Your Teammate is on your Battlefield. Use ‹ › to switch views.",
|
||||
"无法查看另一组队友的战场": "Cannot view the other pair's Battlefield",
|
||||
"作战中无法查看队友,作战结束后可前往查看": "Cannot view Teammates during Battle. You can view them once your Battle ends.",
|
||||
"无人在家": "Nobody Home",
|
||||
"你自己": "You",
|
||||
"全景": "Overview",
|
||||
"未知错误": "Unknown error",
|
||||
"战场": "Battlefield",
|
||||
"本局禁用": "Disabled this match",
|
||||
"{skip};本局不限时": "{skip}; no time limit this match",
|
||||
"每位博士有 {0} 秒": "each Doctor has {0} s",
|
||||
"每位博士限时决策": "each Doctor has a time limit",
|
||||
"当前选中的": "the currently highlighted ",
|
||||
"已选择「{0}」{1}": "Selected \"{0}\"{1}",
|
||||
",等待其他博士": ", waiting for other Doctors",
|
||||
"轮到你决策": "Your turn to decide",
|
||||
"{turnName} 决策中": "{turnName} is deciding",
|
||||
"等待轮到你": "Waiting for your turn",
|
||||
"华法琳": "Warfarin",
|
||||
"选择策略": "Choose Strategy",
|
||||
"查看禁用盟约与干员": "View disabled Alliances & Operators",
|
||||
"策略": "Strategy",
|
||||
"决策顺序": "Pick Order",
|
||||
"已选择": "Selected",
|
||||
"决策中": "Deciding",
|
||||
"等待中": "Waiting",
|
||||
"队友已选": "Taken by a Teammate",
|
||||
"初始生命值": "Initial LP",
|
||||
"选择一个策略查看详情": "Select a Strategy to view details",
|
||||
"队友已选,请选择其他策略": "Taken by a Teammate, please choose another Strategy",
|
||||
"{turnName} 正在决策…": "{turnName} is deciding...",
|
||||
"跳过本轮,稍后再选": "Skip this turn and pick later",
|
||||
"跳过次数已用完": "No skips left",
|
||||
"(已用)": " (used)",
|
||||
"确认选择": "Confirm",
|
||||
"战场随机(共{poolN}张)": "Random Battlefield ({poolN} total)",
|
||||
"确认本局信息": "Confirm Match Info",
|
||||
"ENEMY LEADER // 敌方领袖": "ENEMY LEADER // Enemy Leader",
|
||||
"第": "Round",
|
||||
"回合 · 最终攻势": "· Final Assault",
|
||||
"特训敌人": "Tactical Training Enemies",
|
||||
"未知领袖": "Unknown Leader",
|
||||
"本局没有特训敌人": "No Tactical Training Enemies this match",
|
||||
"本局模拟已结束": "This simulation has ended",
|
||||
"同盟已返回等待室": "The Alliance has returned to the waiting room",
|
||||
"返回同盟": "Back to Alliance",
|
||||
"暂停中": "Paused",
|
||||
"作战已暂停,计时与敌人行动均已停止": "Battle paused; the timer and enemy actions have stopped",
|
||||
"放弃模拟": "Abandon Simulation",
|
||||
"继续作战": "Resume Battle",
|
||||
"正在载入模拟数据…": "Loading simulation data...",
|
||||
"正在进入模拟…": "Entering simulation...",
|
||||
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)",
|
||||
"已配发的装备无法销毁": "Equipped Equipment cannot be destroyed",
|
||||
"销毁道具": "Destroy Item",
|
||||
"道具无法出售。确定要销毁「{0}」吗?": "Items cannot be sold. Destroy \"{0}\"?",
|
||||
"道具": "Item",
|
||||
"销毁": "Destroy",
|
||||
"正在查看": "Viewing",
|
||||
"的阵地(只读)": "'s Field (read-only)",
|
||||
"返回自己": "Back to Mine",
|
||||
"你已被淘汰 · 可继续观战队友": "You have been eliminated · You can keep spectating your Teammates",
|
||||
"已调整": "Customized",
|
||||
"不装备模组": "No Module",
|
||||
"未知技能": "Unknown Skill",
|
||||
"默认": "Default",
|
||||
"已装备": "Equipped",
|
||||
"初始": "Initial",
|
||||
"消耗": "Cost",
|
||||
"持续": "Duration",
|
||||
"充能": "Charges",
|
||||
"不装备模组:精锐干员以基础属性、特性与天赋作战。": "No Module: the Elite Operator fights with its base stats, Trait and Talents.",
|
||||
"特性": "Trait",
|
||||
"属性": "Stats",
|
||||
"无属性加成": "No stat bonus",
|
||||
"天赋": "Talent",
|
||||
"局内数值": "In-Match Stats",
|
||||
"数值版本": "Stat Variant",
|
||||
"普通": "Normal",
|
||||
"精锐": "Elite",
|
||||
"不含技能发动、装备、盟约等局内加成": "excludes in-match bonuses such as skill activation, Equipment and Alliances",
|
||||
"数值含所选模组;": "Includes the chosen Module; ",
|
||||
"普通干员没有模组,所选模组在「精锐」中生效;": "Normal Operators have no Module; the chosen Module applies to the [Elite]; ",
|
||||
"没有符合条件的干员": "No Operators match the filters",
|
||||
"恢复默认": "Reset to Default",
|
||||
"技能": "Skill",
|
||||
"技能等级": "Skill Level",
|
||||
"选择技能": "Choose Skill",
|
||||
"默认配置": "Default Loadout",
|
||||
"模组": "Module",
|
||||
"仅精锐干员装备 · 模组等级": "Elite Operators only · Module Level",
|
||||
"选择模组": "Choose Module",
|
||||
"不装备": "None",
|
||||
"本局的调配已锁定,修改将在下一局生效": "Loadout is locked for this match; changes will apply next match",
|
||||
"阶级": "Tier",
|
||||
"全部": "All",
|
||||
"搜索干员 / 职业 / 盟约": "Search Operator / Class / Alliance",
|
||||
"职业": "Class",
|
||||
"按盟约筛选": "Filter by Alliance",
|
||||
"全部盟约": "All Alliances",
|
||||
"仅看已调整": "Customized Only",
|
||||
"{t}阶": "Tier {t}",
|
||||
"保存中…": "Saving…",
|
||||
"同步中…": "Syncing…",
|
||||
"已同步": "Synced",
|
||||
"本局已锁定 · 下一局生效": "Locked for this match · applies next match",
|
||||
"同步失败": "Sync failed",
|
||||
"全部恢复默认": "Reset All to Default",
|
||||
"已复制到剪贴板": "Copied to clipboard",
|
||||
"复制失败,请在文本框中手动全选复制": "Copy failed, please select all in the text box and copy manually",
|
||||
"文件过大,请选择「导出」下载的调配文件": "File too large. Please choose a loadout file downloaded via [Export]",
|
||||
"读取文件失败": "Failed to read the file",
|
||||
"干员数据仍在载入,请稍候再导入": "Operator data is still loading, please wait before importing",
|
||||
"导入失败:这份数据在当前版本没有可用的调配,未做任何改动": "Import failed: this data has no loadout usable in the current version. Nothing was changed",
|
||||
"确认本局信息阶段结束前可调整本局配置": "You can adjust this match's loadout until the match briefing ends",
|
||||
"开始模拟前可调整干员携带的技能与模组,干员等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry; Operator levels cannot be changed",
|
||||
"返回 (Esc)": "Back (Esc)",
|
||||
"导出当前调配(可复制或下载)": "Export the current loadout (copy or download)",
|
||||
"导出": "Export",
|
||||
"导入调配(粘贴或选择文件)": "Import a loadout (paste or choose a file)",
|
||||
"导入": "Import",
|
||||
"调配截止": "Loadout Deadline",
|
||||
"本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效": "Loadout is locked for this match (it cannot be changed after the briefing); changes will apply next match",
|
||||
"正在载入干员数据(打开页面后仅载入一次)…": "Loading Operator data (only once per page load)…",
|
||||
"干员列表": "Operator List",
|
||||
"导出干员调配": "Export Operator Loadout",
|
||||
"导入干员调配": "Import Operator Loadout",
|
||||
"关闭": "Close",
|
||||
"复制": "Copy",
|
||||
"下载文件": "Download File",
|
||||
"取消": "Cancel",
|
||||
"选择文件": "Choose File",
|
||||
"把导出的内容粘贴到下方,或点「选择文件」。": "Paste exported data below, or tap [Choose File]. ",
|
||||
"覆盖": "overwrite",
|
||||
"在此粘贴导出的调配内容…": "Paste the exported loadout here…",
|
||||
"调整干员携带的技能与模组": "Change the Skills and Modules your Operators carry",
|
||||
"造成伤害": "Damage Dealt",
|
||||
"击倒敌人": "Enemies Defeated",
|
||||
"领袖伤害": "Leader Damage",
|
||||
"盟约层数": "Alliance Stacks",
|
||||
"晋升次数": "Promotions",
|
||||
"配发装备": "Equipment Equipped",
|
||||
"消耗资金": "Funds Spent",
|
||||
"完美作战": "Perfect Battles",
|
||||
"刷新次数": "Refreshes",
|
||||
"未击倒": "Leaks",
|
||||
"损失生命": "LP Lost",
|
||||
"阵容已撤离": "Lineup withdrawn",
|
||||
"获得奖杯": "Trophies earned",
|
||||
"卫戍认证": "Stronghold Certification",
|
||||
"评语": "Title",
|
||||
"最终攻势起全队共享目标生命值": "From the Final Assault on, the whole team shares LP",
|
||||
"同盟剩余生命": "Alliance LP Left",
|
||||
"剩余生命": "LP Left",
|
||||
"通过回合": "Rounds Cleared",
|
||||
"同盟成员": "Alliance Members",
|
||||
"模拟完成": "Simulation Complete",
|
||||
"模拟失败": "Simulation Failed",
|
||||
"成功卫戍 · 敌方领袖已被击败": "Stronghold held · Enemy Leader defeated",
|
||||
"防线已被突破": "The defense line has been breached",
|
||||
"敌方领袖": "Enemy Leader",
|
||||
"隐秘核心": "Hidden Core",
|
||||
"本局耗时": "Match duration:",
|
||||
"分钟": "min",
|
||||
"返回大厅": "Back to Lobby",
|
||||
"暂无结算数据": "No results data",
|
||||
"服务器会话已重置,上一局模拟已结束": "Server session was reset. The previous simulation has ended.",
|
||||
"服务器会话已重置,已返回大厅": "Server session was reset. Returned to the lobby.",
|
||||
"正在查看 {owner} 的盟约": "Viewing {owner}'s Alliances",
|
||||
"{owner} 尚未激活盟约": "{owner} has no active Alliances yet",
|
||||
"部署干员以激活盟约": "Deploy Operators to activate Alliances",
|
||||
"{owner} 的盟约": "{owner}'s Alliances",
|
||||
"我的盟约": "My Alliances",
|
||||
"(含调和 +{harmony})": " (incl. Harmony +{harmony})",
|
||||
"层数叠加已禁用": "Stack accumulation disabled",
|
||||
"调和": "Harmony",
|
||||
"{0} 在场": "{0} on field",
|
||||
"{harmonyName}已激活": "{harmonyName} active",
|
||||
"层数": "Stacks",
|
||||
"盟约效果": "Alliance Effect",
|
||||
"成员": "Members",
|
||||
"{owner} 的盟约:{name}": "{owner}'s Alliance: {name}",
|
||||
"盟约:{name}": "Alliance: {name}",
|
||||
"CORE BOND // 核心盟约": "CORE BOND // Core Alliance",
|
||||
"ADD-ON BOND // 附加盟约": "ADD-ON BOND // Add-on Alliance",
|
||||
"的盟约": "'s Alliance",
|
||||
"在场": "On field",
|
||||
"(含整备区)": "(incl. Bench)",
|
||||
"(含{harmonyName} +{harmony})": "(incl. {harmonyName} +{harmony})",
|
||||
"已激活{0}": "Active{0}",
|
||||
" · {tier} 阶": " · Tier {tier}",
|
||||
"未激活": "Inactive",
|
||||
"本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)": "This Alliance never activates in this mode (its Operators may still appear through their other Alliances)",
|
||||
"名及以下": " or fewer",
|
||||
"当前效果": "Current Effect",
|
||||
"({layers} 层)": "({layers} Stacks)",
|
||||
"(变形同构体:视为本盟约成员)": " (Isomorph: counts as a member of this Alliance)",
|
||||
"(本局禁用)": " (Disabled this match)",
|
||||
"(在场)": " (On field)",
|
||||
"(整备区)": " (Bench)",
|
||||
"同构": "Iso",
|
||||
"机变": "Improv",
|
||||
"机变阶段": "Improv Phase",
|
||||
"CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase",
|
||||
"选择一项": "Choose one",
|
||||
"倒计时结束后仍未选定将自动分配": "If no choice is made before the countdown ends, one will be assigned automatically",
|
||||
"选择一项(无时间限制)": "Choose one (no time limit)",
|
||||
" · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm",
|
||||
"已完成选择": "Choice made",
|
||||
"当前轮到你决策": "Your turn to decide",
|
||||
"确认选择「{armedName}」(再次点击卡牌亦可)": "Confirm [{armedName}] (or tap the card again)",
|
||||
"{name},已选中,再次点击确认": "{name}, selected, tap again to confirm",
|
||||
"{name},{takerName}已选择": "{name}, selected by {takerName}",
|
||||
"全队获得": "Whole team",
|
||||
"赏金 {coin}": "Bounty {coin}",
|
||||
"{name} 已选择": "Selected by {name}",
|
||||
"再次点击": "Tap again",
|
||||
"选择中": "Selecting",
|
||||
"队友作战进度": "Teammate battle progress",
|
||||
"作战结束": "Battle over",
|
||||
"作战结束,等待队友完成作战": "Battle over. Waiting for teammates to finish",
|
||||
"你已被淘汰,正在观战": "You have been eliminated. Spectating",
|
||||
"上一个战场": "Previous battlefield",
|
||||
"下一个战场": "Next battlefield",
|
||||
"你已被淘汰,可点击队友头像前往查看": "You have been eliminated. Tap a teammate's avatar to watch",
|
||||
"返回战场": "Return to Battlefield",
|
||||
"左侧战场": "Left battlefield",
|
||||
"右侧战场": "Right battlefield",
|
||||
"禁用": "Disabled",
|
||||
"无倒计时": "No countdown",
|
||||
"剩余{remain}秒": "{remain}s remaining",
|
||||
"确认": "Confirm",
|
||||
"加载中": "Loading",
|
||||
"当前延迟 {ms}ms": "Current latency: {ms}ms",
|
||||
"未连接": "Not connected",
|
||||
"正在同步同盟状态…": "Syncing Alliance state…",
|
||||
"服务器已更新,本局结束后刷新": "The server was updated. The page will refresh after this match",
|
||||
"刷新页面": "Refresh Page",
|
||||
"与服务器的连接已中断,正在重连": "Connection to the server was lost. Reconnecting",
|
||||
"立即重连": "Reconnect Now",
|
||||
"在此页面继续": "Continue on This Page",
|
||||
"重新连接": "Reconnect",
|
||||
"重试": "Retry",
|
||||
"第 {attempt} 次 · {secs}s": "Attempt {attempt} · {secs}s",
|
||||
"先锋": "Vanguard",
|
||||
"近卫": "Guard",
|
||||
"重装": "Defender",
|
||||
"狙击": "Sniper",
|
||||
"术师": "Caster",
|
||||
"医疗": "Medic",
|
||||
"辅助": "Supporter",
|
||||
"特种": "Specialist",
|
||||
"召唤物": "Summon",
|
||||
"自动回复": "Auto Recovery",
|
||||
"攻击回复": "Offensive Recovery",
|
||||
"受击回复": "Defensive Recovery",
|
||||
"被动": "Passive",
|
||||
"自动触发": "Auto Trigger",
|
||||
"精英": "Elite",
|
||||
"领袖": "Leader",
|
||||
"物理": "Physical",
|
||||
"法术": "Arts",
|
||||
"治疗": "Healing",
|
||||
"真实": "True",
|
||||
"无": "None",
|
||||
"攻击范围": "Attack Range",
|
||||
"全场": "Whole field",
|
||||
"基础 {0}": "Base {0}",
|
||||
"当前作战中的实时数值(绿色为增益,红色为减益)": "Live values in the current battle (green: buffed, red: debuffed)",
|
||||
"下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)": "Values at the start of the next battle: Equipment, Alliance stacks, Attributes, Strategy and Improv effects included (skills and temporary in-battle effects excluded)",
|
||||
"实时": "Live",
|
||||
"开战时": "At battle start",
|
||||
"搭配以下装备时,携带者视为对应盟约的成员:": "When equipped with one of the following, the wearer counts as a member of that Alliance:",
|
||||
"变形同构体对应关系": "Damazti Isomorph pairings",
|
||||
"已搭配": "Paired",
|
||||
"生效中": "Active",
|
||||
"暂未生效:需与上表中的一件装备一同携带": "Not active yet: carry it together with one of the items above",
|
||||
"所属盟约": "Alliances",
|
||||
"(变形同构体)": " (Damazti Isomorph)",
|
||||
",已激活 {tier} 阶": ", active at tier {tier}",
|
||||
",未激活": ", inactive",
|
||||
"特质": "Attribute",
|
||||
"生命上限": "Max HP",
|
||||
"攻击": "ATK",
|
||||
"防御": "DEF",
|
||||
"法术抗性": "RES",
|
||||
"攻击间隔": "Attack Interval",
|
||||
"阻挡数": "Block",
|
||||
"部署费用": "DP Cost",
|
||||
"再部署": "Redeploy Time",
|
||||
"近战位": "Melee",
|
||||
"远程位": "Ranged",
|
||||
"技能 {skSlot}": "Skill {skSlot}",
|
||||
"干员调配中选择的技能": "Skill chosen in Operator Loadout",
|
||||
"已调配": "Customized",
|
||||
"技力": "SP",
|
||||
"· 消耗": "· Cost",
|
||||
"干员调配中选择的模组": "Module chosen in Operator Loadout",
|
||||
"装备": "Equipment",
|
||||
"拖拽装备至该干员以配发(最多 2 件)": "Drag Equipment onto this Operator to equip it (up to 2)",
|
||||
"出售": "Sell",
|
||||
"可晋升:{hint}": "Can promote: {hint}",
|
||||
"效果": "Effect",
|
||||
"进阶": "Elite",
|
||||
"奇术": "Arts",
|
||||
"将其拖拽至战场上的格子使用": "Drag it onto a tile of the battlefield to use it",
|
||||
";2 件相同装备自动合成进阶装备": "; 2 identical pieces merge into Elite Equipment",
|
||||
"效果获得": "gained from effects",
|
||||
"晕眩": "Stun",
|
||||
"沉默": "Silence",
|
||||
"沉睡": "Sleep",
|
||||
"浮空": "Levitate",
|
||||
"移动速度": "Move Speed",
|
||||
"目标价值": "LP Damage",
|
||||
"空中": "Aerial",
|
||||
"地面": "Ground",
|
||||
"近战": "Melee",
|
||||
"能力": "Abilities",
|
||||
"说明": "Description",
|
||||
"作战开始时在摆放的位置部署": "Deploys on its placed tile when the battle starts",
|
||||
"作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)": "Deploys once on its placed tile when the battle starts, then reappears each time its Operator activates a skill (does not appear if not placed)",
|
||||
"所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)": "Appears on its placed tile only when its Operator activates a skill (does not appear if not placed)",
|
||||
"出售精锐干员": "Sell Elite Operator",
|
||||
"确定要出售精锐干员「{0}」吗?出售后获得 {1} 资金。": "Sell the Elite Operator \"{0}\"? You will get {1} Funds.",
|
||||
"详情": "Details",
|
||||
"退出全屏": "Exit Fullscreen",
|
||||
"全屏": "Fullscreen",
|
||||
"团队增益": "Team Buff",
|
||||
"生效中的效果": "Active effects",
|
||||
"表情": "Emote",
|
||||
"交流": "Chat",
|
||||
"上一组表情": "Previous emotes",
|
||||
"下一组表情": "Next emotes",
|
||||
"表情主题": "Emote themes",
|
||||
"悬赏": "Bounty",
|
||||
"护卫": "Escort",
|
||||
"回合 · 即将迎击": "·",
|
||||
"名敌人": "incoming enemies",
|
||||
"暂无敌方情报": "No enemy intel yet",
|
||||
"本局禁用干员情况": "Banned Operators this match",
|
||||
"我的策略": "My Strategy",
|
||||
"战场:": "Battlefield:",
|
||||
"该盟约中被禁用的干员数": "Banned Operators in this Alliance",
|
||||
"本局没有禁用盟约": "No Alliances banned this match",
|
||||
"本局信息": "Match Info",
|
||||
"敌方情报": "Enemy Intel",
|
||||
"该干员": "This Operator",
|
||||
"将被销毁": "Will be destroyed",
|
||||
"替换装备": "Replace Equipment",
|
||||
"」的装备栏已满(2/2)。选择一件装备进行替换,": "]'s Equipment slots are full (2/2). Choose a piece of Equipment to replace.",
|
||||
"被替换的装备将被销毁": "The replaced Equipment will be destroyed",
|
||||
"新装备": "New Equipment",
|
||||
"选择被替换的装备": "Choose Equipment to replace",
|
||||
"将销毁「": "Destroy [",
|
||||
"」并配发「": "] and equip [",
|
||||
"点击选择要替换的装备": "Tap to choose the Equipment to replace",
|
||||
"上": "Up",
|
||||
"右": "Right",
|
||||
"下": "Down",
|
||||
"左": "Left",
|
||||
"选择{0}朝向": "Choose facing{0}",
|
||||
"「{name}」的": " for [{name}]",
|
||||
"点击取消": "Tap to cancel",
|
||||
"拖回中心区域取消": "Drag back to center to cancel",
|
||||
"朝向:{0}": "Facing: {0}",
|
||||
"干员": "Operator",
|
||||
"整备区": "Bench",
|
||||
"召唤": "Summon",
|
||||
"目标生命值": "LP",
|
||||
"结算时扣除 {p}": "-{p} at settlement",
|
||||
"已淘汰": "Eliminated",
|
||||
"已离开": "Left",
|
||||
"{name}:本局禁用(该盟约不会激活)": "{name}: Disabled this match (this Alliance will not activate)",
|
||||
"本局禁用{0}盟约,此策略效果可能无法发挥": "{0} Alliance disabled this match; this Strategy's effect may not work",
|
||||
"行动中": "Acting",
|
||||
"作战中": "In Combat",
|
||||
"联防中": "Uniting",
|
||||
"未装备模组": "No Module",
|
||||
"协议启动": "Protocol Start",
|
||||
"模拟即将开始": "The simulation is about to begin",
|
||||
"第 {r} 回合": "Round {r}",
|
||||
"资金已到账": "Funds received",
|
||||
"依次选择机变": "Choose Improv in turn",
|
||||
"休整期": "Rest Phase",
|
||||
"部署干员,准备迎敌": "Deploy Operators and prepare for the enemy",
|
||||
"作战开始": "Battle Start",
|
||||
"各自行动阶段": "Individual action phase",
|
||||
"联防阶段": "Unite Phase",
|
||||
"联防:{0}": "Unite: {0}",
|
||||
"完美作战的博士迎战突破防线的敌人": "Doctors with a flawless battle take on the enemies that broke through",
|
||||
"最终攻势": "Final Assault",
|
||||
"击败敌方领袖": "Defeat the Enemy Leader",
|
||||
"被源石侵蚀的假想敌": "An OpFor eroded by Originium",
|
||||
"回合开始": "Round Start",
|
||||
"回合结算": "Round Settlement",
|
||||
"休息一下": "Take a Break",
|
||||
"找不到该单位": "Unit not found",
|
||||
"无法放置在该位置": "Cannot place here",
|
||||
"位置未变化": "Position unchanged",
|
||||
"该道具需要放置在战场上使用": "This Item must be placed on the Battlefield to use",
|
||||
"交换后的单位无法部署在原位置": "The swapped unit cannot be deployed at the original position",
|
||||
"请将装备拖拽至干员身上": "Drag Equipment onto an Operator",
|
||||
"近战单位只能部署在地面": "Melee units can only be deployed on ground tiles",
|
||||
"只能部署在召唤者攻击范围内": "Can only be deployed within the summoner's Attack Range",
|
||||
"该位置已有单位": "This tile is already occupied",
|
||||
"召唤者尚未部署": "The summoner is not deployed yet",
|
||||
"装备只能配发给干员": "Equipment can only be equipped on Operators",
|
||||
"临时整备区无法放入单位": "Units cannot be placed on the Temporary Bench",
|
||||
"之后还有 {queued} 项": "{queued} more to come",
|
||||
"晋升奖励": "Promotion Reward",
|
||||
"免费选择 1 名": "Choose 1 Operator for free",
|
||||
"晋升奖励待选择": "Promotion Reward pending",
|
||||
"装备补给": "Equipment Supply",
|
||||
"特殊招募": "Special Recruit",
|
||||
"免费选择 1 件": "Choose 1 Item for free",
|
||||
"{title}待选择": "{title} pending",
|
||||
"尚未就绪": "Not ready yet",
|
||||
"临时整备区不为空,请先处理溢出的资源": "The Temporary Bench is not empty; deal with the overflow first",
|
||||
"当前无法选择": "Cannot choose right now",
|
||||
"已准备就绪,取消准备后才能操作": "You are Ready; cancel Ready to make changes",
|
||||
"该队友已被淘汰,无法查看其阵地": "This Teammate has been eliminated; their Field cannot be viewed",
|
||||
"自己": "Self",
|
||||
"联防(自己)": "Unite (Self)",
|
||||
"联防阵地": "Unite Field",
|
||||
"领袖战场": "Leader Battlefield",
|
||||
"基础规则": "Basic Rules",
|
||||
"卫戍协议已运行": "Stronghold Protocol Online",
|
||||
"攻防战": "Attack and Defense",
|
||||
"休整期 · 区域": "Rest Phase · Areas",
|
||||
"休整期 · 资金与调度": "Rest Phase · Funds and Dispatch",
|
||||
"限时战斗": "Timed Battle",
|
||||
"作战期": "Battle Phase",
|
||||
"协同战斗": "Co-op Battle",
|
||||
"调度手册": "Dispatch Handbook",
|
||||
"干员晋级": "Operator Promotion",
|
||||
"加成情况": "Bonuses",
|
||||
"卫戍能力": "Stronghold Abilities",
|
||||
"助战及自选编队": "Support and Custom Squads",
|
||||
"进阶图鉴": "Advanced Handbook",
|
||||
"敌人类型": "Enemy Types",
|
||||
"盟约激活与叠加": "Alliance Activation and Stacking",
|
||||
"追加盟约": "Add-on Alliances",
|
||||
"策略与轮选": "Strategies and Draft",
|
||||
"TIPS // 模拟要点": "TIPS // SIMULATION TIPS",
|
||||
"暂无说明内容": "No guide content available",
|
||||
"关闭 (Esc)": "Close (Esc)",
|
||||
"章节": "Chapters",
|
||||
"上一页": "Previous page",
|
||||
"下一页": "Next page",
|
||||
"页码": "Pages",
|
||||
"联防": "Unite",
|
||||
"{name} 漏过的敌人": "Enemies that got past {name}",
|
||||
"你漏过的敌人": "Enemies that got past you",
|
||||
"队友正在迎战": "teammates are engaging",
|
||||
"{who}已全部被击倒": "{who}: all defeated",
|
||||
"剩余敌人 {v}": "Enemies remaining: {v}",
|
||||
"剩余不足 {cap} 个后,队友每击倒一个少扣 1 点": "once fewer than {cap} remain, each one your teammates defeat costs 1 less",
|
||||
"队友每击倒一个就少扣 1 点": "each one your teammates defeat costs 1 less",
|
||||
"目标生命值 {lp}:联防中,你漏过的敌人还剩 {left} 个,{each};按现在结算扣除 {pending} 点(每回合至多 {cap} 点)": "LP {lp}: Unite in progress, {left} enemies that got past you remain; {each}. Settling now would cost {pending} LP (max {cap} per round)",
|
||||
"目标生命值 {lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 {pending} 点)": "LP {lp}: Unite in progress, teammates are engaging the enemies that got past you; the loss at settlement depends on the enemies left after Unite (max {pending} LP)",
|
||||
"{cap} 个以上": "{cap}+",
|
||||
"{pending} 个": "{pending}",
|
||||
"放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁": "Move them to the Bench or Battlefield, equip or use them before you can Ready; units still on the Temporary Bench when the Rest Phase ends will be destroyed",
|
||||
"临时整备区还有 {count} 个单位:{TEMP_RULE}": "{count} unit(s) still on the Temporary Bench: {TEMP_RULE}",
|
||||
"取消准备": "Cancel Ready",
|
||||
"临时整备区": "Temporary Bench:",
|
||||
"个单位待处理": "unit(s) pending",
|
||||
"本局信息(策略 / 禁用盟约 / 干员)": "Match Info (Strategies / Banned Alliances / Operators)",
|
||||
"查看即将迎击的敌方单位": "View the enemies you are about to face",
|
||||
"休整期可以查看即将迎击的敌方单位": "Upcoming enemies can be viewed during the Rest Phase",
|
||||
"秒后全队生命值开始流失": "s",
|
||||
"超时 · 生命值": "Overtime · LP",
|
||||
"/秒": "/s",
|
||||
"已流失": "Lost",
|
||||
"暂停": "Pause",
|
||||
"继续作战(Space)": "Resume Battle (Space)",
|
||||
"暂停作战(Space)": "Pause Battle (Space)",
|
||||
"离开 / 暂离": "Leave / Step Away",
|
||||
"部署费用(再部署消耗)": "DP Cost (spent on redeploy)",
|
||||
"攻击力": "ATK",
|
||||
"防御力": "DEF",
|
||||
"攻击速度": "ASPD",
|
||||
"再部署时间": "Redeploy Time",
|
||||
"秒": "s",
|
||||
"每秒回复": "HP Regen/s",
|
||||
"内容过长,无法导入": "Content too long to import",
|
||||
"没有可导入的内容": "Nothing to import",
|
||||
"无法识别的内容": "Unrecognized content",
|
||||
"无法识别的格式": "Unrecognized format",
|
||||
"这不是干员调配的数据": "This is not Operator Loadout data",
|
||||
"里面没有有效的调配条目": "It contains no valid loadout entries",
|
||||
"弹药": "Ammo",
|
||||
"{0}秒": "{0}s",
|
||||
"本局的干员调配已锁定,修改将在下一局生效": "Operator loadout is locked for this match; changes will apply next match",
|
||||
"独立模拟的休整期没有时间限制,你可以随时继续。": "The Rest Phase in Solo Simulation has no time limit. You can continue at any time.",
|
||||
"放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。": "Abandoning the simulation ends this match immediately and returns you to the lobby. Progress will not be kept and no results will be tallied.",
|
||||
"离开模拟": "Leave Simulation",
|
||||
"暂离(AI 托管)": "Step Away (AI Autopilot)",
|
||||
"暂离": "Step Away",
|
||||
":由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。": ": AI takes over your seat (auto deploy, ready-up and choices). You can return at any time.",
|
||||
":离开后无法返回本局,你的干员将回到共享卡池。": ": You cannot return to this match after leaving, and your Operators go back to the shared pool.",
|
||||
"AI 托管中": "AI Autopilot active",
|
||||
"AUTOPILOT // AI 托管": "AUTOPILOT // AI AUTOPILOT",
|
||||
"AI 正在代为操作你的席位": "AI is playing your seat for you",
|
||||
"返回模拟": "Return to Simulation",
|
||||
"该盟约中无法出现的干员数": "Operators of this Alliance that cannot appear",
|
||||
",或本模式禁用": ", or disabled in this mode",
|
||||
"本局禁用干员": "Banned Operators",
|
||||
"本局没有禁用干员": "No Operators are banned this match",
|
||||
"核心盟约": "Core Alliances",
|
||||
"附加盟约": "Add-on Alliances",
|
||||
"折扣价(原价 {basePrice})": "Discounted (was {basePrice})",
|
||||
"加价(原价 {basePrice})": "Marked up (was {basePrice})",
|
||||
"价格": "Price",
|
||||
"精锐干员将出现在作战区原位置": "The Elite Operator will take the original spot on the Battlefield",
|
||||
"精锐干员将进入整备区": "The Elite Operator will go to the Bench",
|
||||
"无法购买": "Cannot Buy",
|
||||
"确认购买": "Confirm Purchase",
|
||||
",无法购买": ", cannot buy",
|
||||
",再次点击确认": ", tap again to confirm",
|
||||
"已拥有 {copies}/{need}": "Owned {copies}/{need}",
|
||||
"可晋升": "Promotable",
|
||||
"技能{0}:{1}{2}{3}": "Skill{0}: {1}{2}{3}",
|
||||
"(已调配)": " (Assigned)",
|
||||
" · 模组:{name}": " · Module: {name}",
|
||||
" · 未装备模组": " · No Module equipped",
|
||||
"已购买": "Purchased",
|
||||
"已招募": "Recruited",
|
||||
"再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
|
||||
"升级调度中心({price} 资金) · D": "Upgrade Dispatch Center ({price} Funds) · D",
|
||||
"已满级": "Max Level",
|
||||
"确认升级": "Confirm Upgrade",
|
||||
"稍后选择(回合结束后消失)": "Pick later (expires at the end of the round)",
|
||||
"稍后": "Later",
|
||||
"展开商店": "Expand Shop",
|
||||
"调度中心": "Dispatch Center",
|
||||
"剩余可放置角色:": "Deployable units left:",
|
||||
"目前资金": "Current Funds",
|
||||
"收起": "Collapse",
|
||||
"解冻商店 · F": "Unfreeze Shop · F",
|
||||
"冻结商店(下回合保留) · F": "Freeze Shop (kept next round) · F",
|
||||
"解冻": "Unfreeze",
|
||||
"刷新商店 · R": "Refresh Shop · R",
|
||||
"免费 ×{free}": "Free ×{free}",
|
||||
"联防中,": "In Unite; ",
|
||||
"至多": "up to ",
|
||||
"查看 {name} 的战场": "View {name}'s Battlefield",
|
||||
"查看自己的阵地": "View your Field",
|
||||
"查看 {name} 的阵地": "View {name}'s Field",
|
||||
"连接已断开": "Disconnected",
|
||||
"前往查看": "Go View",
|
||||
"{0} 操作": "{0} actions",
|
||||
"单位": "Unit",
|
||||
"撤退": "Retreat",
|
||||
"撤退至整备区": "Retreat to Bench",
|
||||
"出售,获得 {sell} 资金": "Sell for {sell} Funds",
|
||||
"已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)": "Units that arrived after Ready are kept until the next Rest Phase; anything still here then will be destroyed (cancel Ready and they are destroyed at the end of this Rest Phase)",
|
||||
"放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁": "Move to the Bench or Battlefield, equip or use them before you can Ready; anything still here at the end of the Rest Phase will be destroyed",
|
||||
"件道具": "item(s)",
|
||||
"个单位": "unit(s)",
|
||||
"{what}待处理": "{what} pending",
|
||||
"结算": "Settlement",
|
||||
"模拟结束": "Simulation Over",
|
||||
"表情套组:卫戍协议": "Emote Set: Stronghold Protocol",
|
||||
"开心": "Happy",
|
||||
"害怕": "Scared",
|
||||
"对不起": "Sorry",
|
||||
"谢谢": "Thanks",
|
||||
"思考": "Thinking",
|
||||
"合作愉快": "Nice teamwork",
|
||||
"表情套组:虫动": "Emote Set: Slug Moves",
|
||||
"合作愉快!": "Nice teamwork!",
|
||||
"谢谢!": "Thanks!",
|
||||
"对不起!": "Sorry!",
|
||||
"再见!": "Bye!",
|
||||
"很快就好!": "Almost done!",
|
||||
"没问题!": "No problem!",
|
||||
"敬礼!": "Salute!",
|
||||
"欢呼!": "Cheers!",
|
||||
"酷!": "Cool!",
|
||||
"伤心": "Sad",
|
||||
"快死了": "Dying",
|
||||
"表情套组:博士士": "Emote Set: Doctor-tor",
|
||||
"博士士 1": "Doctor-tor 1",
|
||||
"博士士 2": "Doctor-tor 2",
|
||||
"博士士 3": "Doctor-tor 3",
|
||||
"博士士 4": "Doctor-tor 4",
|
||||
"博士士 5": "Doctor-tor 5",
|
||||
"博士士 6": "Doctor-tor 6",
|
||||
"表情套组:米米子": "Emote Set: Mimiko",
|
||||
"米米子 1": "Mimiko 1",
|
||||
"米米子 2": "Mimiko 2",
|
||||
"米米子 3": "Mimiko 3",
|
||||
"米米子 4": "Mimiko 4",
|
||||
"米米子 5": "Mimiko 5",
|
||||
"米米子 6": "Mimiko 6",
|
||||
"表情套组:维维美": "Emote Set: Wiwime",
|
||||
"维维美 1": "Wiwime 1",
|
||||
"维维美 2": "Wiwime 2",
|
||||
"维维美 3": "Wiwime 3",
|
||||
"维维美 4": "Wiwime 4",
|
||||
"维维美 5": "Wiwime 5",
|
||||
"维维美 6": "Wiwime 6",
|
||||
"<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>{status} · 卫戍协议:盟约</title><style>\n:root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111614;color:#d8e3de;font:16px/1.6 \"Noto Sans SC\",system-ui,sans-serif}\nmain{border:1px solid #2c3a35;padding:32px 40px;max-width:520px;text-align:center}h1{margin:0;color:#4ed8af;font-size:56px;letter-spacing:4px}\np{margin:8px 0}a{color:#4ed8af}</style></head><body><main><h1>{status}</h1><p>{2}</p>\n{3}<p><a href=\"/\">返回首页 · Back to home</a></p></main></body></html>": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>{status} · Stronghold Protocol: Alliance</title><style>\n:root{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111614;color:#d8e3de;font:16px/1.6 \"Noto Sans SC\",system-ui,sans-serif}\nmain{border:1px solid #2c3a35;padding:32px 40px;max-width:520px;text-align:center}h1{margin:0;color:#4ed8af;font-size:56px;letter-spacing:4px}\np{margin:8px 0}a{color:#4ed8af}</style></head><body><main><h1>{status}</h1><p>{2}</p>\n{3}<p><a href=\"/\">Back to home</a></p></main></body></html>",
|
||||
"页面不存在 · Not found": "Not found",
|
||||
"禁止访问 · Forbidden": "Forbidden",
|
||||
"请求地址过长 · URI too long": "URI too long",
|
||||
"请求地址无效 · Bad request": "Bad request",
|
||||
"不支持的请求方法 · Method not allowed": "Method not allowed",
|
||||
"服务器内部错误 · Internal error": "Internal error",
|
||||
"AI·华法琳": "AI·Warfarin",
|
||||
"AI·阿米娅": "AI·Amiya",
|
||||
"AI·惊蛰": "AI·Leizi",
|
||||
"AI·杜宾": "AI·Dobermann",
|
||||
"AI·凯尔希": "AI·Kal'tsit",
|
||||
"AI·可露希尔": "AI·Closure",
|
||||
"对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。": "Match core not implemented (STUB): the match settles once everyone confirms or the countdown ends.",
|
||||
"精打细算玩偶": "Budget-Savvy Doll",
|
||||
"悬赏决策": "Bounty Decision",
|
||||
"道具补给": "Item Supply",
|
||||
"机密商店": "Secret Shop",
|
||||
"战术决策": "Tactical Decision",
|
||||
"隐秘核心已解锁": "Hidden Core unlocked",
|
||||
"{name}博士中途退出了模拟": "Dr. {name} left the simulation",
|
||||
"你的目标生命值耗尽,已被淘汰": "Your LP ran out. You have been eliminated",
|
||||
"{name}博士的目标生命值已耗尽": "Dr. {name} has run out of LP",
|
||||
"整备区已满,获得的干员已返还": "Bench is full. The obtained Operator was returned",
|
||||
"整备区已满,晋升的精锐干员无法放入": "Bench is full. The promoted Elite Operator cannot be placed",
|
||||
"整备区已满,获得的装备已销毁": "Bench is full. The obtained Equipment was destroyed",
|
||||
"整备区已满,合成的装备已销毁": "Bench is full. The combined Equipment was destroyed",
|
||||
"发生意外错误:{error}": "Unexpected error: {error}",
|
||||
"战场#{no}": "Battlefield #{no}",
|
||||
"与至多 {n} 名博士组成同盟,共享干员池,联防协作抵御敌潮。": "Form an Alliance with up to {n} other Doctors, share the Operator pool, and Unite to hold back the enemy tide.",
|
||||
"1–{n} 名博士 · 可由 AI 队友补位": "1–{n} Doctors · AI Teammates can fill empty seats",
|
||||
"没有找到密钥 {k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)": "No Alliance found for the key {k}: check it with the host (a key expires when its Alliance ends)",
|
||||
"你已经是该同盟的博士:先离开同盟,才能以观战身份进入": "You are already a Doctor in this Alliance: leave it first to enter as a spectator",
|
||||
"以观战者身份进入:不占博士席位,只能观看(每个同盟最多 {MAX_SPECTATORS} 名,模拟进行中也可进入)": "Enter as a spectator: no Doctor seat is taken and you can only watch (up to {MAX_SPECTATORS} per Alliance, also while the simulation runs)",
|
||||
"填入密钥(不会直接加入)": "Fill in the key (does not join yet)",
|
||||
"将该博士移出同盟": "Remove this Doctor from the Alliance",
|
||||
"移出该博士": "Remove this Doctor",
|
||||
"观战席": "Spectators",
|
||||
"移出观战者 {name}": "Remove spectator {name}",
|
||||
"移出该观战者": "Remove this spectator",
|
||||
"入座": "Take a Seat",
|
||||
"移出同盟": "Remove from Alliance",
|
||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
|
||||
"移出": "Remove",
|
||||
"观战中 · 不占博士席位,模拟开始后可切换观看各位博士": "Spectating · no Doctor seat taken; once the simulation starts you can switch between the Doctors' views",
|
||||
"共": "Total:",
|
||||
"观战中": "Spectating",
|
||||
"toggle::开启": "On",
|
||||
"toggle::关闭": "Off",
|
||||
"撤退选中干员": "Retreat selected Operator",
|
||||
"出售选中干员": "Sell selected Operator",
|
||||
"关闭弹窗": "Close window",
|
||||
"语言": "Language",
|
||||
"右键查看详情": "Right-click for details",
|
||||
"观战中无法进行该操作": "Spectators cannot do that",
|
||||
"标准": "Standard",
|
||||
"险境": "Perilous",
|
||||
"绝境": "Dire",
|
||||
"终极": "Ultimate",
|
||||
"rounds::回合": "Rounds",
|
||||
"{who}:获得{name}": "{who}: obtained {name}",
|
||||
"获得{name}": "Obtained {name}",
|
||||
"联防阶段:{names} 迎战突破防线的敌人": "Unite Phase: {names} take on the enemies that broke through",
|
||||
"地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench",
|
||||
"{names}只能部署在召唤者攻击范围内,已退回整备区": "{names} can only be deployed within the summoner's attack range and returned to the Bench",
|
||||
"{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还": "{names} can only be deployed within the summoner's attack range; the Bench is full, so they come back next round",
|
||||
"【{label}】层数达成,下回合开始时获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds at the start of the next round",
|
||||
"【{label}】层数达成,获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds",
|
||||
"【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
|
||||
"【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}",
|
||||
"【远见】所有干员购买价格永久降低": "[Foresight] Every Operator's purchase price is permanently reduced",
|
||||
"【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced"
|
||||
}
|
||||
+95
-3
@@ -17,8 +17,16 @@
|
||||
// - or either of those wrapped as { <name>: … } / { list: … } / { data: … }.
|
||||
// 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).
|
||||
|
||||
import { useEffect, useReducer } from '../vendor/hooks.module.js';
|
||||
import { applyFileOverlay } from '../../shared/i18nData.js';
|
||||
|
||||
/** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */
|
||||
export const DATA_FILES = Object.freeze({
|
||||
@@ -124,6 +132,14 @@ export function createDataStore(opts = {}) {
|
||||
const entries = new Map();
|
||||
const listeners = new Set();
|
||||
const warned = new Set();
|
||||
/** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */
|
||||
let locale = 'zh';
|
||||
/** @type {Map<string, Promise<any>>} */
|
||||
const overlayLoads = new Map();
|
||||
/** @type {Map<string, any>} */
|
||||
const overlays = new Map();
|
||||
/** name → { lang, value, index } localized copy of a ready file (dropped on every switch / reload of the file) */
|
||||
const localized = new Map();
|
||||
|
||||
const notify = (name) => {
|
||||
for (const fn of [...listeners]) {
|
||||
@@ -221,20 +237,67 @@ export function createDataStore(opts = {}) {
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** The localized copy of a ready file (null in Chinese or without an overlay for the file). */
|
||||
function localizedEntry(name) {
|
||||
if (locale === 'zh') return null;
|
||||
const e = entries.get(name);
|
||||
const fileOverlay = overlays.get(locale)?.files?.[name];
|
||||
if (!e || e.status !== 'ready' || !fileOverlay) 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); }
|
||||
l = { lang: locale, base: e.value, value, index: null };
|
||||
localized.set(name, l);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
function index(name) {
|
||||
const e = entries.get(name);
|
||||
if (!e || e.status !== 'ready') return null;
|
||||
const l = localizedEntry(name);
|
||||
if (l) {
|
||||
if (!l.index) l.index = buildIndex(name, l.value);
|
||||
return l.index;
|
||||
}
|
||||
if (!e.index) e.index = buildIndex(name, e.value);
|
||||
return e.index;
|
||||
}
|
||||
|
||||
/** Download (once per language) the overlay data/i18n/<lang>.json; resolves to it, or null when unavailable. */
|
||||
function loadOverlay(lang) {
|
||||
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' });
|
||||
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 });
|
||||
overlays.set(lang, json);
|
||||
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`);
|
||||
overlayLoads.delete(lang); // a later switch may try again
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
overlayLoads.set(lang, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Fetch (once) and return a file's JSON, or null when missing. */
|
||||
load,
|
||||
/** Load several files; resolves when all settled. */
|
||||
loadAll: (...names) => Promise.all(names.flat().map(load)),
|
||||
/** Raw JSON of a loaded file (null when missing / not loaded yet). */
|
||||
get: (name) => entries.get(name)?.value ?? null,
|
||||
/** JSON of a loaded file in the current locale (null when missing / not loaded yet). */
|
||||
get: (name) => localizedEntry(name)?.value ?? entries.get(name)?.value ?? null,
|
||||
/** The file exactly as downloaded (Chinese texts), whatever the locale. */
|
||||
getRaw: (name) => entries.get(name)?.value ?? null,
|
||||
/** 'idle' | 'loading' | 'ready' | 'missing' */
|
||||
status: (name) => entries.get(name)?.status ?? 'idle',
|
||||
/** Record by id from a loaded file (null when unknown / not loaded). */
|
||||
@@ -248,16 +311,45 @@ export function createDataStore(opts = {}) {
|
||||
invalidate(name) {
|
||||
if (!entries.has(name)) return Promise.resolve(null);
|
||||
entries.delete(name);
|
||||
localized.delete(name);
|
||||
warned.delete(name);
|
||||
const p = load(name);
|
||||
notify(name); // status is 'loading' again, never a stuck 'idle'
|
||||
return p;
|
||||
},
|
||||
/** Called with the file name whenever a file finishes loading (or is invalidated). */
|
||||
/** Called with the file name whenever a file finishes loading (or is invalidated, or the locale changes). */
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
/** Current game-text locale ('zh' or a language whose overlay is applied). */
|
||||
locale: () => locale,
|
||||
/**
|
||||
* 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.
|
||||
* @param {string} lang
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
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;
|
||||
locale = want;
|
||||
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).
|
||||
* @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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+27
-16
@@ -24,6 +24,9 @@
|
||||
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise).
|
||||
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
|
||||
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
|
||||
// Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang).
|
||||
// Server texts are translated on arrival: m.toast / m.ticker frames (msgid + params, or the text as a msgid; a
|
||||
// config.broadcasts line from its id + args), error codes (ui/toasts.js describeError) and room.closed reasons.
|
||||
|
||||
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
|
||||
import './ui/compat.js';
|
||||
@@ -47,6 +50,8 @@ import { installDeviceSupport } from './ui/device.js';
|
||||
import { LoadoutHost } from './screens/loadout.js';
|
||||
import { installLoadoutSync, installOwnershipSync } from './ui/loadoutSync.js';
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||
import { t, N_, translateWire } from '../../shared/i18n.js';
|
||||
|
||||
const RESTORE_GRACE_MS = 1500;
|
||||
const JOIN_DELAY_MS = 350;
|
||||
@@ -93,7 +98,7 @@ function schedulePendingJoin() {
|
||||
const code = s.ui.pendingJoin;
|
||||
if (!code || joinInFlight || !s.session.entered || net.status !== 'online') return;
|
||||
if (s.room) {
|
||||
if (s.room.code !== code) toast('你已在其他同盟中,请先离开当前同盟', 'warn');
|
||||
if (s.room.code !== code) toast(t('你已在其他同盟中,请先离开当前同盟'), 'warn');
|
||||
clearPendingJoin();
|
||||
return;
|
||||
}
|
||||
@@ -147,7 +152,7 @@ function onWelcome(msg) {
|
||||
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
|
||||
const notice = sessionResetNotice(prev, msg.playerId);
|
||||
backToLobby();
|
||||
if (notice) toast(notice, 'warn', { ttl: 7000 });
|
||||
if (notice) toast(t(notice), 'warn', { ttl: 7000 });
|
||||
} else if (prev.room || prev.match.public) {
|
||||
// Resumed session: the server re-pushes room/match state; drop whatever it doesn't.
|
||||
store.patch('ui', { restoring: true });
|
||||
@@ -171,7 +176,7 @@ function onRoomState(msg) {
|
||||
const seats = Array.isArray(room.seats) ? room.seats : [];
|
||||
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
|
||||
// We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
|
||||
if (store.get().room) toast('你已不在该同盟中', 'warn');
|
||||
if (store.get().room) toast(t('你已不在该同盟中'), 'warn');
|
||||
store.set({ room: null, match: emptyMatch() });
|
||||
return;
|
||||
}
|
||||
@@ -185,8 +190,8 @@ function onRoomState(msg) {
|
||||
|
||||
const CLOSE_REASON = {
|
||||
// 'timeout' = this player was removed after staying disconnected past the lobby grace (server/lobby.js)
|
||||
host_left: '创建者已离开,同盟已解散', timeout: '由于长时间断开连接,你已离开同盟', empty: '同盟已解散',
|
||||
kicked: '你已被移出同盟', ended: '模拟已结束', expired: '同盟已过期', shutdown: '服务器维护中,同盟已关闭',
|
||||
host_left: N_('创建者已离开,同盟已解散'), timeout: N_('由于长时间断开连接,你已离开同盟'), empty: N_('同盟已解散'),
|
||||
kicked: N_('你已被移出同盟'), ended: N_('模拟已结束'), expired: N_('同盟已过期'), shutdown: N_('服务器维护中,同盟已关闭'),
|
||||
};
|
||||
|
||||
function wireNet() {
|
||||
@@ -202,12 +207,13 @@ function wireNet() {
|
||||
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
|
||||
net.on('welcome', onWelcome);
|
||||
net.on('helloError', (err) => toastError(err));
|
||||
net.on('replaced', () => toast('该身份已在其他页面登录,本页已断开', 'warn', { ttl: 6000 }));
|
||||
net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
|
||||
net.on('unhandledError', (err) => toastError(err));
|
||||
net.on('room.state', onRoomState);
|
||||
net.on('room.closed', (msg) => {
|
||||
backToLobby();
|
||||
toast(CLOSE_REASON[msg.reason] || (typeof msg.reason === 'string' && msg.reason.length < 60 ? `同盟已关闭:${msg.reason}` : '同盟已关闭'), 'warn');
|
||||
const known = Object.hasOwn(CLOSE_REASON, String(msg.reason)) ? CLOSE_REASON[msg.reason] : null;
|
||||
toast(known ? t(known) : typeof msg.reason === 'string' && msg.reason.length < 60 ? t('同盟已关闭:{reason}', { reason: msg.reason }) : t('同盟已关闭'), 'warn');
|
||||
});
|
||||
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
|
||||
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
|
||||
@@ -215,17 +221,19 @@ function wireNet() {
|
||||
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
|
||||
net.on('m.toast', (msg) => {
|
||||
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
|
||||
toast(msg.text, kind);
|
||||
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
|
||||
toast(translateWire(msg), kind);
|
||||
});
|
||||
net.on('m.ticker', (msg) => {
|
||||
if (typeof msg.text !== 'string') return;
|
||||
const text = tickerText(msg);
|
||||
// type, player + the round it came in: a BOSS_HIT line is dropped once its boss round is over and superseded by the
|
||||
// same player's next one (ui/ticker.js tickerLineLive / tickerSupersedes)
|
||||
const type = typeof msg.type === 'string' ? msg.type : null;
|
||||
const playerId = typeof msg.playerId === 'string' ? msg.playerId : null;
|
||||
// its broadcast priority: the strip plays the highest first (ui/ticker.js enqueueTickerLines)
|
||||
const priority = Number.isFinite(msg.priority) ? msg.priority : 0;
|
||||
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
|
||||
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
|
||||
});
|
||||
net.on('m.emote', (msg) => {
|
||||
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
|
||||
@@ -258,15 +266,16 @@ function ScreenCrashed({ error, reset }) {
|
||||
return html`<div class="screen crash">
|
||||
<div class="crash__box brackets">
|
||||
<${MicroLabel} tone="mint">SYSTEM FAULT<//>
|
||||
<h2>界面发生错误</h2>
|
||||
<h2>${t('界面发生错误')}</h2>
|
||||
<p class="t-lo">${String(error?.message || error).slice(0, 200)}</p>
|
||||
<${Button} variant="primary" icon="refresh" onClick=${reset}>重新加载界面<//>
|
||||
<${Button} variant="primary" icon="refresh" onClick=${reset}>${t('重新加载界面')}<//>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const route = useStore(selectRoute);
|
||||
useLang(); // a language switch re-renders the whole tree in place
|
||||
const [error, resetError] = useErrorBoundary((err) => console.error('[ui] screen crashed', err));
|
||||
const Screen = SCREENS[route] || LobbyScreen;
|
||||
return html`<div class="app-root">
|
||||
@@ -286,8 +295,8 @@ async function waitForFonts(ms) {
|
||||
const fonts = document.fonts;
|
||||
if (!fonts || typeof fonts.load !== 'function') return;
|
||||
const loads = [
|
||||
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'),
|
||||
fonts.load('700 1em "Noto Sans SC"', '开始'),
|
||||
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'), // i18n-ignore (font sample)
|
||||
fonts.load('700 1em "Noto Sans SC"', '开始'), // i18n-ignore (font sample)
|
||||
fonts.load('700 1em Bender', '0123456789'),
|
||||
fonts.load('700 1em Rajdhani', '0123456789'),
|
||||
].map((p) => p.catch(() => null));
|
||||
@@ -302,7 +311,7 @@ function installGlobalErrorHandlers() {
|
||||
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
|
||||
console.error('[app] unhandled rejection', err);
|
||||
if (err instanceof NetError) toastError(err);
|
||||
else toast(`发生意外错误:${describeError(err)}`.slice(0, 120), 'error');
|
||||
else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
|
||||
});
|
||||
window.addEventListener('error', (ev) => {
|
||||
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
|
||||
@@ -344,7 +353,9 @@ async function boot() {
|
||||
if (entered) net.setName(savedName);
|
||||
else net.connect();
|
||||
});
|
||||
await Promise.all([waitForFonts(1200), connectWhenReady]);
|
||||
// the language (and its UI translations) before the first render: no Chinese flash for an English player
|
||||
const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err));
|
||||
await Promise.all([waitForFonts(1200), connectWhenReady, langReady]);
|
||||
const root = document.getElementById('app');
|
||||
render(html`<${App} />`, root);
|
||||
|
||||
@@ -371,5 +382,5 @@ async function boot() {
|
||||
boot().catch((err) => {
|
||||
console.error('[app] boot failed', err);
|
||||
const el = document.getElementById('boot-err');
|
||||
if (el) el.textContent = '启动失败,请刷新页面重试';
|
||||
if (el) el.textContent = t('启动失败,请刷新页面重试');
|
||||
});
|
||||
+69
-61
@@ -8,6 +8,8 @@
|
||||
// screen is complete before data is generated. Rounds: solo 标准 = 9, everything else 14 (+R15
|
||||
// hidden core on 险境+), per research 00-INDEX §2. Battlefield pool (`modes[].stages`): 标准 always
|
||||
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
|
||||
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
|
||||
// config.json mode texts come localized from data.js.
|
||||
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
|
||||
@@ -18,20 +20,21 @@ import { LoadoutButton } from './loadout.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
|
||||
import { getConfig, getMode, getStage, useData } from '../data.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
/** Official mode texts (activity_table act2autochess.modeDataDict), fallback when config.json is absent. */
|
||||
export const MODE_TEXT = {
|
||||
single: {
|
||||
FUNNY: { code: 'AC-1', desc: '时长较短的模拟训练', effects: ['可以快速完成作战', '常规奖励'] },
|
||||
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
|
||||
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
|
||||
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
|
||||
FUNNY: { code: 'AC-1', desc: N_('时长较短的模拟训练'), effects: [N_('可以快速完成作战'), N_('常规奖励')] },
|
||||
NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
|
||||
HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
|
||||
ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
|
||||
},
|
||||
multi: {
|
||||
FUNNY: { code: 'AC-1', desc: '敌方攻击强度较低的模拟训练', effects: ['作战环境较为温和', '常规奖励'] },
|
||||
NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
|
||||
HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
|
||||
ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
|
||||
FUNNY: { code: 'AC-1', desc: N_('敌方攻击强度较低的模拟训练'), effects: [N_('作战环境较为温和'), N_('常规奖励')] },
|
||||
NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
|
||||
HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
|
||||
ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,13 +43,17 @@ export const STAGE_POOL = { FUNNY: ['act1autochess_m01'], NORMAL: 8, HARD: 7, AB
|
||||
|
||||
/**
|
||||
* Display name of a stage: stages.json when it is loaded, else derived from the id (act1 m0N → 战场#0N, act2 m0N → 战场#0(N+4)).
|
||||
* From the data name only its 战场#NN part (with a (上半) / (下半) mark) — 'Battlefield #NN (First Half)' in English.
|
||||
* @param {string} id e.g. 'act1autochess_m01'
|
||||
*/
|
||||
export function stageLabel(id) {
|
||||
const rec = getStage(id);
|
||||
if (rec && typeof rec.name === 'string' && rec.name) return rec.name.split(/\s+/)[0];
|
||||
if (rec && typeof rec.name === 'string' && rec.name) {
|
||||
const m = rec.name.match(/^(.*?#\d+(?:\s*[((][^))]*[))])?)/);
|
||||
return m ? m[1] : rec.name.split(/\s+/)[0];
|
||||
}
|
||||
const m = String(id || '').match(/^act(\d)autochess_m(\d+)$/);
|
||||
return m ? `战场#${String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0')}` : '';
|
||||
return m ? t('战场#{no}', { no: String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0') }) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,22 +65,22 @@ export function stageLabel(id) {
|
||||
export function stageNote(stages) {
|
||||
if (Array.isArray(stages)) {
|
||||
const ids = stages.filter((s) => typeof s === 'string' && s);
|
||||
if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? `战场固定为 ${name}` : '战场固定'; }
|
||||
return ids.length > 1 ? `战场随机(共${ids.length}张)` : '';
|
||||
if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? t('战场固定为 {name}', { name }) : t('战场固定'); }
|
||||
return ids.length > 1 ? t('战场随机(共{n}张)', { n: ids.length }) : '';
|
||||
}
|
||||
return Number.isInteger(stages) && stages > 1 ? `战场随机(共${stages}张)` : '';
|
||||
return Number.isInteger(stages) && stages > 1 ? t('战场随机(共{stages}张)', { stages }) : '';
|
||||
}
|
||||
|
||||
const MODE_CARDS = [
|
||||
{
|
||||
id: 'solo', name: '独立模拟', en: 'SOLO SIMULATION', icon: 'user',
|
||||
desc: '独自调配资金与干员,以自己的节奏完成整场模拟。',
|
||||
points: ['1 名博士', '休整期与机变阶段不限时'],
|
||||
id: 'solo', name: N_('独立模拟'), en: 'SOLO SIMULATION', icon: 'user',
|
||||
desc: N_('独自调配资金与干员,以自己的节奏完成整场模拟。'),
|
||||
points: [N_('1 名博士'), N_('休整期与机变阶段不限时')],
|
||||
},
|
||||
{
|
||||
id: 'coop', name: '同盟模拟', en: 'ALLIANCE SIMULATION', icon: 'users',
|
||||
desc: `与至多 ${MAX_SEATS - 1} 名博士组成同盟,共享干员池,联防协作抵御敌潮。`,
|
||||
points: [`1–${MAX_SEATS} 名博士 · 可由 AI 队友补位`, '联防阶段 · 最终攻势合并生命值'],
|
||||
id: 'coop', name: N_('同盟模拟'), en: 'ALLIANCE SIMULATION', icon: 'users',
|
||||
desc: N_('与至多 {n} 名博士组成同盟,共享干员池,联防协作抵御敌潮。'), params: { n: MAX_SEATS - 1 },
|
||||
points: [N_('1–{n} 名博士 · 可由 AI 队友补位'), N_('联防阶段 · 最终攻势合并生命值')], pointParams: { n: MAX_SEATS },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -89,11 +96,11 @@ export function difficultyInfo(roomMode, difficulty) {
|
||||
const m = DIFFICULTIES.includes(difficulty) ? getMode(modeIdFor(roomMode, difficulty)) : null;
|
||||
const effects = Array.isArray(m?.effectDescList)
|
||||
? m.effectDescList.map((e) => String(e).replace(/^[·•\s]+/, '')).filter(Boolean)
|
||||
: fallback.effects;
|
||||
: fallback.effects.map((e) => t(e));
|
||||
const rounds = Number.isFinite(m?.lastRound) ? m.lastRound : roomMode === 'solo' && difficulty === 'FUNNY' ? 9 : 14;
|
||||
return {
|
||||
code: typeof m?.code === 'string' ? m.code : fallback.code,
|
||||
desc: typeof m?.desc === 'string' ? m.desc : fallback.desc,
|
||||
desc: typeof m?.desc === 'string' ? m.desc : t(fallback.desc),
|
||||
effects,
|
||||
rounds,
|
||||
hidden: difficulty !== 'FUNNY',
|
||||
@@ -160,20 +167,21 @@ export function rememberRoom(code) {
|
||||
}
|
||||
|
||||
const FALLBACK_TIPS = [
|
||||
'联合模拟在选择策略时可以进行一次跳过',
|
||||
'调度中心即使冻结,依然可以主动刷新',
|
||||
'两件同名装备可以合成一件更强力的装备',
|
||||
'只有达成完美作战的队友可以进行联防',
|
||||
N_('联合模拟在选择策略时可以进行一次跳过'),
|
||||
N_('调度中心即使冻结,依然可以主动刷新'),
|
||||
N_('两件同名装备可以合成一件更强力的装备'),
|
||||
N_('只有达成完美作战的队友可以进行联防'),
|
||||
];
|
||||
const TIP_ROTATE_MS = 5000; // matchingTipRotateInterval
|
||||
|
||||
/** Rotating tactical tips (config.json `tips`, weighted list of { tip, weight }). */
|
||||
function TipsPanel() {
|
||||
const cfg = getConfig();
|
||||
const fallback = FALLBACK_TIPS.map((x) => t(x));
|
||||
const tips = Array.isArray(cfg?.tips)
|
||||
? cfg.tips.map((t) => (typeof t === 'string' ? t : t?.tip)).filter((t) => typeof t === 'string' && t)
|
||||
: FALLBACK_TIPS;
|
||||
const list = tips.length ? tips : FALLBACK_TIPS;
|
||||
? cfg.tips.map((x) => (typeof x === 'string' ? x : x?.tip)).filter((x) => typeof x === 'string' && x)
|
||||
: fallback;
|
||||
const list = tips.length ? tips : fallback;
|
||||
const [idx, setIdx] = useState(() => Math.floor(Math.random() * list.length));
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setIdx((i) => i + 1), TIP_ROTATE_MS);
|
||||
@@ -183,11 +191,11 @@ function TipsPanel() {
|
||||
return html`<div class="tips brackets">
|
||||
<div class="tips__head">
|
||||
<${Icon} name="info" />
|
||||
<span>作战提示</span>
|
||||
<span>${t('作战提示')}</span>
|
||||
<${MicroLabel}>TACTICAL TIPS<//>
|
||||
<span class="tips__idx num">${String(i + 1).padStart(2, '0')}<span class="t-dim">/${String(list.length).padStart(2, '0')}</span></span>
|
||||
<button type="button" class="tips__nav" onClick=${() => setIdx(i - 1 + list.length)} aria-label="上一条"><${Icon} name="chevronLeft" /></button>
|
||||
<button type="button" class="tips__nav" onClick=${() => setIdx(i + 1)} aria-label="下一条"><${Icon} name="chevronRight" /></button>
|
||||
<button type="button" class="tips__nav" onClick=${() => setIdx(i - 1 + list.length)} aria-label=${t('上一条')}><${Icon} name="chevronLeft" /></button>
|
||||
<button type="button" class="tips__nav" onClick=${() => setIdx(i + 1)} aria-label=${t('下一条')}><${Icon} name="chevronRight" /></button>
|
||||
</div>
|
||||
<p key=${i} class="tips__text">${list[i]}</p>
|
||||
</div>`;
|
||||
@@ -200,11 +208,11 @@ function ModeCard({ card, selected, onSelect }) {
|
||||
<span class="mode-card__icon"><${Icon} name=${card.icon} /></span>
|
||||
<span class="mode-card__text">
|
||||
<${MicroLabel} tone=${selected ? 'mint' : undefined}>${card.en}<//>
|
||||
<span class="mode-card__name">${card.name}</span>
|
||||
<span class="mode-card__desc">${card.desc}</span>
|
||||
<span class="mode-card__points">${card.points.map((p) => html`<span key=${p}>${p}</span>`)}</span>
|
||||
<span class="mode-card__name">${t(card.name)}</span>
|
||||
<span class="mode-card__desc">${t(card.desc, card.params)}</span>
|
||||
<span class="mode-card__points">${card.points.map((p) => html`<span key=${p}>${t(p, card.pointParams)}</span>`)}</span>
|
||||
</span>
|
||||
<span class="mode-card__check" aria-hidden="true"><${Icon} name="check" />已选定</span>
|
||||
<span class="mode-card__check" aria-hidden="true"><${Icon} name="check" />${t('已选定')}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
@@ -215,15 +223,15 @@ function DifficultyCard({ roomMode, difficulty, selected, onSelect }) {
|
||||
<span class="diff-card__bar" aria-hidden="true"></span>
|
||||
<span class="diff-card__head">
|
||||
<${DifficultyIcon} difficulty=${difficulty} class="diff-card__glyph" />
|
||||
<span class="diff-card__name">${DIFFICULTY_NAMES[difficulty]}</span>
|
||||
<span class="diff-card__name">${t(DIFFICULTY_NAMES[difficulty])}</span>
|
||||
<span class="diff-card__code num">${info.code}</span>
|
||||
<span class="diff-card__meta">
|
||||
<span class="num">${info.rounds}</span> 回合${info.hidden ? html`<span class="diff-card__hidden">+ 隐秘核心</span>` : null}
|
||||
<span class="num">${info.rounds}</span> ${tc('rounds', '回合')}${info.hidden ? html`<span class="diff-card__hidden">${t('+ 隐秘核心')}</span>` : null}
|
||||
</span>
|
||||
</span>
|
||||
<span class="diff-card__desc">${info.desc}</span>
|
||||
<span class="diff-card__effects">${info.effects.map((e) => html`<span key=${e}>${e}</span>`)}${info.stageNote ? html`<span key="stage" class="diff-card__stage"><${Icon} name="rook" />${info.stageNote}</span>` : null}</span>
|
||||
<span class="diff-card__check" aria-hidden="true"><${Icon} name="check" /><span>已选定</span></span>
|
||||
<span class="diff-card__check" aria-hidden="true"><${Icon} name="check" /><span>${t('已选定')}</span></span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
@@ -252,7 +260,7 @@ export function LobbyScreen() {
|
||||
|
||||
const run = async (kind, fn) => {
|
||||
if (inFlight.current) return;
|
||||
if (!online) { toast('尚未连接到服务器,请稍候', 'warn'); return; }
|
||||
if (!online) { toast(t('尚未连接到服务器,请稍候'), 'warn'); return; }
|
||||
inFlight.current = true;
|
||||
setBusy(kind);
|
||||
try { await fn(); } catch (err) { toastError(err); } finally {
|
||||
@@ -265,23 +273,23 @@ export function LobbyScreen() {
|
||||
// `onClick=${join}` hands the click EVENT as the first argument, and a default parameter only applies to
|
||||
// `undefined` — codeArg keeps an event target out of the key and falls back to the input field
|
||||
const k = codeArg(c, code);
|
||||
if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
|
||||
run('join', () => net.request('room.join', { code: k }));
|
||||
};
|
||||
// a spectator seat: no player seat taken, nothing to do but watch (also a match already running)
|
||||
const spectate = (c = code) => {
|
||||
// same guard as join: `onClick=${spectate}` passes the click event, not a code
|
||||
const k = codeArg(c, code);
|
||||
if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
|
||||
if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
|
||||
run('spectate', () => net.request('room.spectate', { code: k }).catch((err) => {
|
||||
// Clearer than the bare ERR_TEXT: the usual cause is a code that is not the host's (a remembered one from an
|
||||
// earlier room, or another machine's) — the server can only answer "no such room".
|
||||
if (err?.code === ERR.ROOM_NOT_FOUND) {
|
||||
toast(`没有找到密钥 ${k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)`, 'warn');
|
||||
toast(t('没有找到密钥 {k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)', { k }), 'warn');
|
||||
return;
|
||||
}
|
||||
if (err?.code === ERR.ALREADY) {
|
||||
toast('你已经是该同盟的博士:先离开同盟,才能以观战身份进入', 'warn');
|
||||
toast(t('你已经是该同盟的博士:先离开同盟,才能以观战身份进入'), 'warn');
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
@@ -295,20 +303,20 @@ export function LobbyScreen() {
|
||||
return html`<div class="screen lobby-screen">
|
||||
<header class="topbar">
|
||||
<div class="topbar__left">
|
||||
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title="返回标题">返回<//>
|
||||
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}<//>
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
|
||||
<h1 class="topbar__title">选择模拟协议</h1>
|
||||
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
|
||||
<div class="me-chip__text">
|
||||
<span class="me-chip__name">${me.name || '博士'}</span>
|
||||
<span class="me-chip__name">${me.name || t('博士')}</span>
|
||||
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,45 +325,45 @@ export function LobbyScreen() {
|
||||
|
||||
<div class="lobby-body screen__scroll">
|
||||
<section class="lobby-left">
|
||||
<div class="section-label"><span class="section-label__idx num">01</span>模拟方式<${MicroLabel}>MODE<//></div>
|
||||
<div class="section-label"><span class="section-label__idx num">01</span>${t('模拟方式')}<${MicroLabel}>MODE<//></div>
|
||||
<div class="mode-cards">
|
||||
${MODE_CARDS.map((c) => html`<${ModeCard} key=${c.id} card=${c} selected=${roomMode === c.id} onSelect=${pickMode} />`)}
|
||||
</div>
|
||||
|
||||
<div class="section-label"><span class="section-label__idx num">03</span>加入同盟<${MicroLabel}>JOIN WITH ALLIANCE KEY<//></div>
|
||||
<div class="section-label"><span class="section-label__idx num">03</span>${t('加入同盟')}<${MicroLabel}>JOIN WITH ALLIANCE KEY<//></div>
|
||||
<${Panel} class="join-panel" tone="amber">
|
||||
<div class="join-row">
|
||||
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
|
||||
<${TextField} size="code" icon="key" value=${code} placeholder=${t('输入同盟密钥 / 粘贴邀请链接')}
|
||||
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
|
||||
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟<//>
|
||||
<${Tooltip} text=${`以观战者身份进入:不占博士席位,只能观看(每个同盟最多 ${MAX_SPECTATORS} 名,模拟进行中也可进入)`}>
|
||||
<${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>观战<//>
|
||||
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>${t('加入同盟')}<//>
|
||||
<${Tooltip} text=${t('以观战者身份进入:不占博士席位,只能观看(每个同盟最多 {MAX_SPECTATORS} 名,模拟进行中也可进入)', { MAX_SPECTATORS })}>
|
||||
<${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>${t('观战')}<//>
|
||||
<//>
|
||||
</div>
|
||||
<div class="join-foot">
|
||||
${recent.length ? html`<span class="t-lo">最近的同盟</span>
|
||||
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" title="填入密钥(不会直接加入)"
|
||||
${recent.length ? html`<span class="t-lo">${t('最近的同盟')}</span>
|
||||
${recent.map((c) => html`<button key=${c} type="button" class="code-chip num" title=${t('填入密钥(不会直接加入)')}
|
||||
onClick=${() => setCode(c)}>${c}</button>`)}`
|
||||
: html`<span class="t-dim">向同伴索取 ${ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接</span>`}
|
||||
: html`<span class="t-dim">${t('向同伴索取 {ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接', { ROOM_CODE_LEN })}</span>`}
|
||||
</div>
|
||||
<//>
|
||||
<${TipsPanel} />
|
||||
</section>
|
||||
|
||||
<section class="lobby-right">
|
||||
<div class="section-label"><span class="section-label__idx num">02</span>模拟难度<${MicroLabel}>DIFFICULTY<//></div>
|
||||
<div class="section-label"><span class="section-label__idx num">02</span>${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></div>
|
||||
<div class="diff-list">
|
||||
${DIFFICULTIES.map((d) => html`<${DifficultyCard} key=${d} roomMode=${roomMode} difficulty=${d} selected=${difficulty === d} onSelect=${pickDifficulty} />`)}
|
||||
</div>
|
||||
<div class="create-box">
|
||||
<${Tooltip} block=${true} text=${online ? null : '正在连接服务器…'}>
|
||||
<${Tooltip} block=${true} text=${online ? null : t('正在连接服务器…')}>
|
||||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" loading=${busy === 'create'} disabled=${!online} onClick=${create}>
|
||||
${roomMode === 'solo' ? '开始独立模拟' : '创建同盟'}
|
||||
${roomMode === 'solo' ? t('开始独立模拟') : t('创建同盟')}
|
||||
<//>
|
||||
<//>
|
||||
<div class="create-box__hint">
|
||||
${online
|
||||
? html`<span>${roomMode === 'solo' ? '创建后即可开始模拟' : '创建后可邀请好友或添加 AI 队友'}</span>`
|
||||
? html`<span>${roomMode === 'solo' ? t('创建后即可开始模拟') : t('创建后可邀请好友或添加 AI 队友')}</span>`
|
||||
: html`<${Spinner} size="sm" label="CONNECTING" />`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+57
-55
@@ -9,6 +9,7 @@
|
||||
// Spectator seats (community report #26, a remake feature): a co-op room with spectators shows the 观战席 strip under
|
||||
// the seats — names, offline marks, the host's ✕ (room.removeSpectator) — and a spectator's own view swaps the ready
|
||||
// button for 观战中 and offers 入座 (room.join of the room) while a player seat is free.
|
||||
// Texts go through t() (docs/I18N.md).
|
||||
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS, MAX_SPECTATORS } from '../../../shared/constants.js';
|
||||
@@ -22,6 +23,7 @@ import { LoadoutButton } from './loadout.js';
|
||||
import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
|
||||
import { difficultyInfo } from './lobby.js';
|
||||
import { t, tc } from '../../../shared/i18n.js';
|
||||
|
||||
/**
|
||||
* Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record.
|
||||
@@ -90,13 +92,13 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
|
||||
<header class="seat__head"><span class="seat__no num">P${index + 1}</span><${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//></header>
|
||||
<div class="seat__art seat__art--empty">
|
||||
<div class="seat__radar" aria-hidden="true"></div>
|
||||
<span class="seat__wait">等待博士加入</span>
|
||||
<span class="seat__wait">${t('等待博士加入')}</span>
|
||||
<${MicroLabel}>AWAITING DOCTOR<//>
|
||||
</div>
|
||||
<footer class="seat__foot">
|
||||
${canAdd
|
||||
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>添加 AI 队友<//>`
|
||||
: html`<span class="seat__state t-dim">空位</span>`}
|
||||
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>${t('添加 AI 队友')}<//>`
|
||||
: html`<span class="seat__state t-dim">${t('空位')}</span>`}
|
||||
</footer>
|
||||
</article>`;
|
||||
}
|
||||
@@ -110,30 +112,30 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
|
||||
<header class="seat__head">
|
||||
<span class="seat__no num">P${index + 1}</span>
|
||||
<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//>
|
||||
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />创建者</span>` : null}
|
||||
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />${t('创建者')}</span>` : null}
|
||||
</header>
|
||||
<div class="seat__art">
|
||||
<div class="seat__stripes" aria-hidden="true"></div>
|
||||
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} />
|
||||
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />AI 队友</span>` : null}
|
||||
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />${t('AI 队友')}</span>` : null}
|
||||
</div>
|
||||
<div class="seat__who">
|
||||
<span class="seat__name">${seat.name || '博士'}</span>
|
||||
${isMe ? html`<span class="seat__you">你</span>` : null}
|
||||
<span class="seat__name">${seat.name || t('博士')}</span>
|
||||
${isMe ? html`<span class="seat__you">${t('你')}</span>` : null}
|
||||
</div>
|
||||
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}<//>
|
||||
<footer class="seat__foot">
|
||||
<span class=${`seat__state seat__state--${state}`}>
|
||||
${state === 'ready' ? html`<${Icon} name="check" />已就绪`
|
||||
: state === 'offline' ? html`<${Icon} name="wifiOff" />连接中断`
|
||||
: state === 'host' ? html`<${Icon} name="crown" />待命中`
|
||||
: html`<${Icon} name="hourglass" />准备中`}
|
||||
${state === 'ready' ? html`<${Icon} name="check" />${t('已就绪')}`
|
||||
: state === 'offline' ? html`<${Icon} name="wifiOff" />${t('连接中断')}`
|
||||
: state === 'host' ? html`<${Icon} name="crown" />${t('待命中')}`
|
||||
: html`<${Icon} name="hourglass" />${t('准备中')}`}
|
||||
</span>
|
||||
${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友">
|
||||
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" />
|
||||
${seat.isBot && facts.isHost ? html`<${Tooltip} text=${t('移除该 AI 队友')}>
|
||||
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label=${t('移除 AI 队友')} />
|
||||
<//>` : null}
|
||||
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text="将该博士移出同盟">
|
||||
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label="移出该博士" />
|
||||
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text=${t('将该博士移出同盟')}>
|
||||
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label=${t('移出该博士')} />
|
||||
<//>` : null}
|
||||
</footer>
|
||||
</article>`;
|
||||
@@ -142,29 +144,29 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
|
||||
/** 观战席: the room's spectators (host: ✕ frees a seat), and 入座 for a spectator while a player seat is free. */
|
||||
function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
|
||||
if (!facts.spectators.length) return null;
|
||||
return html`<section class="specbar" aria-label="观战席">
|
||||
<span class="specbar__label"><${Icon} name="eye" />观战席<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
|
||||
return html`<section class="specbar" aria-label=${t('观战席')}>
|
||||
<span class="specbar__label"><${Icon} name="eye" />${t('观战席')}<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
|
||||
${facts.spectators.map((s) => html`<span key=${s.playerId} class=${`specbar__who${s.playerId === myId ? ' is-me' : ''}${s.connected === false ? ' is-offline' : ''}`}>
|
||||
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`<span class="seat__you">你</span>` : null}
|
||||
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || t('博士')}${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
|
||||
${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`}
|
||||
onClick=${() => onRemove(s.playerId)} aria-label=${`移出观战者 ${s.name || ''}`} title="移出该观战者" />` : null}
|
||||
onClick=${() => onRemove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
|
||||
</span>`)}
|
||||
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座<//>` : null}
|
||||
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>${t('入座')}<//>` : null}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function InviteBox({ code }) {
|
||||
const copy = async (what) => {
|
||||
const ok = await copyText(what === 'code' ? code : inviteLink(code));
|
||||
if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success');
|
||||
else toast('复制失败,请手动复制', 'warn');
|
||||
if (ok) toast(what === 'code' ? t('已复制同盟密钥 {code}', { code }) : t('已复制邀请链接'), 'success');
|
||||
else toast(t('复制失败,请手动复制'), 'warn');
|
||||
};
|
||||
return html`<div class="invite brackets">
|
||||
<div class="invite__label"><${Icon} name="key" /><span>同盟密钥</span><${MicroLabel}>ALLIANCE KEY<//></div>
|
||||
<div class="invite__code num selectable" aria-label=${`同盟密钥 ${code}`}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
|
||||
<div class="invite__label"><${Icon} name="key" /><span>${t('同盟密钥')}</span><${MicroLabel}>ALLIANCE KEY<//></div>
|
||||
<div class="invite__code num selectable" aria-label=${t('同盟密钥 {code}', { code })}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
|
||||
<div class="invite__btns">
|
||||
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>复制密钥<//>
|
||||
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>复制链接<//>
|
||||
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>${t('复制密钥')}<//>
|
||||
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>${t('复制链接')}<//>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -173,14 +175,14 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
|
||||
if (!isHost) {
|
||||
return html`<div class="dpick dpick--ro">
|
||||
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} />
|
||||
<span class="t-dim">由创建者选择</span>
|
||||
<span class="t-dim">${t('由创建者选择')}</span>
|
||||
</div>`;
|
||||
}
|
||||
return html`<div class="dpick" role="radiogroup" aria-label="模拟难度">
|
||||
return html`<div class="dpick" role="radiogroup" aria-label=${t('模拟难度')}>
|
||||
${DIFFICULTIES.map((d) => html`<button key=${d} type="button" role="radio" aria-checked=${room.difficulty === d ? 'true' : 'false'}
|
||||
class=${`dpick__opt${room.difficulty === d ? ' is-active' : ''}`} style=${`--d-color:${DIFFICULTY_COLORS[d]}`}
|
||||
disabled=${!!busy} onClick=${() => room.difficulty !== d && onPick(d)}>
|
||||
<${DifficultyIcon} difficulty=${d} />${DIFFICULTY_NAMES[d].replace('模拟', '')}
|
||||
<${DifficultyIcon} difficulty=${d} />${t(DIFFICULTY_NAMES[d].replace('模拟', ''))}
|
||||
</button>`)}
|
||||
</div>`;
|
||||
}
|
||||
@@ -204,7 +206,7 @@ export function RoomScreen() {
|
||||
|
||||
const run = async (kind, fn) => {
|
||||
if (inFlight.current) return;
|
||||
if (!online) { toast('连接中断,请稍候重试', 'warn'); return; }
|
||||
if (!online) { toast(t('连接中断,请稍候重试'), 'warn'); return; }
|
||||
inFlight.current = true;
|
||||
setBusy(kind);
|
||||
try { await fn(); } catch (err) { toastError(err); } finally {
|
||||
@@ -221,7 +223,7 @@ export function RoomScreen() {
|
||||
// confirmed player's id goes along: if they left and someone else took the seat meanwhile, the server refuses it.
|
||||
const kick = async (seat, name, playerId) => {
|
||||
if (inFlight.current) return;
|
||||
const ok = await confirmDialog({ title: '移出同盟', text: `确定将「${name || '博士'}」移出同盟吗?对方可以凭同盟密钥重新加入。`, okText: '移出', danger: true });
|
||||
const ok = await confirmDialog({ title: t('移出同盟'), text: t('确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。', { name: name || t('博士') }), okText: t('移出'), danger: true });
|
||||
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
|
||||
};
|
||||
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
|
||||
@@ -232,7 +234,7 @@ export function RoomScreen() {
|
||||
if (inFlight.current) return;
|
||||
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
|
||||
if (facts.isHost && othersHere) {
|
||||
const ok = await confirmDialog({ title: '离开同盟', text: '你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?', okText: '离开', danger: true });
|
||||
const ok = await confirmDialog({ title: t('离开同盟'), text: t('你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?'), okText: t('离开'), danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
inFlight.current = true;
|
||||
@@ -250,37 +252,37 @@ export function RoomScreen() {
|
||||
};
|
||||
|
||||
const statusLine = !online
|
||||
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
|
||||
? html`<span class="t-orange"><${Icon} name="wifiOff" />${t('连接中断,正在重连…')}</span>`
|
||||
: facts.spectating
|
||||
? html`<span class="t-lo"><${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士</span>`
|
||||
? html`<span class="t-lo"><${Icon} name="eye" />${t('观战中 · 不占博士席位,模拟开始后可切换观看各位博士')}</span>`
|
||||
: !coop
|
||||
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
|
||||
? html`<span class="t-mint">${t('*模拟协议已就绪,准许进入模拟')}</span>`
|
||||
: facts.isHost
|
||||
? facts.canStart
|
||||
? html`<span class="t-mint">*同盟人数达标,准许进入模拟</span>`
|
||||
: html`<span class="t-lo">等待所有博士准备就绪</span>`
|
||||
? html`<span class="t-mint">${t('*同盟人数达标,准许进入模拟')}</span>`
|
||||
: html`<span class="t-lo">${t('等待所有博士准备就绪')}</span>`
|
||||
: myReady
|
||||
? html`<span class="t-mint">已就绪 · 等待创建者开始模拟</span>`
|
||||
: html`<span class="t-lo">准备就绪后,创建者即可开始模拟</span>`;
|
||||
? html`<span class="t-mint">${t('已就绪 · 等待创建者开始模拟')}</span>`
|
||||
: html`<span class="t-lo">${t('准备就绪后,创建者即可开始模拟')}</span>`;
|
||||
|
||||
return html`<div class="screen room-screen">
|
||||
<header class="topbar">
|
||||
<div class="topbar__left">
|
||||
<${Tooltip} text="离开同盟" placement="bottom">
|
||||
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label="离开同盟" />
|
||||
<${Tooltip} text=${t('离开同盟')} placement="bottom">
|
||||
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label=${t('离开同盟')} />
|
||||
<//>
|
||||
<div class="room-ping">
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
<${MicroLabel}>当前延迟<//>
|
||||
<${MicroLabel}>${t('当前延迟')}<//>
|
||||
</div>
|
||||
<${GuideButton} class="room-guide" variant="secondary" />
|
||||
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}<//>
|
||||
<h1 class="topbar__title">${coop ? '同盟模拟' : '独立模拟'}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
|
||||
<h1 class="topbar__title">${coop ? t('同盟模拟') : t('独立模拟')}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>仅限 1 名博士</span></div>`}
|
||||
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>${t('仅限 1 名博士')}</span></div>`}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -289,12 +291,12 @@ export function RoomScreen() {
|
||||
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)}
|
||||
${coop ? null : html`<aside class="solo-brief brackets">
|
||||
<${MicroLabel} tone="mint">BRIEFING<//>
|
||||
<h2>${DIFFICULTY_NAMES[room.difficulty] || ''}<span class="num t-dim"> ${info.code}</span></h2>
|
||||
<h2>${DIFFICULTY_NAMES[room.difficulty] ? t(DIFFICULTY_NAMES[room.difficulty]) : ''}<span class="num t-dim"> ${info.code}</span></h2>
|
||||
<p>${info.desc}</p>
|
||||
<ul>
|
||||
${info.effects.map((e) => html`<li key=${e}>${e}</li>`)}
|
||||
<li>共 <b class="num">${info.rounds}</b> 回合${info.hidden ? ',满足条件时进入隐秘核心' : ''}</li>
|
||||
<li>独立模拟中休整期与机变阶段不限时</li>
|
||||
<li>${t('共')} <b class="num">${info.rounds}</b> ${tc('rounds', '回合')}${info.hidden ? t(',满足条件时进入隐秘核心') : ''}</li>
|
||||
<li>${t('独立模拟中休整期与机变阶段不限时')}</li>
|
||||
</ul>
|
||||
</aside>`}
|
||||
</main>
|
||||
@@ -302,12 +304,12 @@ export function RoomScreen() {
|
||||
|
||||
<footer class="room-bar">
|
||||
<div class="room-bar__left">
|
||||
<span class="room-bar__label">模拟难度<${MicroLabel}>DIFFICULTY<//></span>
|
||||
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
|
||||
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
|
||||
</div>
|
||||
<div class="room-bar__center">
|
||||
<div class="ready-count" hidden=${!coop}>
|
||||
<span class="t-lo">已就绪</span>
|
||||
<span class="t-lo">${t('已就绪')}</span>
|
||||
<b class="num">${facts.readyHumans}</b><span class="num t-dim">/${facts.humans.length}</span>
|
||||
<span class="ready-count__icons" aria-hidden="true">
|
||||
${facts.humans.map((s) => html`<${Icon} key=${s.playerId} name="user" class=${facts.isReady(s) ? 'is-on' : ''} />`)}
|
||||
@@ -316,15 +318,15 @@ export function RoomScreen() {
|
||||
<div class="room-bar__status">${statusLine}</div>
|
||||
</div>
|
||||
<div class="room-bar__right">
|
||||
<${LoadoutButton} from="room" size="lg" class="room-loadout" />
|
||||
<${LoadoutButton} from="room" size="lg" class="room-loadout" label=${t('干员调配')} />
|
||||
${facts.isHost
|
||||
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
|
||||
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//>
|
||||
? html`<${Tooltip} text=${facts.canStart ? null : t('仍有博士未准备就绪')}>
|
||||
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>${t('开始模拟')}<//>
|
||||
<//>`
|
||||
: facts.spectating
|
||||
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>观战中<//>`
|
||||
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>${t('观战中')}<//>`
|
||||
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
|
||||
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
|
||||
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? t('已就绪') : t('准备就绪')}<//>`}
|
||||
</div>
|
||||
</footer>
|
||||
</div>`;
|
||||
|
||||
+23
-16
@@ -1,4 +1,5 @@
|
||||
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始.
|
||||
// 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).
|
||||
//
|
||||
// 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
|
||||
@@ -17,6 +18,8 @@ import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual } from '../store.js';
|
||||
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';
|
||||
|
||||
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
|
||||
// the client accepts is never rejected by the server's hello validation.
|
||||
@@ -174,14 +177,15 @@ function Ridges() {
|
||||
}
|
||||
|
||||
const STATUS_TEXT = {
|
||||
idle: '准备连接', connecting: '正在连接服务器', connected: '已连接服务器', handshaking: '正在验证身份',
|
||||
online: '已连接服务器', reconnecting: '连接中断,正在重连', closed: '连接已关闭',
|
||||
idle: N_('准备连接'), connecting: N_('正在连接服务器'), connected: N_('已连接服务器'), handshaking: N_('正在验证身份'),
|
||||
online: N_('已连接服务器'), reconnecting: N_('连接中断,正在重连'), closed: N_('连接已关闭'),
|
||||
};
|
||||
|
||||
/** Title screen component. */
|
||||
export function TitleScreen() {
|
||||
const conn = useStore((s) => s.connection, shallowEqual);
|
||||
const pendingJoin = useStore((s) => s.ui.pendingJoin);
|
||||
const lang = useLang();
|
||||
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
|
||||
const assetsSettled = useData('assets');
|
||||
const assets = data.get('assets');
|
||||
@@ -199,7 +203,7 @@ export function TitleScreen() {
|
||||
|
||||
const valid = isValidName(name);
|
||||
const start = () => {
|
||||
if (!valid) { toast('请输入博士代号', 'warn'); return; }
|
||||
if (!valid) { toast(t('请输入博士代号'), 'warn'); return; }
|
||||
enterSession(name);
|
||||
};
|
||||
|
||||
@@ -232,39 +236,42 @@ export function TitleScreen() {
|
||||
<div><${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE<//><br /><${MicroLabel}>TACTICAL CO-OP NODE · 02<//></div>
|
||||
</div>
|
||||
<div class="title-corner title-corner--tr">
|
||||
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
|
||||
<div>
|
||||
<${LangToggle} class="title-lang" />
|
||||
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="title-main">
|
||||
<${Emblem} />
|
||||
<div class="title-en">
|
||||
${lang === 'en' ? null : html`<div class="title-en">
|
||||
<span class="title-en__a">STRONGHOLD PROTOCOL</span>
|
||||
<span class="title-en__b">ALLIANCE</span>
|
||||
</div>
|
||||
<h1 class="title-cn">卫戍协议<span class="title-cn__colon">:</span><em>盟约</em></h1>
|
||||
<p class="title-tag">调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。</p>
|
||||
</div>`}
|
||||
<h1 class=${`title-cn${lang === 'en' ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${lang === 'en' ? ': ' : ':'}</span><em>${t('盟约')}</em></h1>
|
||||
<p class="title-tag">${t('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}</p>
|
||||
|
||||
<div class="title-login">
|
||||
${pendingJoin ? html`<div class="title-invite">
|
||||
<${Icon} name="key" />
|
||||
<span>收到同盟邀请</span><b class="num">${pendingJoin}</b><span class="t-lo">· 输入代号后将自动加入</span>
|
||||
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 输入代号后将自动加入')}</span>
|
||||
</div>` : null}
|
||||
<${TextField} label="博士代号" micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
|
||||
placeholder="输入你的代号(最多 ${NAME_MAX_LEN} 字)" autoFocus=${!touchUi}
|
||||
<${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
|
||||
placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi}
|
||||
onInput=${setName} onEnter=${start} />
|
||||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>开始<//>
|
||||
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${t('开始')}<//>
|
||||
<div class="title-conn">
|
||||
<span class=${`status-dot ${dotClass}`}></span>
|
||||
<span>${STATUS_TEXT[conn.status] || conn.status}</span>
|
||||
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
|
||||
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
|
||||
<${GuideButton} class="title-guide" />
|
||||
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
|
||||
<${FullscreenButton} class="title-fs" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="title-foot">
|
||||
<span>非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有</span>
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
</footer>
|
||||
</div>`;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { h, Fragment } from '../../vendor/preact.module.js';
|
||||
import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import htm from '../../vendor/htm.module.js';
|
||||
import { DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { serverNow } from '../store.js';
|
||||
import { data, useData, localAsset } from '../data.js';
|
||||
import { uiUrl } from './assetUrls.js';
|
||||
@@ -781,7 +782,7 @@ export function DifficultyIcon({ difficulty, class: cls }) {
|
||||
* @param {{ difficulty: string, size?: 'sm'|'md'|'lg', class?: string, code?: string }} props
|
||||
*/
|
||||
export function DifficultyTag({ difficulty, size = 'md', class: cls, code }) {
|
||||
const name = DIFFICULTY_NAMES[difficulty] || difficulty || '—';
|
||||
const name = DIFFICULTY_NAMES[difficulty] ? t(DIFFICULTY_NAMES[difficulty]) : difficulty || '—';
|
||||
const color = DIFFICULTY_COLORS[difficulty] || 'var(--text-lo)';
|
||||
return html`<span class=${cx('dtag', `dtag--${size}`, cls)} style=${`--d-color:${color}`}>
|
||||
<${DifficultyIcon} difficulty=${difficulty} class="dtag__icon" />
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Language switch of the client (docs/I18N.md): picks the language at boot, loads the UI translations
|
||||
// (public/i18n/<lang>.json, shared/i18n.js) and the localized game data (data/i18n/<lang>.json through data.js), keeps
|
||||
// the choice, and translates what the server sends (m.toast / m.ticker / error codes).
|
||||
//
|
||||
// Chinese is the default; English is a switch (the owner's decision of 2026-10-05) — the browser's language is not
|
||||
// consulted. Order at boot: `?lang=en|zh` in the URL (then removed from the address bar and kept as the choice), the
|
||||
// stored choice (localStorage `sp.pref.lang`), else Chinese. A switch re-renders the app in place (main.js App
|
||||
// subscribes with useLang) — no reload; the game texts follow as soon as the data overlay has downloaded (data.js
|
||||
// notifies its subscribers).
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { LANGS, DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire } from '../../../shared/i18n.js';
|
||||
import { loadPref, savePref } from '../store.js';
|
||||
import { data } from '../data.js';
|
||||
import { html } from './components.js';
|
||||
|
||||
/** Labels of the language switch (each in its own language). */
|
||||
export const LANG_LABELS = Object.freeze({ zh: '中文', en: 'English' });
|
||||
const PREF_KEY = 'lang';
|
||||
/** How long boot waits for the UI translations before rendering in Chinese anyway (they apply when they arrive). */
|
||||
const BOOT_WAIT_MS = 2500;
|
||||
|
||||
/** @type {Map<string, Promise<boolean>>} */
|
||||
const uiLoads = new Map();
|
||||
|
||||
/**
|
||||
* Download (once) the UI translations of a language.
|
||||
* @param {string} lang
|
||||
* @param {typeof fetch} [doFetch]
|
||||
* @returns {Promise<boolean>} false when unavailable (the UI stays Chinese)
|
||||
*/
|
||||
export function loadUiMessages(lang, doFetch = (...a) => globalThis.fetch(...a)) {
|
||||
if (lang === DEFAULT_LANG) return Promise.resolve(true);
|
||||
if (uiLoads.has(lang)) return uiLoads.get(lang);
|
||||
const p = (async () => {
|
||||
try {
|
||||
const res = await doFetch(`/i18n/${lang}.json`, { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
|
||||
addMessages(lang, await res.json());
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(`[i18n] /i18n/${lang}.json unavailable (${err?.message || err}); the interface stays Chinese`);
|
||||
uiLoads.delete(lang);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
uiLoads.set(lang, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language to start in: the URL's `?lang=`, then the stored choice, then Chinese.
|
||||
* @param {string} [search] location.search
|
||||
* @param {(key: string, fallback: any) => any} [load]
|
||||
* @returns {{ lang: string, fromUrl: boolean }}
|
||||
*/
|
||||
export function initialLang(search = globalThis.location?.search || '', load = loadPref) {
|
||||
let fromUrl = null;
|
||||
try { fromUrl = normalizeLang(new URLSearchParams(search).get('lang')); } catch { /* ignore */ }
|
||||
if (fromUrl) return { lang: fromUrl, fromUrl: true };
|
||||
let stored = null;
|
||||
try { stored = normalizeLang(load(PREF_KEY, null)); } catch { /* ignore */ }
|
||||
return { lang: stored || DEFAULT_LANG, fromUrl: false };
|
||||
}
|
||||
|
||||
function stripLangParam() {
|
||||
try {
|
||||
const url = new URL(globalThis.location.href);
|
||||
if (!url.searchParams.has('lang')) return;
|
||||
url.searchParams.delete('lang');
|
||||
globalThis.history?.replaceState(globalThis.history.state, '', url.pathname + (url.search || '') + url.hash);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** <html lang> and the tab title follow the language. */
|
||||
function applyDocument(lang) {
|
||||
const doc = globalThis.document;
|
||||
if (!doc) return;
|
||||
doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
|
||||
doc.documentElement.dataset.lang = lang;
|
||||
doc.title = lang === 'en' ? 'Stronghold Protocol: Alliance · Web Simulation' : '卫戍协议:盟约 · STRONGHOLD PROTOCOL';
|
||||
}
|
||||
|
||||
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
|
||||
setNameResolver((name) => data.localeName(name));
|
||||
|
||||
let wired = false;
|
||||
function wire() {
|
||||
if (wired) return;
|
||||
wired = true;
|
||||
onLangChange((lang) => { applyDocument(lang); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot: choose the language and load its UI translations (waits at most BOOT_WAIT_MS) before the first render; the
|
||||
* game-data overlay downloads in the background.
|
||||
* @returns {Promise<string>} the language in effect
|
||||
*/
|
||||
export async function initLang() {
|
||||
wire();
|
||||
const { lang, fromUrl } = initialLang();
|
||||
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
|
||||
applyDocument(getLang());
|
||||
if (lang === DEFAULT_LANG) return getLang();
|
||||
const ok = await Promise.race([loadUiMessages(lang), new Promise((r) => setTimeout(() => r(null), BOOT_WAIT_MS))]);
|
||||
if (ok === null) loadUiMessages(lang).then((loaded) => { if (loaded && normalizeLang(loadPref(PREF_KEY, null)) === lang) setLang(lang); });
|
||||
else if (ok) setLang(lang);
|
||||
data.setLocale(lang).catch(() => {});
|
||||
return getLang();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the language (the switch on the title screen and in 设置): keeps the choice, loads the translations, then
|
||||
* re-renders; the game texts follow when their overlay has downloaded.
|
||||
* @param {string} lang
|
||||
* @returns {Promise<string>} the language in effect
|
||||
*/
|
||||
export async function switchLang(lang) {
|
||||
wire();
|
||||
const want = normalizeLang(lang) || DEFAULT_LANG;
|
||||
savePref(PREF_KEY, want);
|
||||
if (want !== DEFAULT_LANG && !(await loadUiMessages(want))) return getLang();
|
||||
setLang(want);
|
||||
data.setLocale(want).catch(() => {});
|
||||
return getLang();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preact hook: the current language; the component re-renders when it changes.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function useLang() {
|
||||
const [lang, setState] = useState(getLang);
|
||||
useEffect(() => {
|
||||
setState(getLang());
|
||||
return onLangChange((l) => setState(l));
|
||||
}, []);
|
||||
return lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language switch (segmented 中文 | English).
|
||||
* @param {{ class?: string }} props
|
||||
*/
|
||||
export function LangToggle({ class: cls }) {
|
||||
const lang = useLang();
|
||||
return html`<div class=${`set-seg lang-toggle${cls ? ` ${cls}` : ''}`} role="radiogroup" aria-label="Language / 语言" data-testid="lang-toggle">
|
||||
${LANGS.map((l) => html`<button key=${l} type="button" role="radio" lang=${l === 'zh' ? 'zh-CN' : 'en'} aria-checked=${lang === l ? 'true' : 'false'}
|
||||
class=${lang === l ? 'is-on' : ''} data-lang=${l} onClick=${() => { if (lang !== l) switchLang(l); }}>${LANG_LABELS[l]}</button>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of an m.ticker frame in the current language. A config.broadcasts line (`id` + `args`, server ≥ 0.2.0) is
|
||||
* rebuilt from the localized broadcast template — `{0}` is the player's name (never translated), other args are
|
||||
* game-data names or numbers; any other line goes through translateWire (msgid + params, or its text as a msgid).
|
||||
* @param {{ text?: string, id?: string|null, args?: unknown[], msgid?: string, params?: any }} msg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tickerText(msg) {
|
||||
if (getLang() !== DEFAULT_LANG && typeof msg?.id === 'string' && Array.isArray(msg.args)) {
|
||||
const list = data.get('config')?.broadcasts;
|
||||
const b = Array.isArray(list) ? list.find((x) => x && x.id === msg.id) : null;
|
||||
if (b && typeof b.text === 'string') {
|
||||
const args = msg.args.map((a, i) => (i === 0 ? String(a ?? '') : tName(String(a ?? ''))));
|
||||
return format(b.text, args);
|
||||
}
|
||||
}
|
||||
return translateWire(msg);
|
||||
}
|
||||
+24
-15
@@ -1,6 +1,6 @@
|
||||
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
|
||||
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
|
||||
// the settings modal.
|
||||
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`).
|
||||
|
||||
import { useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||
@@ -9,6 +9,8 @@ import { sanitizeSettings } from './gameLogic.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { openGuide } from './guide.js';
|
||||
import { detectFeatures } from './device.js';
|
||||
import { LangToggle } from './lang.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
|
||||
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
|
||||
@@ -41,11 +43,14 @@ function Toggle({ label, micro, value, onChange }) {
|
||||
return html`<div class="set-row">
|
||||
<span class="set-row__label">${label}<${MicroLabel}>${micro}<//></span>
|
||||
<button type="button" class=${`set-toggle${value ? ' is-on' : ''}`} role="switch" aria-checked=${value ? 'true' : 'false'}
|
||||
onClick=${() => onChange(!value)}><i></i><span>${value ? '开启' : '关闭'}</span></button>
|
||||
onClick=${() => onChange(!value)}><i></i><span>${value ? tc('toggle', '开启') : tc('toggle', '关闭')}</span></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const QUALITY = [['high', '高'], ['medium', '中'], ['low', '低']];
|
||||
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
|
||||
/** Desktop shortcuts of the hint line: key → action (msgids). */
|
||||
const SHORTCUTS = [['R', N_('刷新')], ['F', N_('冻结')], ['D', N_('升级')], ['Q', N_('撤退选中干员')], ['X', N_('出售选中干员')],
|
||||
['Space', N_('准备就绪')], ['Esc', N_('关闭弹窗')]];
|
||||
|
||||
/**
|
||||
* Settings modal.
|
||||
@@ -55,25 +60,29 @@ export function SettingsModal({ open, onClose }) {
|
||||
const s = useSettings();
|
||||
const [tested, setTested] = useState(false);
|
||||
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
|
||||
return html`<${Modal} open=${open} onClose=${onClose} title="设置" micro="SETTINGS" width="7.4rem"
|
||||
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>玩法说明<//>
|
||||
<${Button} variant="primary" icon="check" onClick=${onClose}>完成<//>`}>
|
||||
return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
|
||||
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
|
||||
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
|
||||
<div class="set-list">
|
||||
<${Slider} label="背景音乐" micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
|
||||
<${Slider} label="音效" micro="SFX" icon="signal" value=${s.sfx}
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label="静音" micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
<${Toggle} label="显示伤害数字" micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">画面质量<${MicroLabel}>QUALITY<//></span>
|
||||
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
|
||||
<${LangToggle} class="set-lang" />
|
||||
</div>
|
||||
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
|
||||
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
|
||||
<div class="set-seg" role="radiogroup">
|
||||
${QUALITY.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.quality === id ? 'true' : 'false'}
|
||||
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${label}</button>`)}
|
||||
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
${touchUi
|
||||
? html`<p class="set-hint">触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向</p>`
|
||||
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Q</kbd> 撤退选中干员 · <kbd>X</kbd> 出售选中干员 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
|
||||
? html`<p class="set-hint">${t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向')}</p>`
|
||||
: html`<p class="set-hint">${t('快捷键:')}${SHORTCUTS.map(([key, label]) => html`<kbd>${key}</kbd> ${t(label)} · `)}${t('右键查看详情')}</p>`}
|
||||
</div>
|
||||
<//>`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { h } from '../../vendor/preact.module.js';
|
||||
import { useEffect, useReducer } from '../../vendor/hooks.module.js';
|
||||
import htm from '../../vendor/htm.module.js';
|
||||
import { ERR_TEXT } from '../../../shared/constants.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
|
||||
function hFresh(type, props, ...children) {
|
||||
@@ -87,17 +88,18 @@ export function dismissToast(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Text for an error-ish value: NetError / {code,msg} / Error / string.
|
||||
* Text for an error-ish value: NetError / {code,msg} / Error / string — in the current language (the Chinese texts are
|
||||
* msgids: ERR_TEXT, net.js CLIENT_ERR_TEXT, a server `msg`; anything else is shown as it is).
|
||||
* @param {any} err
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeError(err) {
|
||||
if (!err) return '发生未知错误';
|
||||
if (typeof err === 'string') return ERR_TEXT[err] || err;
|
||||
if (err.code && ERR_TEXT[err.code]) return ERR_TEXT[err.code];
|
||||
if (typeof err.message === 'string' && err.message) return err.message;
|
||||
if (typeof err.msg === 'string' && err.msg) return err.msg;
|
||||
return '发生未知错误';
|
||||
if (!err) return t('发生未知错误');
|
||||
if (typeof err === 'string') return t(Object.hasOwn(ERR_TEXT, err) ? ERR_TEXT[err] : err);
|
||||
if (err.code && Object.hasOwn(ERR_TEXT, err.code)) return t(ERR_TEXT[err.code]);
|
||||
if (typeof err.message === 'string' && err.message) return t(err.message);
|
||||
if (typeof err.msg === 'string' && err.msg) return t(err.msg);
|
||||
return t('发生未知错误');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,7 @@ import { boardOrder, parseKey, tileKey } from './board.js';
|
||||
import { pieceBonds as bondsOfPiece } from './bondsMeta.js';
|
||||
import { registerAllMeta } from '../sim/content/index.js';
|
||||
import { registerBuiltins } from './builtinMeta.js';
|
||||
import { msg, dn } from '../../shared/i18n.js';
|
||||
|
||||
export const HOOKS = Object.freeze([
|
||||
'onRoundStart', 'onIncome', 'onPrepStart', 'onPrepEnd', 'onGain', 'onSold', 'onRefresh', 'onPrice', 'onBuy',
|
||||
@@ -525,7 +526,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
const name = got && got.name;
|
||||
if (name) {
|
||||
const who = grantSpeaker(gd, source);
|
||||
m.toast(ps, 'info', who ? `${who}:获得${name}` : `获得${name}`);
|
||||
m.toast(ps, 'info', who ? msg('{who}:获得{name}', { who: dn(who), name: dn(name) }) : msg('获得{name}', { name: dn(name) }));
|
||||
}
|
||||
}
|
||||
return p ? view(p) : null;
|
||||
@@ -635,8 +636,9 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
addBounty: (card) => m.addBounty(ps, card),
|
||||
|
||||
// ---- messaging
|
||||
toast: (text, kind = 'info') => m.toast(ps, kind, String(text)),
|
||||
ticker: (text) => m.tickerText(String(text)),
|
||||
// a string or a shared/i18n.js msg(msgid, params)
|
||||
toast: (text, kind = 'info') => m.toast(ps, kind, text && typeof text === 'object' ? text : String(text)),
|
||||
ticker: (text) => m.tickerText(text && typeof text === 'object' ? text : String(text)),
|
||||
/** CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}". */
|
||||
giftTicker: (fromName, chessId) => { const c = gd.chess(chessId); m.tickerFor('CHAR_GIFT', [String(fromName), c ? c.name : String(chessId)], { to: ps.playerId }); },
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// server/match/match/messaging.js — Match methods: messaging — unicast, broadcast, toasts, the ticker lines
|
||||
// (config.broadcasts by type; the remake's CUSTOM lines), the dirty marks and flush: m.private per player only when it
|
||||
// changed (plus the prep scouts of that player's board), m.public throttled (DELAYS.PUBLIC_THROTTLE) and deduplicated.
|
||||
// i18n (docs/I18N.md): a toast / CUSTOM line is a string (its own msgid) or a shared/i18n.js msg(msgid, params); the frame
|
||||
// carries `text` (the Chinese rendering, what older clients show) plus `msgid` / `params`; a broadcast line carries its
|
||||
// `args` so the client can fill the localized config.broadcasts template.
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { DELAYS } from './common.js';
|
||||
import { wireMessage } from '../../../shared/i18n.js';
|
||||
|
||||
export class MatchMessaging {
|
||||
sendTo(playerId, msg) {
|
||||
@@ -18,9 +22,10 @@ export class MatchMessaging {
|
||||
try { this.broadcastFn(msg); } catch (e) { this.reportError('broadcast', e); }
|
||||
}
|
||||
|
||||
/** @param {string|{ msgid: string, params?: object }} text a string or msg() (shared/i18n.js) */
|
||||
toast(ps, kind, text) {
|
||||
if (!ps || ps.isBot || ps.left || !ps.connected) return;
|
||||
this.sendTo(ps.playerId, { t: 'm.toast', kind, text });
|
||||
this.sendTo(ps.playerId, { t: 'm.toast', kind, ...wireMessage(text) });
|
||||
}
|
||||
|
||||
/** Broadcast ticker from config.broadcasts by type; `param` picks the variant (SHOP_LEVEL level, BOSS_HIT share…). */
|
||||
@@ -31,7 +36,7 @@ export class MatchMessaging {
|
||||
const tpl = b && typeof b.text === 'string' ? b.text : null;
|
||||
if (!tpl) return;
|
||||
const text = tpl.replace(/\{(\d)\}/g, (_, i) => (args[Number(i)] != null ? String(args[Number(i)]) : ''));
|
||||
const msg = { t: 'm.ticker', text, id: b.id, type, priority: Number(b.priority) || 0, playerId };
|
||||
const msg = { t: 'm.ticker', text, id: b.id, type, priority: Number(b.priority) || 0, playerId, args: args.map((a) => String(a ?? '')) };
|
||||
if (to) this.sendTo(to, msg);
|
||||
else this.broadcast(msg);
|
||||
}
|
||||
@@ -42,7 +47,8 @@ export class MatchMessaging {
|
||||
*/
|
||||
tickerText(text, priority = 0) {
|
||||
if (!text) return;
|
||||
this.broadcast({ t: 'm.ticker', text: String(text).slice(0, 200), id: null, type: 'CUSTOM', priority: Number(priority) || 0, playerId: null });
|
||||
const w = wireMessage(text);
|
||||
this.broadcast({ t: 'm.ticker', ...w, text: w.text.slice(0, 200), id: null, type: 'CUSTOM', priority: Number(priority) || 0, playerId: null });
|
||||
}
|
||||
|
||||
markPublic() { this._pubDirty = true; }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { C2S } from '../../../shared/protocol.js';
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { syntheticResult } from '../fields.js';
|
||||
import { FLOW_TICKER_PRIORITY, OK, fail } from './common.js';
|
||||
import { msg } from '../../../shared/i18n.js';
|
||||
|
||||
const GAME_TYPES = new Set(Object.keys(C2S).filter((t) => Object.hasOwn(C2S, t) && (t.startsWith('g.') || t.startsWith('b.'))));
|
||||
|
||||
@@ -171,7 +172,7 @@ export class MatchPlatform {
|
||||
}
|
||||
ps.lp = 0;
|
||||
ps.eliminate(passedRound);
|
||||
this.tickerText(`${ps.name}博士中途退出了模拟`, FLOW_TICKER_PRIORITY);
|
||||
this.tickerText(msg('{name}博士中途退出了模拟', { name: ps.name }), FLOW_TICKER_PRIORITY);
|
||||
if (this.bossWaves && (phase === PHASE.ROUND_START || phase === PHASE.SP_DRAFT || phase === PHASE.PREP)) {
|
||||
// before the boss fight: pair the players left again (the prep preview shows the new partner / template); a
|
||||
// player moved to the other half re-checks its board there at once (recompute → deployMap, marks it private)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PHASE, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { uniteSurvivors } from '../unite.js';
|
||||
import { buildResult } from '../results.js';
|
||||
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
|
||||
import { msg } from '../../../shared/i18n.js';
|
||||
|
||||
export class MatchSettle {
|
||||
settle(plan, uniteResult) {
|
||||
@@ -63,7 +64,7 @@ export class MatchSettle {
|
||||
ps.lp = 0;
|
||||
ps.eliminate(this.round);
|
||||
this.toast(ps, 'error', '你的目标生命值耗尽,已被淘汰');
|
||||
this.tickerText(`${ps.name}博士的目标生命值已耗尽`, FLOW_TICKER_PRIORITY);
|
||||
this.tickerText(msg('{name}博士的目标生命值已耗尽', { name: ps.name }), FLOW_TICKER_PRIORITY);
|
||||
}
|
||||
}
|
||||
this.fields = [];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { uniteBattleOpts, uniteSurvivors, uniteStageId } from '../unite.js';
|
||||
import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js';
|
||||
import { uniteLeft } from '../../sim/spec.js';
|
||||
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
|
||||
import { msg } from '../../../shared/i18n.js';
|
||||
|
||||
export class MatchUnite {
|
||||
startUnite(plan) {
|
||||
@@ -21,7 +22,7 @@ export class MatchUnite {
|
||||
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
|
||||
this._defaultWatch();
|
||||
this.markPublic();
|
||||
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY);
|
||||
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
|
||||
this._uniteLeftKey = null;
|
||||
this.runner = new FieldRunner(this, this.fields, {
|
||||
onTick: (runner) => this._uniteTick(runner),
|
||||
@@ -79,7 +80,7 @@ export class MatchUnite {
|
||||
this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) });
|
||||
}
|
||||
this.markPublic();
|
||||
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY);
|
||||
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
|
||||
}
|
||||
|
||||
_finishUniteClient() {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// the player state).
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { msg, dn } from '../../../shared/i18n.js';
|
||||
import { checkLoadout, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
|
||||
import { tileKey, boardOrder } from '../board.js';
|
||||
|
||||
@@ -175,7 +176,7 @@ export class PlayerBasics {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (names.length) this.m.toast(this, 'warn', `地形变化:${names.join('、')}无法停留在原位置,已撤回整备区`);
|
||||
if (names.length) this.m.toast(this, 'warn', msg('地形变化:{names}无法停留在原位置,已撤回整备区', { names: names.map(dn) }));
|
||||
return moved;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// the player state).
|
||||
|
||||
import { ERR } from '../../../shared/constants.js';
|
||||
import { msg, dn } from '../../../shared/i18n.js';
|
||||
import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js';
|
||||
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js';
|
||||
import { OK, fail } from './common.js';
|
||||
@@ -92,8 +93,8 @@ export class PlayerPlacement {
|
||||
this.board.delete(k);
|
||||
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
|
||||
}
|
||||
if (back.length) this.m.toast(this, 'warn', `${back.join('、')}只能部署在召唤者攻击范围内,已退回整备区`);
|
||||
if (gone.length) this.m.toast(this, 'warn', `${gone.join('、')}只能部署在召唤者攻击范围内,整备区已满,下回合返还`);
|
||||
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
|
||||
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
|
||||
return back.length + gone.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
// assumed 1).
|
||||
// Latched state lives in player counters (prefix `bondaddon:`); onPrice only reads them (pure).
|
||||
|
||||
import { msg, dn } from '../../../../../shared/i18n.js';
|
||||
|
||||
const C_VISI_PAID = 'bondaddon:visi:paid';
|
||||
const C_VISI_DISC = 'bondaddon:visi:disc';
|
||||
const C_MIRA_PAID = 'bondaddon:mira:paid';
|
||||
@@ -50,10 +52,10 @@ function settleCoins(ctx, bondId, counterKey, label) {
|
||||
const gain = (due - paid) * count;
|
||||
if (prepEnded(ctx)) {
|
||||
ctx.addPendingFunds(gain);
|
||||
ctx.toast(`【${label}】层数达成,下回合开始时获得${gain}资金`, 'info');
|
||||
ctx.toast(msg('【{label}】层数达成,下回合开始时获得{gain}资金', { label: dn(label), gain }), 'info');
|
||||
} else {
|
||||
ctx.addFunds(gain, `bond:${bondId}`);
|
||||
ctx.toast(`【${label}】层数达成,获得${gain}资金`, 'info');
|
||||
ctx.toast(msg('【{label}】层数达成,获得{gain}资金', { label: dn(label), gain }), 'info');
|
||||
}
|
||||
return gain;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { spawnYanyou } from '../tokens.js';
|
||||
import { kjeragColdWind } from '../devices.js';
|
||||
import * as items from '../items.js';
|
||||
import { FORCED_EXIT } from '../../constants.js';
|
||||
import { msg } from '../../../../shared/i18n.js';
|
||||
|
||||
const num = S.num;
|
||||
|
||||
@@ -637,7 +638,7 @@ export function payHammers(ctx) {
|
||||
if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++;
|
||||
}
|
||||
}
|
||||
if (granted && typeof ctx.toast === 'function') ctx.toast(`【维多利亚】获得${granted}件维式重锤`, 'info');
|
||||
if (granted && typeof ctx.toast === 'function') ctx.toast(msg('【维多利亚】获得{n}件维式重锤', { n: granted }), 'info');
|
||||
return granted;
|
||||
}
|
||||
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
// shared/i18n.js — gettext-style translation for the browser and Node (no framework, no dependency, no Node builtin).
|
||||
//
|
||||
// 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; English ('en') is a switch (the owner's decision of 2026-10-05: an English
|
||||
// browser still opens in Chinese until the player picks English). docs/I18N.md explains how to add and translate
|
||||
// strings.
|
||||
//
|
||||
// Context: the same Chinese text may need two translations ('关闭' = "Close" on a button, "Off" on a switch):
|
||||
// `tc('toggle', '关闭')` looks up the key 'toggle::关闭' first, then '关闭'; Chinese shows '关闭'.
|
||||
// Module-level tables are evaluated once, before a language can change: mark their strings N_('…') (a no-op that
|
||||
// tools/i18n.mjs extracts) and call t() where they are shown.
|
||||
//
|
||||
// Placeholders (the same in msgids and translations):
|
||||
// {name} the value of params.name (params may also be an array: {0}, {1} …)
|
||||
// {n|one|other} `one` when Number(params.n) === 1, else `other` — the only plural form (English needs no more)
|
||||
// A value that is an array renders as a list joined with the language's separator ('、' / ', '); a value `{ dn: '…' }`
|
||||
// (made by `dn()`) is a game-data name, translated through the name resolver the client installs (setNameResolver).
|
||||
// A placeholder whose value is missing stays verbatim, so official templates such as '{0}博士…' survive untouched.
|
||||
//
|
||||
// Server → client messages (m.toast / m.ticker): the server builds `msg(msgid, params)` and sends
|
||||
// `wireMessage(m)` = { text: <the Chinese rendering>, msgid, params } — `text` keeps older clients working, the client
|
||||
// renders `translateWire(frame)` in its own language. A plain string message is its own msgid.
|
||||
|
||||
/** Supported languages; the first one is the default and the source language of every msgid. */
|
||||
export const LANGS = Object.freeze(['zh', 'en']);
|
||||
export const DEFAULT_LANG = 'zh';
|
||||
|
||||
/** List separator per language (array params). */
|
||||
const LIST_SEP = Object.freeze({ zh: '、', en: ', ' });
|
||||
|
||||
/** @type {Map<string, Map<string, string>>} lang → msgid → translation */
|
||||
const catalogs = new Map();
|
||||
let current = DEFAULT_LANG;
|
||||
/** @type {Set<(lang: string, prev: string) => void>} */
|
||||
const listeners = new Set();
|
||||
/** @type {((name: string, lang: string) => string) | null} */
|
||||
let nameResolver = null;
|
||||
|
||||
/**
|
||||
* A supported language code from loose input ('en-US' → 'en', 'zh_CN' → 'zh'), or null.
|
||||
* @param {unknown} v
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeLang(v) {
|
||||
const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
|
||||
if (!s) return null;
|
||||
for (const l of LANGS) if (s === l || s.startsWith(`${l}-`) || s.startsWith(`${l}_`)) return l;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The current language code. */
|
||||
export function getLang() { return current; }
|
||||
|
||||
/**
|
||||
* Switch the language (unknown codes fall back to the default). Listeners run when it actually changed.
|
||||
* @param {unknown} lang
|
||||
* @returns {boolean} true when the language changed
|
||||
*/
|
||||
export function setLang(lang) {
|
||||
const next = normalizeLang(lang) || DEFAULT_LANG;
|
||||
if (next === current) return false;
|
||||
const prev = current;
|
||||
current = next;
|
||||
for (const fn of [...listeners]) {
|
||||
try { fn(next, prev); } catch (err) { /** @type {any} */ (globalThis).console?.error('[i18n] listener failed', err); }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to language changes.
|
||||
* @param {(lang: string, prev: string) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
export function onLangChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translations for a language (merged into what is there). Keys starting with '_' are metadata and skipped; only
|
||||
* non-empty string values count.
|
||||
* @param {string} lang
|
||||
* @param {Record<string, unknown> | null | undefined} dict msgid → translation
|
||||
* @returns {number} entries added
|
||||
*/
|
||||
export function addMessages(lang, dict) {
|
||||
const l = normalizeLang(lang);
|
||||
if (!l || !dict || typeof dict !== 'object') return 0;
|
||||
let cat = catalogs.get(l);
|
||||
if (!cat) { cat = new Map(); catalogs.set(l, cat); }
|
||||
let n = 0;
|
||||
for (const [k, v] of Object.entries(dict)) {
|
||||
if (k.startsWith('_') || typeof v !== 'string' || !v) continue;
|
||||
cat.set(k, v);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a language's translations.
|
||||
* @param {string} lang
|
||||
* @param {Record<string, unknown> | null | undefined} dict
|
||||
* @returns {number}
|
||||
*/
|
||||
export function setMessages(lang, dict) {
|
||||
const l = normalizeLang(lang);
|
||||
if (!l) return 0;
|
||||
catalogs.delete(l);
|
||||
return addMessages(l, dict);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a msgid has a translation in a language (the default language always "has" its own msgids).
|
||||
* @param {string} msgid
|
||||
* @param {string} [lang]
|
||||
*/
|
||||
export function hasMessage(msgid, lang = current) {
|
||||
const l = normalizeLang(lang) || DEFAULT_LANG;
|
||||
return l === DEFAULT_LANG || !!catalogs.get(l)?.has(String(msgid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the translator of game-data names (`{ dn: '…' }` params and `tName`). The client maps a Chinese name to the
|
||||
* current language through the localized game data; without a resolver names stay Chinese.
|
||||
* @param {((name: string, lang: string) => string) | null} fn
|
||||
*/
|
||||
export function setNameResolver(fn) { nameResolver = typeof fn === 'function' ? fn : null; }
|
||||
|
||||
/**
|
||||
* A game-data name (operator, item, bond …) in the current language.
|
||||
* @param {unknown} name the Chinese name from the data
|
||||
* @param {string} [lang]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tName(name, lang = current) {
|
||||
const s = name == null ? '' : String(name);
|
||||
if (!s || lang === DEFAULT_LANG || !nameResolver) return s;
|
||||
try { return nameResolver(s, lang) || s; } catch { return s; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a param as a game-data name (translated by the client's name resolver).
|
||||
* @param {unknown} name
|
||||
* @returns {{ dn: string }}
|
||||
*/
|
||||
export const dn = (name) => ({ dn: name == null ? '' : String(name) });
|
||||
|
||||
const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
|
||||
|
||||
/**
|
||||
* Render one param value.
|
||||
* @param {unknown} v
|
||||
* @param {string} lang
|
||||
* @param {((name: string, lang: string) => string) | null} resolve
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderValue(v, lang, resolve) {
|
||||
if (v == null) return '';
|
||||
if (Array.isArray(v)) return v.map((x) => renderValue(x, lang, resolve)).filter((s) => s !== '').join(LIST_SEP[lang] || LIST_SEP.en);
|
||||
if (typeof v === 'object') {
|
||||
if (own(v, 'dn')) {
|
||||
const name = String(/** @type {any} */ (v).dn ?? '');
|
||||
if (!resolve || lang === DEFAULT_LANG || !name) return name;
|
||||
try { return resolve(name, lang) || name; } catch { return name; }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
const PLACEHOLDER = /\{([A-Za-z0-9_$]+)(?:\|([^{}|]*)\|([^{}|]*))?\}/g;
|
||||
|
||||
/**
|
||||
* Fill a template's placeholders (pure: no global language state).
|
||||
* @param {unknown} template
|
||||
* @param {Record<string, unknown> | unknown[] | null | undefined} [params]
|
||||
* @param {{ lang?: string, resolveName?: ((name: string, lang: string) => string) | null }} [opts]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function format(template, params, opts = {}) {
|
||||
const s = template == null ? '' : String(template);
|
||||
if (!params || typeof params !== 'object' || !s.includes('{')) return s;
|
||||
const lang = normalizeLang(opts.lang) || DEFAULT_LANG;
|
||||
const resolve = opts.resolveName || null;
|
||||
return s.replace(PLACEHOLDER, (m, key, one, other) => {
|
||||
if (!own(params, key)) return m;
|
||||
const v = /** @type {any} */ (params)[key];
|
||||
if (one !== undefined) return Number(v) === 1 ? one : other;
|
||||
return renderValue(v, lang, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a message into the current language (gettext style: the msgid is the Chinese text).
|
||||
* @param {unknown} msgid
|
||||
* @param {Record<string, unknown> | unknown[] | null} [params]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(msgid, params) {
|
||||
const id = msgid == null ? '' : String(msgid);
|
||||
let text = id;
|
||||
if (current !== DEFAULT_LANG) {
|
||||
const tr = catalogs.get(current)?.get(id);
|
||||
if (tr) text = tr;
|
||||
}
|
||||
return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a message with a context (see the header): the key `${context}::${msgid}` first, then the msgid alone.
|
||||
* @param {string} context
|
||||
* @param {unknown} msgid
|
||||
* @param {Record<string, unknown> | unknown[] | null} [params]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tc(context, msgid, params) {
|
||||
const id = msgid == null ? '' : String(msgid);
|
||||
if (current !== DEFAULT_LANG) {
|
||||
const tr = catalogs.get(current)?.get(`${context}::${id}`);
|
||||
if (tr) return params ? format(tr, params, { lang: current, resolveName: nameResolver }) : tr;
|
||||
}
|
||||
return t(id, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a string as a msgid without translating it (module-level tables: translate with t() where it is shown).
|
||||
* @template {string} S
|
||||
* @param {S} s
|
||||
* @returns {S}
|
||||
*/
|
||||
export const N_ = (s) => s;
|
||||
|
||||
/**
|
||||
* A structured message (server → client): the msgid with its params.
|
||||
* @param {string} msgid
|
||||
* @param {Record<string, unknown> | unknown[]} [params]
|
||||
* @returns {{ msgid: string, params: Record<string, unknown> | unknown[] | undefined }}
|
||||
*/
|
||||
export const msg = (msgid, params) => ({ msgid: String(msgid), params });
|
||||
|
||||
/** @param {unknown} m @returns {m is { msgid: string, params?: any }} */
|
||||
const isMsg = (m) => !!m && typeof m === 'object' && typeof (/** @type {any} */ (m).msgid) === 'string';
|
||||
|
||||
/**
|
||||
* The Chinese text of a message (a plain string is returned as is).
|
||||
* @param {unknown} m a string or msg()
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderMessage(m) {
|
||||
if (isMsg(m)) return format(m.msgid, m.params, { lang: DEFAULT_LANG });
|
||||
return m == null ? '' : String(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-safe copy of message params: strings, finite numbers, booleans, `{ dn }` and arrays of those (anything else is
|
||||
* dropped), so a frame never carries an object the client would have to trust.
|
||||
* @param {unknown} v
|
||||
* @param {number} [depth]
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function cleanParam(v, depth = 0) {
|
||||
if (typeof v === 'string') return v.slice(0, 200);
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'boolean') return v;
|
||||
if (Array.isArray(v) && depth < 1) return v.slice(0, 32).map((x) => cleanParam(x, depth + 1));
|
||||
if (v && typeof v === 'object' && own(v, 'dn')) return { dn: String(/** @type {any} */ (v).dn ?? '').slice(0, 200) };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire fields of a message for m.toast / m.ticker: `{ text }` for a plain string, `{ text, msgid, params }` for a
|
||||
* msg() — `text` is the Chinese rendering (older clients show it as before).
|
||||
* @param {unknown} m
|
||||
* @returns {{ text: string, msgid?: string, params?: Record<string, unknown> | unknown[] }}
|
||||
*/
|
||||
export function wireMessage(m) {
|
||||
if (!isMsg(m)) return { text: renderMessage(m) };
|
||||
const out = { text: renderMessage(m), msgid: m.msgid };
|
||||
const p = m.params;
|
||||
if (Array.isArray(p)) return { ...out, params: p.slice(0, 16).map((x) => cleanParam(x)) };
|
||||
if (p && typeof p === 'object') {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const params = {};
|
||||
for (const [k, v] of Object.entries(p).slice(0, 16)) if (/^[A-Za-z0-9_$]{1,32}$/.test(k)) params[k] = cleanParam(v);
|
||||
return { ...out, params };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of a received m.toast / m.ticker frame in the current language: its msgid with params when present, else
|
||||
* its text taken as a msgid (static server texts are their own msgid). Missing translations show the Chinese text.
|
||||
* @param {{ text?: unknown, msgid?: unknown, params?: unknown } | null | undefined} frame
|
||||
* @returns {string}
|
||||
*/
|
||||
export function translateWire(frame) {
|
||||
if (!frame || typeof frame !== 'object') return '';
|
||||
const params = frame.params && typeof frame.params === 'object' ? /** @type {any} */ (frame.params) : null;
|
||||
if (typeof frame.msgid === 'string' && frame.msgid) return t(frame.msgid, params);
|
||||
return typeof frame.text === 'string' ? t(frame.text) : '';
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// shared/i18nData.js — the localized game-data overlay (data/i18n/<lang>.json): its format, written by
|
||||
// tools/build-i18n.mjs and applied by public/js/data.js (also usable under Node).
|
||||
//
|
||||
// { version: 1, lang: 'en', meta: { … sources, coverage … },
|
||||
// names: { <Chinese name>: <name> }, // every translated game-data name (ticker / toast args)
|
||||
// files: { <data file>: { <record id>: RecordOverlay } } }
|
||||
//
|
||||
// A RecordOverlay mirrors the record of data/<file>.json: a string leaf replaces the Chinese string at the same path,
|
||||
// an object recurses, an array is index-aligned (null = keep the Chinese entry). `_h` (on every record overlay) holds
|
||||
// the 2-character hash (textHash2) of each Chinese string a leaf replaces, in walk order (object keys in their order,
|
||||
// array entries by index, `_h` itself skipped). A record that is a plain string (config `seasonName`) has { _s, _h }. A leaf whose Chinese source no longer hashes the same — data/*.json was
|
||||
// rebuilt after the overlay — is not applied: the player sees the current Chinese text rather than a stale translation.
|
||||
|
||||
export const OVERLAY_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 32-bit FNV-1a of a string, as 2 base-36 characters (1296 buckets): the per-leaf staleness check.
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
export function textHash2(s) {
|
||||
let h = 0x811c9dc5;
|
||||
const str = String(s);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return (h % 1296).toString(36).padStart(2, '0');
|
||||
}
|
||||
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/**
|
||||
* Visit every string leaf of a record overlay in walk order.
|
||||
* @param {any} ov record overlay (or a sub-tree of it)
|
||||
* @param {(path: (string|number)[], text: string) => void} visit
|
||||
* @param {(string|number)[]} [path]
|
||||
*/
|
||||
export function walkOverlay(ov, visit, path = []) {
|
||||
if (typeof ov === 'string') { visit(path, ov); return; }
|
||||
if (Array.isArray(ov)) {
|
||||
for (let i = 0; i < ov.length; i++) if (ov[i] != null) walkOverlay(ov[i], visit, [...path, i]);
|
||||
return;
|
||||
}
|
||||
if (isObj(ov)) {
|
||||
for (const k of Object.keys(ov)) {
|
||||
if (path.length === 0 && k === '_h') continue;
|
||||
walkOverlay(ov[k], visit, [...path, k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The value at a path of a record, or undefined.
|
||||
* @param {any} base
|
||||
* @param {(string|number)[]} path
|
||||
*/
|
||||
export function getPath(base, path) {
|
||||
let cur = base;
|
||||
for (const k of path) {
|
||||
if (cur == null || typeof cur !== 'object') return undefined;
|
||||
cur = cur[k];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** A path as a lookup key (an array record's top-level indexes become object keys, so numbers and strings match). */
|
||||
const pathKey = (path) => path.map(String).join('\u0000');
|
||||
|
||||
/**
|
||||
* Build a record overlay from its leaves (the build tool): sets each `en` at its path and computes `_h` from the
|
||||
* Chinese sources in the final walk order. A record that is itself an array (config `tips`, `titles` …) gets an object
|
||||
* overlay keyed by index ("0", "1" …: integer keys keep index order).
|
||||
* @param {{ path: (string|number)[], zh: string, en: string }[]} leaves
|
||||
* @returns {any|null} null when there is no leaf
|
||||
*/
|
||||
export function buildRecordOverlay(leaves) {
|
||||
if (!leaves.length) return null;
|
||||
// a record that is a plain string (config `seasonName`): { _s, _h }
|
||||
if (leaves.length === 1 && leaves[0].path.length === 0) return { _s: leaves[0].en, _h: textHash2(leaves[0].zh) };
|
||||
/** @type {any} */
|
||||
const root = {};
|
||||
const zhAt = new Map();
|
||||
for (const { path, zh, en } of leaves) {
|
||||
let cur = root;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const k = path[i];
|
||||
const nextIsIndex = typeof path[i + 1] === 'number';
|
||||
if (cur[k] == null) cur[k] = nextIsIndex ? [] : {};
|
||||
cur = cur[k];
|
||||
}
|
||||
cur[path[path.length - 1]] = en;
|
||||
zhAt.set(pathKey(path), zh);
|
||||
}
|
||||
// sparse arrays → explicit nulls (JSON has no holes)
|
||||
const fill = (v) => {
|
||||
if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) v[i] = v[i] === undefined ? null : fill(v[i]); return v; }
|
||||
if (isObj(v)) { for (const k of Object.keys(v)) v[k] = fill(v[k]); return v; }
|
||||
return v;
|
||||
};
|
||||
fill(root);
|
||||
let h = '';
|
||||
walkOverlay(root, (path) => { h += textHash2(zhAt.get(pathKey(path)) ?? ''); });
|
||||
return { ...root, _h: h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a record overlay to a Chinese record: a copy that shares every untouched branch (copy-on-write), with each leaf
|
||||
* whose Chinese source still matches its hash replaced. The input record is never modified.
|
||||
* @param {any} base
|
||||
* @param {any} ov
|
||||
* @returns {{ value: any, applied: number, stale: number }}
|
||||
*/
|
||||
export function applyRecordOverlay(base, ov) {
|
||||
if (isObj(ov) && typeof ov._s === 'string') {
|
||||
const ok = typeof base === 'string' && textHash2(base) === ov._h;
|
||||
return { value: ok ? ov._s : base, applied: ok ? 1 : 0, stale: ok ? 0 : 1 };
|
||||
}
|
||||
if (!isObj(ov) || base == null || typeof base !== 'object') return { value: base, applied: 0, stale: 0 };
|
||||
const hashes = typeof ov._h === 'string' ? ov._h : '';
|
||||
let i = 0;
|
||||
let applied = 0;
|
||||
let stale = 0;
|
||||
const rec = (b, o, depth) => {
|
||||
if (typeof o === 'string') {
|
||||
const want = hashes.slice(i * 2, i * 2 + 2);
|
||||
i++;
|
||||
if (typeof b === 'string' && want && textHash2(b) === want) { applied++; return o; }
|
||||
stale++;
|
||||
return b;
|
||||
}
|
||||
if (Array.isArray(o)) {
|
||||
if (!Array.isArray(b)) { walkOverlay(o, () => { i++; stale++; }); return b; }
|
||||
let out = b;
|
||||
for (let k = 0; k < o.length; k++) {
|
||||
if (o[k] == null) continue;
|
||||
const nv = rec(b[k], o[k], depth + 1);
|
||||
if (nv !== b[k]) { if (out === b) out = b.slice(); out[k] = nv; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (isObj(o)) {
|
||||
if (b == null || typeof b !== 'object') { walkOverlay(o, () => { i++; stale++; }); return b; }
|
||||
// an array record's overlay is keyed by index (see buildRecordOverlay)
|
||||
const arr = Array.isArray(b);
|
||||
let out = b;
|
||||
for (const k of Object.keys(o)) {
|
||||
if (depth === 0 && k === '_h') continue;
|
||||
const at = arr ? (/^\d+$/.test(k) ? Number(k) : -1) : k;
|
||||
const cur = at === -1 ? undefined : b[at];
|
||||
const nv = rec(cur, o[k], depth + 1);
|
||||
if (at !== -1 && nv !== cur) { if (out === b) out = arr ? b.slice() : { ...b }; out[at] = nv; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
const value = rec(base, ov, 0);
|
||||
return { value, applied, stale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a file's overlay to a whole data file (the `{ id: record }` maps of data/*.json; other shapes are returned
|
||||
* unchanged). Untouched records are shared with the input.
|
||||
* @param {any} json the Chinese file
|
||||
* @param {Record<string, any> | null | undefined} fileOverlay `files[<name>]` of the overlay
|
||||
* @returns {{ value: any, applied: number, stale: number }}
|
||||
*/
|
||||
export function applyFileOverlay(json, fileOverlay) {
|
||||
if (!isObj(json) || !isObj(fileOverlay)) return { value: json, applied: 0, stale: 0 };
|
||||
let out = json;
|
||||
let applied = 0;
|
||||
let stale = 0;
|
||||
for (const [id, ov] of Object.entries(fileOverlay)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(json, id)) continue;
|
||||
const r = applyRecordOverlay(json[id], ov);
|
||||
applied += r.applied;
|
||||
stale += r.stale;
|
||||
if (r.value !== json[id]) { if (out === json) out = { ...json }; out[id] = r.value; }
|
||||
}
|
||||
return { value: out, applied, stale };
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Localized game data: the overlay format (shared/i18nData.js), its build (tools/build-i18n.mjs → data/i18n/en.json:
|
||||
// shape, freshness against data/*.json, coverage, rich text) and the client accessors (public/js/data.js setLocale).
|
||||
// docs/I18N.md.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildRecordOverlay, applyRecordOverlay, applyFileOverlay, walkOverlay, getPath, textHash2 } from '../shared/i18nData.js';
|
||||
import { buildOverlay, inverseFormat, shapeKey, DATA_FILES, stripRich } from '../tools/build-i18n.mjs';
|
||||
import { createDataStore } from '../public/js/data.js';
|
||||
import { parseRichText, richTextPlain } from '../public/js/ui/richText.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
const OVERLAY = readJson('data/i18n/en.json');
|
||||
const HAN = /[一-鿿]/;
|
||||
|
||||
test('overlay records: build → apply round trip, copy-on-write, array and string records', () => {
|
||||
const base = { name: '炎', n: 3, skills: [{ name: '甲', desc: '一' }, { name: '乙', desc: '二' }], keep: { x: '中' } };
|
||||
const ov = buildRecordOverlay([
|
||||
{ path: ['name'], zh: '炎', en: 'Yan' },
|
||||
{ path: ['skills', 1, 'desc'], zh: '二', en: 'two' },
|
||||
]);
|
||||
assert.deepEqual(ov.skills, [null, { desc: 'two' }], 'arrays are index-aligned, null keeps the entry');
|
||||
assert.equal(ov._h, textHash2('炎') + textHash2('二'));
|
||||
const r = applyRecordOverlay(base, ov);
|
||||
assert.equal(r.applied, 2);
|
||||
assert.equal(r.value.name, 'Yan');
|
||||
assert.equal(r.value.skills[1].desc, 'two');
|
||||
assert.equal(r.value.skills[0], base.skills[0], 'untouched branches are shared');
|
||||
assert.equal(r.value.keep, base.keep);
|
||||
assert.equal(base.name, '炎', 'the input is never modified');
|
||||
// an array record (config tips) and a plain-string record (config seasonName)
|
||||
const tips = [{ tip: '甲' }, { tip: '乙' }];
|
||||
const tov = buildRecordOverlay([{ path: [1, 'tip'], zh: '乙', en: 'B' }]);
|
||||
assert.deepEqual(applyRecordOverlay(tips, tov).value, [{ tip: '甲' }, { tip: 'B' }]);
|
||||
const sov = buildRecordOverlay([{ path: [], zh: '卫戍协议:盟约', en: 'Stronghold Protocol: Alliance' }]);
|
||||
assert.equal(applyRecordOverlay('卫戍协议:盟约', sov).value, 'Stronghold Protocol: Alliance');
|
||||
});
|
||||
|
||||
test('overlay records: a leaf whose Chinese source changed stays Chinese (no stale translation)', () => {
|
||||
const ov = buildRecordOverlay([{ path: ['desc'], zh: '攻击力+15%', en: 'ATK +15%' }, { path: ['name'], zh: '维式重锤', en: 'Victorian Hammer' }]);
|
||||
const r = applyRecordOverlay({ name: '维式重锤', desc: '攻击力+20%' }, ov);
|
||||
assert.deepEqual(r.value, { name: 'Victorian Hammer', desc: '攻击力+20%' });
|
||||
assert.equal(r.applied, 1);
|
||||
assert.equal(r.stale, 1);
|
||||
const f = applyFileOverlay({ a: { name: '甲' }, b: { name: '乙' } }, { a: buildRecordOverlay([{ path: ['name'], zh: '甲', en: 'A' }]), gone: { name: 'x', _h: '00' } });
|
||||
assert.deepEqual(f.value, { a: { name: 'A' }, b: { name: '乙' } }, 'records missing from the data are skipped');
|
||||
});
|
||||
|
||||
test('build-i18n matching: exact, blackboard templates, plain from raw, composites, fallback order, notes, names', () => {
|
||||
const zh = [{ chars: { char_x: { name: '测试员', description: '攻击造成<@ba.kw>法术伤害</>', skill: { levels: [{ name: '强攻', description: '攻击力<@ba.vup>+{atk:0%}</>,持续{duration}秒' }] } } } }];
|
||||
const en = [{ chars: { char_x: { name: 'Tester', description: 'Attacks deal <@ba.kw>Arts damage</>', skill: { levels: [{ name: 'Assault', description: 'ATK <@ba.vup>+{atk:0%}</> for {duration} seconds' }] } } } }];
|
||||
const data = {
|
||||
chess: {
|
||||
chess_x_a: {
|
||||
charId: 'char_x', name: '测试员',
|
||||
trait: { desc: '攻击造成法术伤害', descRaw: '攻击造成<@ba.kw>法术伤害</>' },
|
||||
skill: { skillId: 'sk_x', name: '强攻', descRaw: '攻击力<@ba.vup>+80%</>,持续30秒', desc: '攻击力+80%,持续30秒', bb: { atk: 0.8 }, duration: 30 },
|
||||
spec: { note: '研究笔记' },
|
||||
},
|
||||
},
|
||||
stages: { m1: { name: '战场#05(下半) 测试员' }, m2: { name: '战场#04 活性源石' } },
|
||||
config: { seasonName: '卫戍协议:盟约', tips: [{ tip: '无官方的提示' }, { tip: '界面里的话' }] },
|
||||
};
|
||||
const { overlay, report } = buildOverlay({
|
||||
zh, en, data,
|
||||
fallback: { remake: { 活性源石: 'Active Originium', '卫戍协议:盟约': 'Stronghold Protocol: Alliance' }, pr70: { 无官方的提示: 'A tip', 界面里的话: 'PR wording' }, ui: { 界面里的话: 'UI wording' } },
|
||||
});
|
||||
const c = applyRecordOverlay(data.chess.chess_x_a, overlay.files.chess.chess_x_a).value;
|
||||
assert.equal(c.name, 'Tester');
|
||||
assert.equal(c.trait.descRaw, 'Attacks deal <@ba.kw>Arts damage</>');
|
||||
assert.equal(c.trait.desc, 'Attacks deal Arts damage', 'the plain text is the stripped raw translation');
|
||||
assert.equal(c.skill.descRaw, 'ATK <@ba.vup>+80%</> for 30 seconds', 'numbers read back from the Chinese and filled in');
|
||||
assert.equal(c.skill.desc, 'ATK +80% for 30 seconds');
|
||||
assert.equal(c.spec.note, '研究笔记', 'research notes are not translated');
|
||||
const st = applyFileOverlay(data.stages, overlay.files.stages).value;
|
||||
assert.equal(st.m1.name, 'Battlefield #05 (Second Half) Tester');
|
||||
assert.equal(st.m2.name, 'Battlefield #04 Active Originium');
|
||||
const cfg = applyFileOverlay(data.config, overlay.files.config).value;
|
||||
assert.equal(cfg.seasonName, 'Stronghold Protocol: Alliance');
|
||||
assert.deepEqual(cfg.tips.map((x) => x.tip), ['A tip', 'PR wording'], 'remake > PR #70 > UI table');
|
||||
assert.equal(overlay.names['测试员'], 'Tester');
|
||||
assert.equal(report.coverage.operators.translated, 1);
|
||||
assert.equal(report.coverage.skills.template, 2);
|
||||
assert.equal(report.coverage.operators.notes, 1);
|
||||
assert.deepEqual(inverseFormat('攻击力+{atk:0%},{-x}格', '攻击力+80%,-2格'), { bb: { atk: 0.8, x: 2 }, bbStr: {} });
|
||||
assert.equal(inverseFormat('攻击力+{atk:0%}', '攻击力+80'), null);
|
||||
assert.equal(shapeKey('攻击力<@ba.vup>+{atk:0%}</>'), shapeKey('攻击力+80%'));
|
||||
});
|
||||
|
||||
test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and the names map', () => {
|
||||
assert.equal(OVERLAY.version, 1);
|
||||
assert.equal(OVERLAY.lang, 'en');
|
||||
assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半');
|
||||
// the 自选 data (0.2.0) brings 72 owned 6★, some of them (and some newer modules) not in the EN client yet: their
|
||||
// names, skills, talents and modules stay Chinese — measured 97.6 % in all, skills 95.9 / talents 95.1 % (2026-10-05)
|
||||
assert.ok(OVERLAY.meta.totals.pct >= 97, `coverage ${OVERLAY.meta.totals.pct} %`);
|
||||
for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) {
|
||||
const c = OVERLAY.meta.coverage[kind];
|
||||
assert.ok(c && c.texts > 0, `coverage of ${kind}`);
|
||||
assert.ok(c.pct >= (['operators', 'skills', 'talents', 'modules'].includes(kind) ? 95 : 98), `${kind}: ${c.pct} %`);
|
||||
}
|
||||
assert.deepEqual(Object.keys(OVERLAY.files).filter((f) => !DATA_FILES.includes(f)), []);
|
||||
assert.equal(OVERLAY.names['琳琅诗怀雅'], 'Swire the Elegant Wit');
|
||||
assert.equal(OVERLAY.names['维式重锤'], 'Victorian Hammer');
|
||||
assert.equal(OVERLAY.names['奇迹'], 'Marvel');
|
||||
for (const [zh, en] of Object.entries(OVERLAY.names)) assert.ok(HAN.test(zh) && !HAN.test(en), `${zh} → ${en}`);
|
||||
});
|
||||
|
||||
test('data/i18n/en.json matches the current data/*.json: every leaf replaces a Chinese string that still hashes the same', () => {
|
||||
// A failure here means data/*.json was rebuilt after the overlay: run `node tools/build-i18n.mjs` (docs/I18N.md).
|
||||
for (const [file, fov] of Object.entries(OVERLAY.files)) {
|
||||
const data = readJson(`data/${file}.json`);
|
||||
let leaves = 0;
|
||||
for (const [id, rov] of Object.entries(fov)) {
|
||||
if (typeof rov._s === 'string') { assert.equal(typeof data[id], 'string', `${file}.${id}`); leaves++; continue; }
|
||||
walkOverlay(rov, (p, enText) => {
|
||||
const zhText = getPath(data[id], p);
|
||||
assert.equal(typeof zhText, 'string', `${file}.${id}.${p.join('.')} is not a string in data/${file}.json`);
|
||||
assert.ok(HAN.test(zhText), `${file}.${id}.${p.join('.')}: not a Chinese text`);
|
||||
assert.ok(enText.trim(), `${file}.${id}.${p.join('.')}: empty translation`);
|
||||
leaves++;
|
||||
});
|
||||
}
|
||||
const r = applyFileOverlay(data, fov);
|
||||
assert.equal(r.stale, 0, `${file}: ${r.stale} translations are stale (run node tools/build-i18n.mjs)`);
|
||||
assert.equal(r.applied, leaves, file);
|
||||
}
|
||||
});
|
||||
|
||||
test('official EN skill texts keep the rich-text markup and the remake\'s numbers (ui/richText.js renders them)', () => {
|
||||
const chess = readJson('data/chess.json');
|
||||
const cases = [['chess_char_3_04_a', 1, '155%'], ['chess_char_3_04_b', 1, '170%'], ['chess_char_1_01_a', 1, '+80%']];
|
||||
for (const [id, idx, number] of cases) {
|
||||
const zhRec = chess[id];
|
||||
const en = applyRecordOverlay(zhRec, OVERLAY.files.chess[id]).value;
|
||||
const zhSkill = zhRec.skills[idx];
|
||||
const enSkill = en.skills[idx];
|
||||
assert.ok(!HAN.test(enSkill.descRaw), `${id}: ${enSkill.descRaw}`);
|
||||
const vup = (s) => parseRichText(s).filter((x) => x.cls && x.cls.includes('ba.vup')).map((x) => x.text);
|
||||
assert.ok(vup(zhSkill.descRaw).includes(number) && vup(enSkill.descRaw).includes(number), `${id}: ${number} highlighted in both languages`);
|
||||
assert.equal(richTextPlain(enSkill.descRaw), enSkill.desc, 'desc is the plain form of descRaw');
|
||||
assert.equal((enSkill.descRaw.match(/<[@$][^<>]*>/g) || []).length, (enSkill.descRaw.match(/<\/>/g) || []).length, 'tags closed');
|
||||
}
|
||||
// every translated skill: it parses, no placeholder is left, plain = stripped raw (a few official EN texts leave a
|
||||
// tag unclosed, e.g. skchr_excu2_1's <@ba.rem>; the parser takes that as running to the end, as the game does)
|
||||
for (const [id, rov] of Object.entries(OVERLAY.files.chess)) {
|
||||
const rec = applyRecordOverlay(chess[id], rov).value;
|
||||
for (const s of rec.skills || []) {
|
||||
if (HAN.test(s.descRaw)) continue;
|
||||
assert.ok(parseRichText(s.descRaw).length > 0, `${id} ${s.skillId}`);
|
||||
assert.ok(!/\{-?[A-Za-z_@][^{}:]*(?::[^{}]+)?\}/.test(s.descRaw), `${id} ${s.skillId}: placeholder left: ${s.descRaw}`);
|
||||
assert.equal(stripRich(s.descRaw), s.desc, `${id} ${s.skillId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** A fetch over the repo's data/ (data/i18n/en.json included). */
|
||||
function diskFetch(calls) {
|
||||
return async (url) => {
|
||||
calls.push(url);
|
||||
const rel = url.replace(/^\/data\//, 'data/');
|
||||
const abs = path.join(ROOT, rel);
|
||||
if (!existsSync(abs)) return { ok: false, status: 404, json: async () => null };
|
||||
const text = readFileSync(abs, 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(text) };
|
||||
};
|
||||
}
|
||||
|
||||
test('data.js setLocale: getters return English records after the overlay loads, Chinese again after zh', async () => {
|
||||
const calls = [];
|
||||
const d = createDataStore({ fetch: diskFetch(calls), retryDelays: [] });
|
||||
await d.loadAll('chess', 'config', 'bonds');
|
||||
const seen = [];
|
||||
d.subscribe((n) => seen.push(n));
|
||||
assert.equal(d.lookup('chess', 'chess_char_3_04_a').name, '琳琅诗怀雅');
|
||||
assert.equal(await d.setLocale('en'), 'en');
|
||||
assert.equal(calls.filter((u) => u === '/data/i18n/en.json').length, 1);
|
||||
assert.deepEqual(seen.sort(), ['bonds', 'chess', 'config'], 'every loaded file notified');
|
||||
const swire = d.lookup('chess', 'chess_char_3_04_a');
|
||||
assert.equal(swire.name, 'Swire the Elegant Wit');
|
||||
assert.equal(swire.subProfessionName, 'Merchant');
|
||||
assert.equal(swire.stats.atk, 672, 'numbers untouched');
|
||||
assert.equal(d.list('bonds').find((b) => b.bondId === 'yanShip').name, 'Yan');
|
||||
assert.equal(d.get('config').modes.mode_multi_hard.name, 'Dire Simulation');
|
||||
assert.equal(d.getRaw('config').modes.mode_multi_hard.name, '绝境模拟', 'getRaw keeps the Chinese file');
|
||||
assert.equal(d.localeName('琳琅诗怀雅'), 'Swire the Elegant Wit');
|
||||
assert.equal(d.localeName('不存在的名字'), '不存在的名字');
|
||||
await d.load('items'); // loaded after the switch: localized too
|
||||
assert.equal(d.lookup('items', 'chess_item_1_01_e_a').name, 'Victorian Hammer');
|
||||
assert.equal(await d.setLocale('zh'), 'zh');
|
||||
assert.equal(d.lookup('chess', 'chess_char_3_04_a').name, '琳琅诗怀雅');
|
||||
assert.equal(d.localeName('琳琅诗怀雅'), '琳琅诗怀雅');
|
||||
await d.setLocale('en');
|
||||
assert.equal(calls.filter((u) => u === '/data/i18n/en.json').length, 1, 'the overlay downloads once');
|
||||
});
|
||||
|
||||
test('data.js setLocale: without an overlay the texts stay Chinese (no throw, no switch)', async () => {
|
||||
const d = createDataStore({ fetch: async () => ({ ok: false, status: 404, json: async () => null }), retryDelays: [] });
|
||||
const w = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
assert.equal(await d.setLocale('en'), 'zh');
|
||||
assert.equal(d.locale(), 'zh');
|
||||
} finally { console.warn = w; }
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Server → client texts in the player's language (docs/I18N.md): m.toast / m.ticker frames carry the Chinese `text`
|
||||
// (older clients) plus `msgid` + `params`, a config.broadcasts line its `args`; the client renders them with
|
||||
// translateWire / ui/lang.js tickerText; error codes through ui/toasts.js describeError.
|
||||
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MatchMessaging } from '../server/match/match/messaging.js';
|
||||
import { addMessages, setLang, setMessages, translateWire, msg, dn } from '../shared/i18n.js';
|
||||
import { makeMatch, give, DATA } from './match/harness.js';
|
||||
import { createRegistry } from '../server/match/effectsMeta.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const EN_UI = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
let lang;
|
||||
let data;
|
||||
let toasts;
|
||||
before(async () => {
|
||||
// the client data store (singleton) reads data/ through fetch: serve it from the repo
|
||||
globalThis.fetch = async (url) => {
|
||||
const abs = path.join(ROOT, String(url).replace(/^\//, ''));
|
||||
if (!existsSync(abs)) return { ok: false, status: 404, json: async () => null };
|
||||
const text = readFileSync(abs, 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(text) };
|
||||
};
|
||||
lang = await import('../public/js/ui/lang.js');
|
||||
({ data } = await import('../public/js/data.js'));
|
||||
toasts = await import('../public/js/ui/toasts.js');
|
||||
await data.loadAll('config', 'chess', 'tokens', 'bonds');
|
||||
addMessages('en', EN_UI);
|
||||
});
|
||||
after(() => { globalThis.fetch = realFetch; setLang('zh'); setMessages('en', {}); });
|
||||
|
||||
/** A Match-shaped stub for the messaging methods. */
|
||||
function stubMatch() {
|
||||
const sent = [];
|
||||
const broadcast = [];
|
||||
return {
|
||||
sent, bc: broadcast,
|
||||
disposed: false,
|
||||
players: new Map([['p1', { playerId: 'p1', connected: true }]]),
|
||||
spectators: new Map(),
|
||||
gd: { config: DATA.config },
|
||||
sendTo(id, m) { sent.push([id, m]); return true; },
|
||||
broadcast(m) { broadcast.push(m); },
|
||||
};
|
||||
}
|
||||
|
||||
test('m.toast frames: a string stays { text }; a msg() adds msgid + params beside its Chinese text', () => {
|
||||
const m = stubMatch();
|
||||
const ps = m.players.get('p1');
|
||||
MatchMessaging.prototype.toast.call(m, ps, 'warn', '整备区已满,获得的装备已销毁');
|
||||
MatchMessaging.prototype.toast.call(m, ps, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: ['医疗探机', '狼影'].map(dn) }));
|
||||
assert.deepEqual(m.sent[0][1], { t: 'm.toast', kind: 'warn', text: '整备区已满,获得的装备已销毁' });
|
||||
assert.deepEqual(m.sent[1][1], {
|
||||
t: 'm.toast', kind: 'warn', text: '医疗探机、狼影只能部署在召唤者攻击范围内,已退回整备区',
|
||||
msgid: '{names}只能部署在召唤者攻击范围内,已退回整备区', params: { names: [{ dn: '医疗探机' }, { dn: '狼影' }] },
|
||||
});
|
||||
});
|
||||
|
||||
test('m.ticker frames: broadcast lines carry their args; CUSTOM lines a msgid', () => {
|
||||
const m = stubMatch();
|
||||
MatchMessaging.prototype.tickerFor.call(m, 'GOLDEN_CHAR', ['阿米娅', '琳琅诗怀雅'], { playerId: 'p1' });
|
||||
MatchMessaging.prototype.tickerText.call(m, msg('{name}博士中途退出了模拟', { name: '阿米娅' }), 5);
|
||||
const [gold, custom] = m.bc;
|
||||
assert.equal(gold.type, 'GOLDEN_CHAR');
|
||||
assert.deepEqual(gold.args, ['阿米娅', '琳琅诗怀雅']);
|
||||
assert.ok(gold.text.includes('阿米娅') && gold.text.includes('琳琅诗怀雅'), gold.text);
|
||||
assert.deepEqual({ ...custom }, { t: 'm.ticker', text: '阿米娅博士中途退出了模拟', msgid: '{name}博士中途退出了模拟', params: { name: '阿米娅' }, id: null, type: 'CUSTOM', priority: 5, playerId: null });
|
||||
});
|
||||
|
||||
test('client: the same frames in English — msgid + params, broadcast template + args (player names never translated)', async () => {
|
||||
setLang('en');
|
||||
await data.setLocale('en');
|
||||
try {
|
||||
const toastFrame = { text: '医疗探机只能部署在召唤者攻击范围内,已退回整备区', msgid: '{names}只能部署在召唤者攻击范围内,已退回整备区', params: { names: [{ dn: '医疗探机' }] } };
|
||||
assert.equal(translateWire(toastFrame), "Medic Drone can only be deployed within the summoner's attack range and returned to the Bench");
|
||||
assert.equal(translateWire({ text: '整备区已满,获得的装备已销毁' }), EN_UI['整备区已满,获得的装备已销毁'], 'a static server text is its own msgid');
|
||||
const m = stubMatch();
|
||||
MatchMessaging.prototype.tickerFor.call(m, 'GOLDEN_CHAR', ['阿米娅', '琳琅诗怀雅'], { playerId: 'p1' });
|
||||
const line = lang.tickerText(m.bc[0]);
|
||||
assert.ok(line.includes('阿米娅'), `player name kept: ${line}`);
|
||||
assert.ok(line.includes('Swire the Elegant Wit'), `operator name translated: ${line}`);
|
||||
assert.ok(!/[一-鿿]/.test(line.replace('阿米娅', '')), line);
|
||||
// an older server (no args): the Chinese line as it came
|
||||
assert.equal(lang.tickerText({ text: m.bc[0].text, id: m.bc[0].id }), m.bc[0].text);
|
||||
// error codes: ERR_TEXT / net.js CLIENT_ERR_TEXT are msgids
|
||||
assert.equal(toasts.describeError({ code: 'HAND_FULL', msg: '整备区已满' }), 'Bench is full');
|
||||
assert.equal(toasts.describeError('NO_FUNDS'), EN_UI['资金不足']);
|
||||
} finally {
|
||||
setLang('zh');
|
||||
await data.setLocale('zh');
|
||||
}
|
||||
});
|
||||
|
||||
test('a real match: 歌蕾蒂娅 grants a chess → 「歌蕾蒂娅:获得X」 in Chinese, "Gladiia: obtained …" in English', async () => {
|
||||
const h = makeMatch({ mode: 'solo', seed: 11, registry: createRegistry({ log: { warn() {}, error() {}, info() {} } }), fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0; ps.bandId = null; ps.recompute();
|
||||
const prepFree = (c) => (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE');
|
||||
const row = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && prepFree(c)).map((c) => c.chessId).sort().slice(0, 2);
|
||||
give(m, ps, 'chess_char_4_12_a', 'board', [10, 4]);
|
||||
give(m, ps, row[0], 'board', [10, 6]);
|
||||
give(m, ps, row[1], 'board', [10, 8]);
|
||||
const n0 = h.allTo('p_0', 'm.toast').length;
|
||||
m.dispatch(ps, 'onRoundStart', { round: m.round });
|
||||
const frame = h.allTo('p_0', 'm.toast').slice(n0).find((t) => t.msgid === '{who}:获得{name}');
|
||||
assert.ok(frame, JSON.stringify(h.allTo('p_0', 'm.toast').slice(n0)));
|
||||
assert.ok(frame.text.startsWith('歌蕾蒂娅:获得'), frame.text);
|
||||
assert.deepEqual(frame.params.who, { dn: '歌蕾蒂娅' });
|
||||
setLang('en');
|
||||
await data.setLocale('en');
|
||||
try {
|
||||
const en = translateWire(JSON.parse(JSON.stringify(frame)));
|
||||
assert.match(en, /^Gladiia: obtained [A-Z]/, en);
|
||||
assert.ok(!/[一-鿿]/.test(en), en);
|
||||
} finally {
|
||||
setLang('zh');
|
||||
await data.setLocale('zh');
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// i18n core (shared/i18n.js) and the UI string table (public/i18n/en.json): t() / tc() fallback and params, plurals,
|
||||
// lists, data names, language state, the server message wire format (msg → wireMessage → translateWire), and the
|
||||
// tooling's msgid derivation (tools/i18n.mjs). docs/I18N.md.
|
||||
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
t, tc, N_, setLang, getLang, onLangChange, addMessages, setMessages, hasMessage, normalizeLang, format, setNameResolver,
|
||||
tName, dn, msg, renderMessage, wireMessage, translateWire, DEFAULT_LANG,
|
||||
} from '../shared/i18n.js';
|
||||
import { templateMsgid, paramName, quote, codemodSource, scanSource } from '../tools/i18n.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const EN = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
|
||||
|
||||
afterEach(() => { setLang('zh'); setMessages('en', {}); setNameResolver(null); });
|
||||
|
||||
test('t(): Chinese is the default and the msgid; a missing translation falls back to the Chinese text', () => {
|
||||
assert.equal(getLang(), DEFAULT_LANG);
|
||||
addMessages('en', { 整备区已满: 'Bench is full' });
|
||||
assert.equal(t('整备区已满'), '整备区已满', 'zh shows the msgid');
|
||||
setLang('en');
|
||||
assert.equal(t('整备区已满'), 'Bench is full');
|
||||
assert.equal(t('没有翻译的句子'), '没有翻译的句子', 'untranslated → Chinese');
|
||||
assert.equal(t(null), '');
|
||||
assert.equal(t(42), '42');
|
||||
assert.equal(hasMessage('整备区已满'), true);
|
||||
assert.equal(hasMessage('没有翻译的句子'), false);
|
||||
assert.equal(hasMessage('没有翻译的句子', 'zh'), true, 'the source language has every msgid');
|
||||
});
|
||||
|
||||
test('params: {name}, positional {0}, plural {n|one|other}, lists and missing values', () => {
|
||||
addMessages('en', { '还剩 {n} 秒': '{n} {n|second|seconds} left', '{0}博士对敌方领袖造成的伤害超过20%!': 'Dr. {0} dealt over 20%!' });
|
||||
assert.equal(t('还剩 {n} 秒', { n: 3 }), '还剩 3 秒');
|
||||
setLang('en');
|
||||
assert.equal(t('还剩 {n} 秒', { n: 1 }), '1 second left');
|
||||
assert.equal(t('还剩 {n} 秒', { n: 5 }), '5 seconds left');
|
||||
assert.equal(t('{0}博士对敌方领袖造成的伤害超过20%!', ['阿米娅']), 'Dr. 阿米娅 dealt over 20%!', 'array params fill {0}');
|
||||
assert.equal(format('{a} / {b}', { a: 'x' }), 'x / {b}', 'a missing value keeps the placeholder');
|
||||
assert.equal(format('{names}', { names: ['甲', '乙'] }, { lang: 'zh' }), '甲、乙');
|
||||
assert.equal(format('{names}', { names: ['A', 'B'] }, { lang: 'en' }), 'A, B');
|
||||
assert.equal(format('官方{0:0%}', { 0: 1 }), '官方{0:0%}', 'official format placeholders are not ours: untouched');
|
||||
assert.equal(format('{constructor}', {}), '{constructor}', 'no prototype lookups');
|
||||
});
|
||||
|
||||
test('tc(): a context key wins, else the plain msgid; Chinese shows the msgid', () => {
|
||||
addMessages('en', { 'toggle::关闭': 'Off', 关闭: 'Close' });
|
||||
assert.equal(tc('toggle', '关闭'), '关闭');
|
||||
setLang('en');
|
||||
assert.equal(tc('toggle', '关闭'), 'Off');
|
||||
assert.equal(tc('dialog', '关闭'), 'Close');
|
||||
assert.equal(t('关闭'), 'Close');
|
||||
assert.equal(N_('标记'), '标记', 'N_ only marks a msgid');
|
||||
});
|
||||
|
||||
test('data names: { dn } params and tName go through the resolver, only outside Chinese; player names never do', () => {
|
||||
setNameResolver((name) => ({ 琳琅诗怀雅: 'Swire the Elegant Wit', 阿米娅: 'Amiya' })[name] || name);
|
||||
addMessages('en', { '{names}只能部署在召唤者攻击范围内,已退回整备区': '{names} were returned to the Bench', '{name}博士中途退出了模拟': 'Dr. {name} left the simulation' });
|
||||
const m = msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: ['琳琅诗怀雅', '无名'].map(dn) });
|
||||
assert.equal(translateWire(wireMessage(m)), '琳琅诗怀雅、无名只能部署在召唤者攻击范围内,已退回整备区', 'zh: names as sent');
|
||||
setLang('en');
|
||||
assert.equal(translateWire(wireMessage(m)), 'Swire the Elegant Wit, 无名 were returned to the Bench');
|
||||
// a player called 阿米娅 stays 阿米娅 (plain string param, not dn)
|
||||
assert.equal(translateWire(wireMessage(msg('{name}博士中途退出了模拟', { name: '阿米娅' }))), 'Dr. 阿米娅 left the simulation');
|
||||
assert.equal(tName('阿米娅'), 'Amiya');
|
||||
setLang('zh');
|
||||
assert.equal(tName('阿米娅'), '阿米娅');
|
||||
});
|
||||
|
||||
test('language state: setLang normalizes, notifies listeners once per change; normalizeLang', () => {
|
||||
const seen = [];
|
||||
const off = onLangChange((l, prev) => seen.push(`${prev}>${l}`));
|
||||
assert.equal(setLang('en-US'), true);
|
||||
assert.equal(setLang('en'), false, 'no change, no event');
|
||||
assert.equal(setLang('xx'), true, 'unknown → default zh');
|
||||
off();
|
||||
setLang('en');
|
||||
assert.deepEqual(seen, ['zh>en', 'en>zh']);
|
||||
assert.equal(normalizeLang('zh_CN'), 'zh');
|
||||
assert.equal(normalizeLang('EN'), 'en');
|
||||
assert.equal(normalizeLang('fr'), null);
|
||||
assert.equal(normalizeLang(undefined), null);
|
||||
});
|
||||
|
||||
test('server wire format: text keeps the Chinese rendering for older clients; params are sanitized', () => {
|
||||
assert.deepEqual(wireMessage('整备区已满,获得的装备已销毁'), { text: '整备区已满,获得的装备已销毁' });
|
||||
const w = wireMessage(msg('【{label}】层数达成,获得{gain}资金', { label: dn('奇迹'), gain: 20, evil: { x: 1 }, 'bad key': 1 }));
|
||||
assert.deepEqual(w, { text: '【奇迹】层数达成,获得20资金', msgid: '【{label}】层数达成,获得{gain}资金', params: { label: { dn: '奇迹' }, gain: 20, evil: null } });
|
||||
assert.equal(renderMessage(msg('{who}:获得{name}', { who: dn('歌蕾蒂娅'), name: dn('斯卡蒂') })), '歌蕾蒂娅:获得斯卡蒂');
|
||||
// a frame from an older server (no msgid): its text is the msgid
|
||||
addMessages('en', { 隐秘核心已解锁: 'Hidden Core unlocked' });
|
||||
setLang('en');
|
||||
assert.equal(translateWire({ text: '隐秘核心已解锁' }), 'Hidden Core unlocked');
|
||||
assert.equal(translateWire({ text: 'Doctor 7 博士的目标生命值已耗尽' }), 'Doctor 7 博士的目标生命值已耗尽', 'unknown text shows as is');
|
||||
assert.equal(translateWire(null), '');
|
||||
});
|
||||
|
||||
test('public/i18n/en.json: valid, English values, placeholders kept, the pilot screens and server messages translated', () => {
|
||||
const entries = Object.entries(EN).filter(([k]) => !k.startsWith('_'));
|
||||
assert.ok(entries.length > 800, `seeded from PR #70: ${entries.length}`);
|
||||
for (const [k, v] of entries) {
|
||||
assert.equal(typeof v, 'string', k);
|
||||
assert.ok(v.trim(), `empty translation for ${k}`);
|
||||
assert.ok(!/[一-鿿]/.test(v.replace(/\{[^{}]*\}/g, '')), `Chinese left in the translation of ${k}: ${v}`);
|
||||
const want = new Set([...k.matchAll(/\{([A-Za-z0-9_$]+)\}/g)].map((m) => m[1]));
|
||||
const have = new Set([...v.matchAll(/\{([A-Za-z0-9_$]+)(?:\|[^{}]*)?\}/g)].map((m) => m[1]));
|
||||
for (const p of want) assert.ok(have.has(p), `${k}: {${p}} missing in "${v}"`);
|
||||
}
|
||||
for (const k of ['开始', '博士代号', '设置', '语言', '同盟模拟', '创建同盟', '加入同盟', '开始模拟', 'toggle::关闭', '整备区已满',
|
||||
'{names}只能部署在召唤者攻击范围内,已退回整备区', '{name}博士的目标生命值已耗尽', '联防阶段:{names} 迎战突破防线的敌人', '【维多利亚】获得{n}件维式重锤']) {
|
||||
assert.ok(EN[k], `en.json lacks ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => {
|
||||
const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js',
|
||||
'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js',
|
||||
'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js',
|
||||
'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js',
|
||||
'server/sim/content/bonds/addon/meta.js', 'server/sim/content/bonds/core.js'];
|
||||
const missing = [];
|
||||
for (const f of files) {
|
||||
const { msgids, literals } = await scanSource(readFileSync(path.join(ROOT, f), 'utf8'), f);
|
||||
const server = f.startsWith('server/');
|
||||
for (const m of msgids) if ((!server || m.via === 'msg' || m.via === 'server') && !EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`);
|
||||
if (!server) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`);
|
||||
}
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
test('tools/i18n.mjs: msgids of template literals name their params; the codemod wraps text, attributes and nested strings', async () => {
|
||||
assert.equal(paramName('poolN', 0), 'poolN');
|
||||
assert.equal(paramName('info.rounds', 1), 'rounds');
|
||||
assert.equal(paramName('ids.length', 0), 'n');
|
||||
assert.equal(paramName('MAX_SEATS - 1', 2), '2');
|
||||
assert.deepEqual(templateMsgid(['战场随机(共', '张)'], ['ids.length']), { msgid: '战场随机(共{n}张)', params: [{ name: 'n', src: 'ids.length' }] });
|
||||
assert.equal(templateMsgid(['', ' / ', ''], ['a.x', 'b.x']).msgid, '{x} / {x2}', 'same name from two expressions: numbered');
|
||||
assert.equal(quote("it's\n"), "'it\\'s\\n'");
|
||||
const src = [
|
||||
"import { html } from './components.js';",
|
||||
"const TABLE = { a: '甲' };",
|
||||
"export function V({ name, n }) {",
|
||||
" if (name === '比较') return null;",
|
||||
" toast(`剩余 ${n} 名`, 'warn');",
|
||||
" return html`<p title=\"提示 ${n}\">你好 ${name || '博士'} 世界<b>${n}</b> 回合</p>`;",
|
||||
" const sample = '字体样例'; // i18n-ignore",
|
||||
"}",
|
||||
].join('\n');
|
||||
const out = await codemodSource(src, path.join(ROOT, 'public/js/ui/x.js'));
|
||||
assert.match(out.src, /import \{ t, N_ \} from '\.\.\/\.\.\/\.\.\/shared\/i18n\.js';/);
|
||||
assert.match(out.src, /const TABLE = \{ a: N_\('甲'\) \};/, 'module level: marked, not translated');
|
||||
assert.match(out.src, /name === '比较'/, 'compared literals are left alone');
|
||||
assert.match(out.src, /toast\(t\('剩余 \{n\} 名', \{ n \}\), 'warn'\)/);
|
||||
assert.match(out.src, /title=\$\{t\('提示 \{n\}', \{ n \}\)\}/);
|
||||
// a child that is not a plain value splits the run; the string inside it is wrapped too
|
||||
assert.match(out.src, />\$\{t\('你好'\)\} \$\{name \|\| t\('博士'\)\} \$\{t\('世界'\)\}<b>/);
|
||||
assert.match(out.src, /<\/b> \$\{t\('回合'\)\}<\/p>/);
|
||||
assert.equal(out.manual.length, 1);
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from '.
|
||||
import { botPrep, planLayout, fieldModel } from '../../server/match/bot.js';
|
||||
import { placementContext, canPlace as clientCanPlace, boardTargets, deployMap as clientDeployMap } from '../../public/js/ui/gameLogic.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { renderMessage } from '../../shared/i18n.js';
|
||||
|
||||
const STAGE = 'act2autochess_m04';
|
||||
const WATER = ['10,6', '11,6', '12,6'];
|
||||
@@ -187,7 +188,7 @@ test('#9 hand and temp full: a re-orientation that would push 狼群 out is refu
|
||||
assert.equal(stackOf(ps, WOLF), null, 'its only copy is placed: no stack left to return onto');
|
||||
const toasts = [];
|
||||
const toast = m.toast.bind(m);
|
||||
m.toast = (p, kind, text) => { toasts.push(text); return toast(p, kind, text); };
|
||||
m.toast = (p, kind, text) => { toasts.push(renderMessage(text)); return toast(p, kind, text); }; // string or msg()
|
||||
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
|
||||
while (ps.hand.includes(null)) giveItem(m, ps, golden);
|
||||
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { planLayout } from '../../server/match/bot.js';
|
||||
import { collectViolations } from '../../server/match/invariants.js';
|
||||
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
|
||||
import { renderMessage } from '../../shared/i18n.js';
|
||||
|
||||
const STAGE = 'act2autochess_m01';
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -115,7 +116,8 @@ describe('#7 the Final Assault prep deploys on the boss field', () => {
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound);
|
||||
const toasts = [];
|
||||
const toast = m.toast.bind(m);
|
||||
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${text}`); return toast(ps, kind, text); };
|
||||
// a toast is a string or a shared/i18n.js msg(): compare its Chinese rendering
|
||||
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${renderMessage(text)}`); return toast(ps, kind, text); };
|
||||
const fields = [];
|
||||
m.deployFieldOf = ((orig) => function (ps) { const f = orig.call(this, ps); if (this.round === this.gd.hiddenRound) fields.push(f); return f; })(m.deployFieldOf);
|
||||
const placed = {};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Browser check of the language switch (puppeteer-core + system Chrome; opt-in: SP_E2E=1, ~20 s):
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/i18n.e2e.test.js
|
||||
//
|
||||
// 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.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CHROME, hasChrome, sleep } from '../e2e/client.mjs';
|
||||
import { NAME_MAX_LEN } from '../../shared/constants.js';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome();
|
||||
|
||||
describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=1 (Chrome)' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
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(); });
|
||||
|
||||
const text = (page, sel) => page.$eval(sel, (el) => el.textContent.replace(/\s+/g, ' ').trim());
|
||||
|
||||
test('中文 by default; English in place, kept across a reload; ?lang=zh back to Chinese', 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()}`); });
|
||||
await page.goto(`${base}/`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.title-screen [data-testid="lang-toggle"]');
|
||||
assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约');
|
||||
assert.equal(await text(page, '.title-login .btn--primary'), '开始');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.lang), 'zh-CN');
|
||||
|
||||
await page.click('[data-testid="lang-toggle"] button[data-lang="en"]');
|
||||
await page.waitForFunction(() => document.querySelector('.title-cn')?.textContent.includes('Stronghold Protocol'), { timeout: 8000 });
|
||||
assert.equal(await text(page, '.title-cn'), 'Stronghold Protocol: Alliance');
|
||||
assert.equal(await text(page, '.title-login .btn--primary'), 'Start');
|
||||
assert.match(await text(page, '.title-tag'), /^Allocate Funds and Operators/);
|
||||
assert.match(await text(page, '.title-conn'), /Connect|Ready to connect/);
|
||||
assert.equal(await page.$eval('.title-login input', (el) => el.placeholder), `Callsign (max ${NAME_MAX_LEN} chars)`);
|
||||
assert.deepEqual(await page.evaluate(() => ({ lang: document.documentElement.lang, title: document.title, pref: localStorage.getItem('sp.pref.lang') })),
|
||||
{ lang: 'en', title: 'Stronghold Protocol: Alliance · Web Simulation', pref: '"en"' });
|
||||
// the game texts follow (data/i18n/en.json applied by data.js)
|
||||
await page.waitForFunction(() => globalThis.__SP__?.data?.locale() === 'en', { timeout: 8000 });
|
||||
assert.equal(await page.evaluate(() => globalThis.__SP__.data.get('config').modes.mode_multi_abyss.name), 'Ultimate Simulation');
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.title-screen .title-cn');
|
||||
assert.equal(await text(page, '.title-cn'), 'Stronghold Protocol: Alliance', 'the choice survives a reload');
|
||||
assert.equal(await text(page, '.title-login .btn--primary'), 'Start');
|
||||
|
||||
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.title-screen .title-cn');
|
||||
await sleep(200);
|
||||
assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约');
|
||||
assert.equal(await page.evaluate(() => location.search), '', '?lang is removed from the address bar');
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('sp.pref.lang')), '"zh"');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,717 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data.
|
||||
//
|
||||
// The game texts of data/*.json (operators, skills, talents, modules, traits, enemies, bonds, items, effects, bands,
|
||||
// 特质, 机变 cards, modes, stages, tokens …) are the official zh_CN texts (tools/build-data.mjs). This tool writes their
|
||||
// official English counterparts as an overlay keyed by the remake's own ids and field paths (format: shared/i18nData.js),
|
||||
// which public/js/data.js applies when the player switches to English. tools/build-data.mjs is not touched.
|
||||
//
|
||||
// How a text is matched (no id table to keep up to date — the official tables are walked side by side):
|
||||
// 1. the zh_CN and the EN tables are walked in parallel (same keys; arrays of keyed records by their key), giving
|
||||
// pairs Chinese source → English source with the ids on their path as "scope";
|
||||
// 2. a data text equal to a Chinese source takes its English source (exact);
|
||||
// 3. a data text whose blackboard placeholders were resolved by build-data ({atk:0%} → 80%) is matched against the
|
||||
// Chinese templates of the same shape: the numbers are read back into a blackboard, the Chinese template must
|
||||
// reproduce the text exactly, and the English template is filled with the same values (template);
|
||||
// 4. a few remake-made composites (stage names) are assembled from translated parts (composite);
|
||||
// 5. what the official EN data lacks falls back to tools/i18n/fallback-remake.json (the remake's own strings),
|
||||
// tools/i18n/fallback-pr70.json (PR #70's translations by @YuriRestia, reused with credit) and the UI table
|
||||
// public/i18n/en.json (a data text that is also a UI msgid); anything else stays Chinese.
|
||||
// Ties between several English sources prefer the one whose path shares the record's ids, then the most frequent.
|
||||
// Rich-text tags and placeholders of the official EN texts are the same as in zh_CN, so the client's rich-text
|
||||
// formatting (ui/richText.js) works unchanged; `desc` / `text` (plain) are the stripped `descRaw` / `textRaw`.
|
||||
//
|
||||
// Usage: node tools/build-i18n.mjs [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>]
|
||||
// [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]
|
||||
// --source EN tables: 'assets' (default) = ArknightsAssets/ArknightsGamedata en (current, has the 盟约 seasons
|
||||
// act1autochess / act2autochess), 'yostar' = Kengxxiao/ArknightsGameData_YoStar en_US (archived in
|
||||
// 2025-11: no 盟约 season, only the 2025 test event act1vautochess)
|
||||
// --refresh re-download the EN tables; --offline never download (fail when a table is missing)
|
||||
// --cache zh_CN cache (default <repo>/.cache/gamedata, shared with build-data; missing tables are downloaded)
|
||||
// --cache-en EN cache (default <repo>/.cache/gamedata-en for 'assets', .cache/gamedata-en-yostar for 'yostar')
|
||||
// --data the data/*.json to localize (default <repo>/data); --out default <repo>/data/i18n/en.json
|
||||
// --report default <repo>/.cache/build-i18n-report.json (coverage per kind, untranslated samples)
|
||||
// --check build in memory and exit 1 when the output differs from --out (nothing written)
|
||||
// Determinism: the output depends only on the inputs (stable key order, no timestamps).
|
||||
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { 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 [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]';
|
||||
|
||||
export const EN_SOURCES = Object.freeze({
|
||||
assets: {
|
||||
label: 'ArknightsAssets/ArknightsGamedata (en)',
|
||||
home: 'https://github.com/ArknightsAssets/ArknightsGamedata',
|
||||
url: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsGamedata/master/en/gamedata/',
|
||||
cache: '.cache/gamedata-en',
|
||||
},
|
||||
yostar: {
|
||||
label: 'Kengxxiao/ArknightsGameData_YoStar (en_US)',
|
||||
home: 'https://github.com/Kengxxiao/ArknightsGameData_YoStar',
|
||||
url: 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData_YoStar/main/en_US/gamedata/',
|
||||
cache: '.cache/gamedata-en-yostar',
|
||||
},
|
||||
});
|
||||
const ZH_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
|
||||
|
||||
/** Official tables walked side by side (zh_CN ↔ EN), with the sub-trees that hold texts the remake uses. */
|
||||
const TABLES = [
|
||||
{ rel: 'excel/activity_table.json', pick: (j) => ({ act2: j?.activity?.AUTOCHESS_SEASON?.act2autochess, act1: j?.activity?.AUTOCHESS_SEASON?.act1autochess, ac: j?.autoChessData, info: { act2autochess: j?.basicInfo?.act2autochess, act1autochess: j?.basicInfo?.act1autochess } }) },
|
||||
{ rel: 'excel/character_table.json' },
|
||||
{ rel: 'excel/skill_table.json' },
|
||||
{ rel: 'excel/uniequip_table.json' },
|
||||
{ rel: 'excel/battle_equip_table.json' },
|
||||
{ rel: 'excel/enemy_handbook_table.json', pick: (j) => j?.enemyData },
|
||||
{ rel: 'excel/item_table.json', pick: (j) => j?.items },
|
||||
{ rel: 'excel/token_table.json' },
|
||||
{ rel: 'levels/enemydata/enemy_database.json', pick: (j) => (Array.isArray(j?.enemies) ? kvMap(j.enemies) : j) },
|
||||
];
|
||||
|
||||
/** A Unity-style list of { Key, Value } entries as a plain map (the two dumps serialize some tables either way). */
|
||||
function kvMap(list) {
|
||||
const out = {};
|
||||
for (const e of list) if (e && (typeof e.Key === 'string' || typeof e.key === 'string')) out[e.Key ?? e.key] = e.Value ?? e.value;
|
||||
return out;
|
||||
}
|
||||
const isKvList = (v) => Array.isArray(v) && v.length > 0 && v.every((e) => e && typeof e === 'object' && typeof e.Key === 'string' && 'Value' in e && Object.keys(e).length === 2);
|
||||
|
||||
/** Scope segment of the act2autochess sub-tree (TABLES pick key): the season data/*.json is built from. */
|
||||
const SEASON_SCOPE = 'act2';
|
||||
|
||||
/** data/*.json files localized, in output order. */
|
||||
export const DATA_FILES = Object.freeze(['chess', 'backups', 'tokens', 'bonds', 'garrisons', 'items', 'bands', 'effects',
|
||||
'choices', 'enemies', 'factions', 'stages', 'bosses', 'config', 'emotes']);
|
||||
|
||||
/**
|
||||
* Research / developer notes of the data files (English with Chinese terms; no screen shows them): never translated,
|
||||
* counted apart. A leaf is a note when one of these keys is on its path, or its own key is in NOTE_LEAF_KEYS.
|
||||
*/
|
||||
const NOTE_KEYS = new Set(['spec', 'implFormula', 'assumed', 'notes', 'formula', 'helperOrder', '_why', '_doc', 'algorithm', 'titleRule']);
|
||||
const NOTE_LEAF_KEYS = new Set(['cards', 'rule', 'note_dev']);
|
||||
|
||||
/** Fields whose translation also feeds the `names` map (server ticker / toast args are data names). */
|
||||
const NAME_KEYS = new Set(['name', 'effectName']);
|
||||
/** File priority when two records give the same Chinese name different English names. */
|
||||
const NAME_PRIORITY = ['chess', 'backups', 'tokens', 'items', 'bonds', 'bands', 'effects', 'garrisons', 'enemies', 'bosses', 'stages', 'choices', 'config', 'factions', 'emotes'];
|
||||
|
||||
const CJK = /[\u3400-\u9fff\uf900-\ufaff]/;
|
||||
/** Official rich-text markup (a styled / term tag or its close). */
|
||||
const RICH = /<[@$#][^<>]*>|<\/>/;
|
||||
const hasCjk = (s) => typeof s === 'string' && CJK.test(s);
|
||||
|
||||
// ===== text helpers (kept identical to tools/build-data.mjs: the zh round trip in the report proves it) ============
|
||||
|
||||
export function unescapeNewlines(s) { return typeof s === 'string' ? s.replace(/\\n/g, '\n') : s; }
|
||||
export function stripRich(s) {
|
||||
if (typeof s !== 'string') return s ?? null;
|
||||
return unescapeNewlines(s)
|
||||
.replace(/<[@$#][^<>]*>/g, '')
|
||||
.replace(/<\/>/g, '')
|
||||
.replace(/<\/?color[^<>]*>/gi, '')
|
||||
.replace(/<\/?[bi]>/gi, '');
|
||||
}
|
||||
function cleanNum(v) {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
|
||||
if (Number.isInteger(v) || Math.abs(v) >= 1e6) return v;
|
||||
return Math.round(v * 1e6) / 1e6;
|
||||
}
|
||||
export function formatValue(v, fmt) {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return String(v);
|
||||
if (!fmt) return String(cleanNum(v));
|
||||
const pct = fmt.endsWith('%');
|
||||
const core = pct ? fmt.slice(0, -1) : fmt;
|
||||
const decimals = core.includes('.') ? core.split('.')[1].length : 0;
|
||||
const x = pct ? v * 100 : v;
|
||||
const rounded = Math.sign(x) * Math.round(Math.abs(x) * 10 ** decimals + 1e-9) / 10 ** decimals;
|
||||
return rounded.toFixed(decimals) + (pct ? '%' : '');
|
||||
}
|
||||
const PH = /\{(-?)([^{}:]+)(?::([^{}]+))?\}/g;
|
||||
/** Resolve {key}, {-key}, {key:0%} against a blackboard (case-insensitive); unknown keys stay verbatim. */
|
||||
export function resolvePlaceholders(text, bb, bbStr = {}) {
|
||||
if (typeof text !== 'string') return text ?? null;
|
||||
const lower = new Map();
|
||||
for (const [k, v] of Object.entries(bb || {})) lower.set(k.toLowerCase(), v);
|
||||
const lowerStr = new Map();
|
||||
for (const [k, v] of Object.entries(bbStr || {})) lowerStr.set(k.toLowerCase(), v);
|
||||
return text.replace(PH, (m, neg, key, fmt) => {
|
||||
const k = key.trim().toLowerCase();
|
||||
if (lowerStr.has(k) && (!lower.has(k) || !fmt)) return lowerStr.get(k);
|
||||
if (lower.has(k)) return formatValue(neg ? -lower.get(k) : lower.get(k), fmt);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
const hasPlaceholder = (s) => typeof s === 'string' && /\{-?[A-Za-z_@][^{}:]*(?::[^{}]+)?\}/.test(s);
|
||||
|
||||
// ===== CLI & IO ======================================================================================================
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
refresh: false, offline: false, quiet: false, check: false, source: 'assets',
|
||||
cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'),
|
||||
out: join(ROOT, 'data', 'i18n', 'en.json'), report: join(ROOT, '.cache', 'build-i18n-report.json'),
|
||||
};
|
||||
const flags = { '--refresh': 'refresh', '--offline': 'offline', '--quiet': 'quiet', '--check': 'check' };
|
||||
const vals = { '--source': 'source', '--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];
|
||||
if (name === '--help' || name === '-h') { console.log(USAGE); process.exit(0); }
|
||||
if (flags[name] && inline === null) { opts[flags[name]] = true; continue; }
|
||||
if (vals[name]) {
|
||||
const v = inline ?? argv[++i];
|
||||
if (!v || v.startsWith('--')) throw new Error(`${name} needs a value\n${USAGE}`);
|
||||
opts[vals[name]] = name === '--source' ? v : resolve(v);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown option ${argv[i]}\n${USAGE}`);
|
||||
}
|
||||
if (opts.refresh && opts.offline) throw new Error(`--refresh and --offline are mutually exclusive\n${USAGE}`);
|
||||
if (!EN_SOURCES[opts.source]) throw new Error(`--source must be one of ${Object.keys(EN_SOURCES).join(', ')}`);
|
||||
if (!opts.cacheEn) opts.cacheEn = join(ROOT, EN_SOURCES[opts.source].cache);
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function download(url, abs, log) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= 4; attempt++) {
|
||||
try {
|
||||
log(` download ${url}${attempt > 1 ? ` (attempt ${attempt})` : ''}`);
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(240_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
const text = await res.text();
|
||||
JSON.parse(text); // never cache a truncated file
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
const tmp = `${abs}.tmp-${process.pid}`;
|
||||
await writeFile(tmp, text);
|
||||
await rename(tmp, abs);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (attempt < 4) await new Promise((r) => setTimeout(r, 600 * attempt));
|
||||
}
|
||||
}
|
||||
throw new Error(`cannot download ${url}: ${lastErr && lastErr.message}`);
|
||||
}
|
||||
|
||||
async function loadTable(dir, base, rel, { refresh, offline, log }) {
|
||||
const abs = join(dir, rel);
|
||||
if (refresh || !existsSync(abs)) {
|
||||
if (offline) throw new Error(`missing cached table ${abs} (offline mode)`);
|
||||
await download(base + rel, abs, log);
|
||||
}
|
||||
return JSON.parse(await readFile(abs, 'utf8'));
|
||||
}
|
||||
|
||||
// ===== the parallel walk: Chinese source → English source pairs ======================================================
|
||||
|
||||
const KEY_FIELDS = ['Key', 'key', 'id', 'charId', 'skillId', 'uniEquipId', 'enemyId', 'itemId', 'effectId', 'bondId', 'bandId', 'stageId', 'modeId', 'bossId', 'talentIndex'];
|
||||
|
||||
/** The field an array of records is keyed by (every element an object with a distinct string/number value), or null. */
|
||||
function arrayKeyField(arr) {
|
||||
if (!Array.isArray(arr) || arr.length < 2 || !arr.every((x) => x && typeof x === 'object' && !Array.isArray(x))) return null;
|
||||
for (const f of KEY_FIELDS) {
|
||||
const vals = arr.map((x) => x[f]);
|
||||
if (vals.every((v) => typeof v === 'string' || typeof v === 'number') && new Set(vals).size === vals.length) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Path segments that identify game objects (they contain '_' or a digit): the scope of a pair. */
|
||||
const isScopeSeg = (s) => typeof s === 'string' && /[_\d]/.test(s) && s.length <= 64;
|
||||
/** A dictionary of records (≥ 6 keys, every value an object): its keys are ids even without '_' ('longrange'). */
|
||||
const isDict = (o) => {
|
||||
const vals = Object.values(o);
|
||||
return vals.length >= 6 && vals.every((v) => v && typeof v === 'object');
|
||||
};
|
||||
|
||||
/**
|
||||
* Pair index: Chinese source text (newlines unescaped) → English variants with counts and scopes; plus a template
|
||||
* index (shape key → [{ zh, en, scope }]) for texts with placeholders.
|
||||
*/
|
||||
class PairIndex {
|
||||
constructor() {
|
||||
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
|
||||
this.exact = new Map();
|
||||
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
|
||||
this.plain = new Map();
|
||||
/** @type {Map<string, Map<string, { zh: string, en: string, n: number, scopes: Set<string> }>>} */
|
||||
this.templates = new Map();
|
||||
this.pairs = 0;
|
||||
}
|
||||
|
||||
static add(map, zh, en, scope) {
|
||||
let m = map.get(zh);
|
||||
if (!m) { m = new Map(); map.set(zh, m); }
|
||||
let e = m.get(en);
|
||||
if (!e) { e = { n: 0, scopes: new Set() }; m.set(en, e); }
|
||||
e.n++;
|
||||
for (const s of scope) if (e.scopes.size < 48) e.scopes.add(s);
|
||||
}
|
||||
|
||||
add(zhRaw, enRaw, scope) {
|
||||
const zh = unescapeNewlines(zhRaw);
|
||||
const en = unescapeNewlines(enRaw);
|
||||
if (!en.trim() || hasCjk(en)) return;
|
||||
this.pairs++;
|
||||
PairIndex.add(this.exact, zh, en, scope);
|
||||
// build-data trims some texts (a tip ends with '\\n' in the table, not in data/config.json)
|
||||
if (zh.trim() !== zh) PairIndex.add(this.exact, zh.trim(), en.trim(), scope);
|
||||
const zp = stripRich(zh);
|
||||
if (zp !== zh) PairIndex.add(this.plain, zp, stripRich(en), scope);
|
||||
if (hasPlaceholder(zh)) {
|
||||
const key = shapeKey(zh);
|
||||
let m = this.templates.get(key);
|
||||
if (!m) { m = new Map(); this.templates.set(key, m); }
|
||||
const id = `${zh}\u0000${en}`;
|
||||
let e = m.get(id);
|
||||
if (!e) { e = { zh, en, n: 0, scopes: new Set() }; m.set(id, e); }
|
||||
e.n++;
|
||||
for (const s of scope) if (e.scopes.size < 48) e.scopes.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a zh_CN tree and its EN counterpart together. */
|
||||
walk(zh, en, scope = []) {
|
||||
if (isKvList(zh)) zh = kvMap(zh);
|
||||
if (isKvList(en)) en = kvMap(en);
|
||||
if (typeof zh === 'string') {
|
||||
if (typeof en === 'string' && hasCjk(zh)) this.add(zh, en, scope);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(zh)) {
|
||||
if (!Array.isArray(en)) return;
|
||||
const kf = arrayKeyField(zh);
|
||||
if (kf && arrayKeyField(en) === kf) {
|
||||
const byKey = new Map(en.map((x) => [x[kf], x]));
|
||||
for (const x of zh) {
|
||||
const y = byKey.get(x[kf]);
|
||||
if (y !== undefined) this.walk(x, y, isScopeSeg(String(x[kf])) ? [...scope, String(x[kf])] : scope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (zh.length !== en.length) return; // unkeyed arrays of different length cannot be aligned safely
|
||||
for (let i = 0; i < zh.length; i++) this.walk(zh[i], en[i], scope);
|
||||
return;
|
||||
}
|
||||
if (zh && typeof zh === 'object') {
|
||||
if (!en || typeof en !== 'object' || Array.isArray(en)) return;
|
||||
const dict = isDict(zh);
|
||||
for (const k of Object.keys(zh)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(en, k)) continue;
|
||||
this.walk(zh[k], en[k], dict || isScopeSeg(k) ? [...scope, k] : scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape of a text for template lookup: markup stripped, placeholders and numbers folded into one marker. */
|
||||
export function shapeKey(s) {
|
||||
return stripRich(s)
|
||||
.replace(/\{-?[^{}:]+(?::[^{}]+)?\}/g, '\u0001')
|
||||
.replace(/[+\-]?\d+(?:\.\d+)?%?/g, '\u0001')
|
||||
.replace(/[+\-]?\u0001%?/g, '\u0001')
|
||||
.replace(/\u0001+/g, '\u0001');
|
||||
}
|
||||
|
||||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/**
|
||||
* Read a resolved text back into the blackboard values of a template: { bb, bbStr } that make
|
||||
* resolvePlaceholders(template) reproduce `text` exactly, or null.
|
||||
* @param {string} template
|
||||
* @param {string} text
|
||||
*/
|
||||
export function inverseFormat(template, text) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
let re = '^';
|
||||
for (const m of template.matchAll(PH)) {
|
||||
re += escapeRe(template.slice(last, m.index)) + '([\\s\\S]*?)';
|
||||
parts.push({ neg: m[1] === '-', key: m[2].trim(), fmt: m[3] || null });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (!parts.length) return null;
|
||||
re += `${escapeRe(template.slice(last))}$`;
|
||||
let mm;
|
||||
try { mm = new RegExp(re).exec(text); } catch { return null; }
|
||||
if (!mm) return null;
|
||||
const bb = {};
|
||||
const bbStr = {};
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const { neg, key, fmt } = parts[i];
|
||||
const cap = mm[i + 1];
|
||||
let v;
|
||||
if (fmt) {
|
||||
const pct = fmt.endsWith('%');
|
||||
const num = pct ? cap.match(/^(-?\d+(?:\.\d+)?)%$/) : cap.match(/^(-?\d+(?:\.\d+)?)$/);
|
||||
if (!num) return null;
|
||||
v = cleanNum(Number(num[1]) / (pct ? 100 : 1));
|
||||
} else if (/^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(cap)) {
|
||||
v = Number(cap);
|
||||
} else {
|
||||
if (neg) return null;
|
||||
bbStr[key] = cap;
|
||||
continue;
|
||||
}
|
||||
if (neg) v = -v;
|
||||
v = cleanNum(v);
|
||||
if (Object.prototype.hasOwnProperty.call(bb, key) && bb[key] !== v) return null;
|
||||
bb[key] = v;
|
||||
}
|
||||
if (resolvePlaceholders(template, bb, bbStr) !== text) return null;
|
||||
return { bb, bbStr };
|
||||
}
|
||||
|
||||
// ===== translation of one data text ==================================================================================
|
||||
|
||||
const scoreScopes = (scopes, ctx) => {
|
||||
let s = 0;
|
||||
for (const id of ctx.near) if (scopes.has(id)) s += 4;
|
||||
for (const id of ctx.record) if (scopes.has(id)) s += 1;
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Best English variant of a set: scope match first, then frequency, then text order (deterministic). */
|
||||
function bestVariant(variants, ctx) {
|
||||
let best = null;
|
||||
for (const [en, e] of variants) {
|
||||
const cand = { en, score: scoreScopes(e.scopes, ctx), n: e.n };
|
||||
if (!best || cand.score > best.score || (cand.score === best.score && (cand.n > best.n || (cand.n === best.n && cand.en < best.en)))) best = cand;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translator over the pair index and the fallback dictionary.
|
||||
*/
|
||||
class Translator {
|
||||
/**
|
||||
* @param {PairIndex} index
|
||||
* @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference
|
||||
*/
|
||||
constructor(index, fallbacks) {
|
||||
this.index = index;
|
||||
this.fallbacks = fallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} zh the data text
|
||||
* @param {{ near: Set<string>, record: Set<string>, bb?: object|null, bbStr?: object|null }} ctx
|
||||
* @returns {{ en: string, how: string } | null}
|
||||
*/
|
||||
translate(zh, ctx) {
|
||||
const ex = this.index.exact.get(zh);
|
||||
if (ex) return { en: bestVariant(ex, ctx).en, how: 'exact' };
|
||||
// a plain text (build-data strips the markup of `desc`, `text`, card / device descriptions) against stripped sources
|
||||
if (!RICH.test(zh)) {
|
||||
const pl = this.index.plain.get(zh);
|
||||
if (pl) return { en: bestVariant(pl, ctx).en, how: 'exact' };
|
||||
}
|
||||
const tpl = this.template(zh, ctx);
|
||||
if (tpl) return tpl;
|
||||
const comp = this.composite(zh, ctx);
|
||||
if (comp) return comp;
|
||||
for (const { name, map } of this.fallbacks) {
|
||||
const fb = map.get(zh);
|
||||
if (fb) return { en: fb, how: name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
template(zh, ctx) {
|
||||
const cands = this.index.templates.get(shapeKey(zh));
|
||||
if (!cands) return null;
|
||||
let best = null;
|
||||
const plain = !RICH.test(zh);
|
||||
for (const c of cands.values()) {
|
||||
const zhT = plain ? stripRich(c.zh) : c.zh;
|
||||
const inv = inverseFormat(zhT, zh);
|
||||
if (!inv) continue;
|
||||
const enT = plain ? stripRich(c.en) : c.en;
|
||||
const bb = { ...(ctx.bb || {}), ...inv.bb };
|
||||
const bbStr = { ...(ctx.bbStr || {}), ...inv.bbStr };
|
||||
const en = resolvePlaceholders(enT, bb, bbStr);
|
||||
if (hasPlaceholder(en) && !hasPlaceholder(zh)) continue; // the English template needs a value the text lacks
|
||||
const cand = { en, score: scoreScopes(c.scopes, ctx), n: c.n };
|
||||
if (!best || cand.score > best.score || (cand.score === best.score && (cand.n > best.n || (cand.n === best.n && cand.en < best.en)))) best = cand;
|
||||
}
|
||||
return best ? { en: best.en, how: 'template' } : null;
|
||||
}
|
||||
|
||||
/** Remake-made composites: stage names "战场#05(下半) 源石流发生装置". */
|
||||
composite(zh, ctx) {
|
||||
const m = zh.match(/^战场#(\d+)(?:\((上半|下半)\))?(?:\s+(.+))?$/);
|
||||
if (!m) return null;
|
||||
let en = `Battlefield #${m[1]}`;
|
||||
if (m[2]) en += m[2] === '上半' ? ' (First Half)' : ' (Second Half)';
|
||||
if (m[3]) {
|
||||
const parts = m[3].split('/').map((p) => this.translate(p.trim(), ctx));
|
||||
if (parts.some((p) => !p)) return null;
|
||||
en += ` ${parts.map((p) => p.en).join(' / ')}`;
|
||||
}
|
||||
return { en, how: 'composite' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== walking the data files ========================================================================================
|
||||
|
||||
const ID_VALUE = /^[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+$/;
|
||||
|
||||
/** Id-like string values of an object (an `…Id` / `id` / `key` field, or a value shaped like an id), `depth` levels down. */
|
||||
function idsOf(obj, depth, out = new Set()) {
|
||||
if (!obj || typeof obj !== 'object' || depth < 0) return out;
|
||||
for (const [k, v] of Array.isArray(obj) ? obj.entries() : Object.entries(obj)) {
|
||||
if (typeof v === 'string') {
|
||||
if (v.length <= 64 && (ID_VALUE.test(v) || (typeof k === 'string' && /(?:^id|Id|^key)$/.test(k) && /^[A-Za-z][\w-]*$/.test(v)))) out.add(v);
|
||||
} else if (v && typeof v === 'object') idsOf(v, depth - 1, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Kind of a leaf for the coverage report. */
|
||||
function kindOf(file, path) {
|
||||
if (file === 'chess' || file === 'backups') {
|
||||
const keys = path.filter((k) => typeof k === 'string');
|
||||
if (keys.some((k) => k === 'talentChanges' || k === 'talents' || k === 'talentsBase')) return 'talents';
|
||||
if (keys.some((k) => k === 'modules' || k === 'module' || k === 'moduleNames')) return 'modules';
|
||||
if (keys.some((k) => k === 'skills' || k === 'skill')) return 'skills';
|
||||
if (keys.some((k) => k === 'trait' || k === 'traitBase')) return 'traits';
|
||||
return 'operators';
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
const isNotePath = (path) => path.some((k) => typeof k === 'string' && NOTE_KEYS.has(k))
|
||||
|| (typeof path[path.length - 1] === 'string' && NOTE_LEAF_KEYS.has(path[path.length - 1]));
|
||||
|
||||
/**
|
||||
* Translate every Chinese text of one record. Raw/plain pairs (descRaw/desc, textRaw/text, moduleDescRaw/moduleDesc,
|
||||
* effectDescRaw/effectDesc): the raw text is translated, the plain one is its stripped translation when it is the
|
||||
* stripped Chinese raw text (as build-data writes it).
|
||||
*/
|
||||
function translateRecord(file, id, record, tr, stats, samples) {
|
||||
const leaves = [];
|
||||
const recordIds = idsOf(record, 3);
|
||||
recordIds.add(id);
|
||||
// data/*.json is the 下半 season (act2autochess): its wording wins a tie with the 上半 one (the `act2` sub-tree)
|
||||
recordIds.add(SEASON_SCOPE);
|
||||
const visit = (node, path, nearIds) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const arr = Array.isArray(node);
|
||||
const near = arr ? nearIds : new Set([...nearIds, ...idsOf(node, 0)]);
|
||||
const done = new Map();
|
||||
const keys = arr ? node.map((_, i) => i) : Object.keys(node);
|
||||
const ordered = arr ? keys : [...keys.filter((k) => k.endsWith('Raw')), ...keys.filter((k) => !k.endsWith('Raw'))];
|
||||
for (const k of ordered) {
|
||||
const v = node[k];
|
||||
const p = [...path, k];
|
||||
if (typeof v !== 'string') { if (v && typeof v === 'object') visit(v, p, near); continue; }
|
||||
if (!hasCjk(v)) continue;
|
||||
const kind = kindOf(file, p);
|
||||
if (isNotePath(p)) { stats.note(kind); continue; }
|
||||
let res = null;
|
||||
const rawKey = `${k}Raw`;
|
||||
if (!arr && !k.endsWith('Raw') && typeof node[rawKey] === 'string' && done.has(rawKey) && stripRich(node[rawKey]) === v) {
|
||||
const r = done.get(rawKey);
|
||||
res = { en: stripRich(r.en), how: r.how };
|
||||
}
|
||||
if (!res) {
|
||||
const bb = arr ? null : node.bb && typeof node.bb === 'object' ? { duration: node.duration, ...node.bb } : (Number.isFinite(node.duration) ? { duration: node.duration } : null);
|
||||
res = tr.translate(v, { near, record: recordIds, bb, bbStr: arr ? null : node.bbStr || null });
|
||||
}
|
||||
if (res) {
|
||||
done.set(k, res);
|
||||
leaves.push({ path: p, zh: v, en: res.en, kind, key: arr ? path[path.length - 1] : k });
|
||||
stats.hit(kind, res.how);
|
||||
} else {
|
||||
stats.miss(kind, v);
|
||||
const list = samples[kind] || (samples[kind] = []);
|
||||
if (list.length < 25) list.push(`${file}.${id}.${p.join('.')}: ${v.slice(0, 80).replace(/\n/g, '⏎')}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(record, [], new Set([id]));
|
||||
return leaves;
|
||||
}
|
||||
|
||||
/** A record that is a plain string (config `seasonName`). */
|
||||
function translateScalar(file, id, text, tr, stats, samples) {
|
||||
if (!hasCjk(text) || NOTE_KEYS.has(id)) return [];
|
||||
const kind = kindOf(file, [id]);
|
||||
const res = tr.translate(text, { near: new Set([id]), record: new Set([id, SEASON_SCOPE]), bb: null, bbStr: null });
|
||||
if (!res) {
|
||||
stats.miss(kind, text);
|
||||
(samples[kind] || (samples[kind] = [])).push(`${file}.${id}: ${text.slice(0, 80)}`);
|
||||
return [];
|
||||
}
|
||||
stats.hit(kind, res.how);
|
||||
return [{ path: [], zh: text, en: res.en, kind, key: id }];
|
||||
}
|
||||
|
||||
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 }); }
|
||||
hit(kind, how) { const r = this.row(kind); r.texts++; r[how]++; }
|
||||
miss(kind, text) {
|
||||
const r = this.row(kind);
|
||||
r.texts++;
|
||||
r.missing++;
|
||||
const m = this.missing.get(text) || { kind, n: 0 };
|
||||
m.n++;
|
||||
this.missing.set(text, m);
|
||||
}
|
||||
note(kind) { this.row(kind).notes++; }
|
||||
}
|
||||
|
||||
// ===== main ==========================================================================================================
|
||||
|
||||
const dictMap = (d) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !hasCjk(v)));
|
||||
|
||||
/**
|
||||
* Build the overlay. Exported for tests (they pass in-memory tables).
|
||||
* @param {{ zh: object[], en: object[], data: Record<string, any>, fallback?: { remake?: object, pr70?: object, ui?: object }, source?: object }} input
|
||||
* zh / en: the picked sub-trees of TABLES, in order
|
||||
*/
|
||||
export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
|
||||
const index = new PairIndex();
|
||||
for (let i = 0; i < zh.length; i++) index.walk(zh[i], en[i], []);
|
||||
const tr = new Translator(index, [
|
||||
{ name: 'remake', map: dictMap(fallback.remake) },
|
||||
{ name: 'pr70', map: dictMap(fallback.pr70) },
|
||||
{ name: 'ui', map: dictMap(fallback.ui) },
|
||||
]);
|
||||
const stats = new Stats();
|
||||
const samples = {};
|
||||
const files = {};
|
||||
const nameVotes = new Map(); // zh → [{ en, prio }]
|
||||
for (const file of DATA_FILES) {
|
||||
const json = data[file];
|
||||
if (!json || typeof json !== 'object' || Array.isArray(json)) continue;
|
||||
const out = {};
|
||||
for (const [id, record] of Object.entries(json)) {
|
||||
if (!record || (typeof record !== 'object' && typeof record !== 'string')) continue;
|
||||
const leaves = typeof record === 'string' ? translateScalar(file, id, record, tr, stats, samples) : translateRecord(file, id, record, tr, stats, samples);
|
||||
const ov = buildRecordOverlay(leaves.map(({ path, zh: z, en: e }) => ({ path, zh: z, en: e })));
|
||||
if (ov) out[id] = ov;
|
||||
for (const l of leaves) {
|
||||
// record-level names (and the stand-ins' operator names): what server messages name
|
||||
const top = l.path.length === 1
|
||||
|| (file === 'backups' && ((id === 'units' && l.path.length === 2) || (id === 'diy' && l.path[0] === 'operators' && l.path.length === 3)));
|
||||
if (!top || !NAME_KEYS.has(l.key) || hasCjk(l.en)) continue;
|
||||
const prio = NAME_PRIORITY.indexOf(file);
|
||||
const list = nameVotes.get(l.zh) || [];
|
||||
list.push({ en: l.en, prio: prio < 0 ? 99 : prio });
|
||||
nameVotes.set(l.zh, list);
|
||||
}
|
||||
}
|
||||
if (Object.keys(out).length) files[file] = out;
|
||||
}
|
||||
const names = {};
|
||||
const nameConflicts = [];
|
||||
for (const zhName of [...nameVotes.keys()].sort()) {
|
||||
const votes = nameVotes.get(zhName).sort((a, b) => a.prio - b.prio || (a.en < b.en ? -1 : a.en > b.en ? 1 : 0));
|
||||
names[zhName] = votes[0].en;
|
||||
const distinct = [...new Set(votes.map((v) => v.en))];
|
||||
if (distinct.length > 1) nameConflicts.push(`${zhName} → ${distinct.join(' | ')}`);
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
const overlay = {
|
||||
version: OVERLAY_VERSION,
|
||||
lang: 'en',
|
||||
meta: {
|
||||
generator: 'tools/build-i18n.mjs',
|
||||
source: source || null,
|
||||
fallback: 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json',
|
||||
coverage: Object.fromEntries(Object.entries(coverage).map(([k, v]) => [k, { texts: v.texts, translated: v.translated, official: v.exact + v.template + v.composite, pct: v.pct }])),
|
||||
totals: { texts: total, translated, official, pct: total ? Math.round((translated / total) * 1000) / 10 : 100 },
|
||||
},
|
||||
names,
|
||||
files,
|
||||
};
|
||||
const missing = [...stats.missing].sort((a, b) => (a[1].kind < b[1].kind ? -1 : a[1].kind > b[1].kind ? 1 : a[0] < b[0] ? -1 : 1))
|
||||
.map(([text, m]) => ({ kind: m.kind, n: m.n, text }));
|
||||
return { overlay, report: { coverage, totals: overlay.meta.totals, pairs: index.pairs, nameConflicts, untranslated: samples, missing } };
|
||||
}
|
||||
|
||||
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 = EN_SOURCES[opts.source];
|
||||
log(`build-i18n: EN tables from ${src.label}`);
|
||||
const zh = [];
|
||||
const en = [];
|
||||
for (const t of 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);
|
||||
en.push(t.pick ? t.pick(ej) : ej);
|
||||
}
|
||||
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))) {
|
||||
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 */ }
|
||||
const data = {};
|
||||
for (const f of DATA_FILES) {
|
||||
const abs = join(opts.data, `${f}.json`);
|
||||
if (existsSync(abs)) data[f] = JSON.parse(await readFile(abs, 'utf8'));
|
||||
}
|
||||
const readDict = async (abs) => (existsSync(abs) ? JSON.parse(await readFile(abs, 'utf8')) : {});
|
||||
const fallback = {
|
||||
remake: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-remake.json')),
|
||||
pr70: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-pr70.json')),
|
||||
ui: await readDict(join(ROOT, 'public', 'i18n', 'en.json')),
|
||||
};
|
||||
const { overlay, report } = buildOverlay({ zh, en, data, fallback, source: { id: opts.source, label: src.label, home: src.home, season: seasonEn } });
|
||||
|
||||
// self-check: applying the overlay keeps every record's shape (strings replace strings only)
|
||||
let applied = 0;
|
||||
for (const [file, fov] of Object.entries(overlay.files)) {
|
||||
const r = applyFileOverlay(data[file], fov);
|
||||
applied += r.applied;
|
||||
if (r.stale) throw new Error(`overlay of ${file} does not apply cleanly (${r.stale} stale leaves)`);
|
||||
}
|
||||
const text = `${JSON.stringify(overlay)}\n`;
|
||||
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 EN build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`);
|
||||
log(` ${report.pairs} zh→en 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(' · ');
|
||||
log(` ${kind.padEnd(10)} ${String(c.translated).padStart(5)} / ${String(c.texts).padEnd(5)} ${String(c.pct).padStart(5)} % official ${c.exact + c.template + c.composite} (exact ${c.exact} · template ${c.template} · composite ${c.composite})${fb ? ` · ${fb}` : ''}${c.notes ? ` · notes skipped ${c.notes}` : ''}`);
|
||||
}
|
||||
log(` total ${report.totals.translated} / ${report.totals.texts} (${report.totals.pct} %), official ${report.totals.official}; ${Object.keys(overlay.names).length} names (${report.nameConflicts.length} with several English forms)`);
|
||||
if (opts.check) {
|
||||
const cur = existsSync(opts.out) ? await readFile(opts.out, 'utf8') : '';
|
||||
if (cur !== text) { console.error(`build-i18n --check: ${relative(ROOT, opts.out)} is out of date (run node tools/build-i18n.mjs)`); process.exit(1); }
|
||||
log(` ${relative(ROOT, opts.out)} is up to date`);
|
||||
return;
|
||||
}
|
||||
await mkdir(dirname(opts.out), { recursive: true });
|
||||
const tmp = `${opts.out}.tmp-${process.pid}`;
|
||||
await writeFile(tmp, text);
|
||||
await rename(tmp, opts.out);
|
||||
log(` wrote ${relative(ROOT, opts.out)} (${(rep.bytes / 1024).toFixed(0)} KB); report ${relative(ROOT, opts.report)}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((e) => { console.error(`build-i18n: ${e.stack || e.message}`); process.exit(1); });
|
||||
}
|
||||
+632
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/i18n.mjs — UI string tooling for the gettext-style i18n (shared/i18n.js, public/i18n/<lang>.json; docs/I18N.md).
|
||||
//
|
||||
// node tools/i18n.mjs extract [paths…] [--list] [--json]
|
||||
// per file: the msgids passed to t() / tc() / N_() / msg(), and the Chinese literals still shown untranslated
|
||||
// (string literals, template literals, html`` text and attribute values outside those calls; comments,
|
||||
// console.* and Error messages are ignored). --list prints each literal with its line.
|
||||
// node tools/i18n.mjs check [paths…] [--lang en] [--strict] [--stale]
|
||||
// msgids the code uses that public/i18n/<lang>.json lacks (exit 1 with --strict when any); --stale also lists
|
||||
// entries no scanned code uses (not an error: phase 2 wraps the rest of the UI later).
|
||||
// node tools/i18n.mjs codemod <files…> [--write]
|
||||
// wrap Chinese literals: html`` text runs / attribute values → ${t('…')}, plain strings → t('…'), template
|
||||
// literals → t('…{name}…', { name }); adds the import. Module-level literals (evaluated once, before a language
|
||||
// can change) are only marked N_('…') and listed: call t() where they are shown. Literals compared, used as keys,
|
||||
// or passed to string methods are left alone, and so is a line marked `// i18n-ignore`. A text run with a child
|
||||
// that is not a plain value (`${a || b}`, a vnode) is split into its static pieces. Prints a summary; --write saves
|
||||
// the files. Review the result.
|
||||
// node tools/i18n.mjs seed --from <file.json> [--lang en] [--all] [--write]
|
||||
// fill public/i18n/<lang>.json from a flat { msgid: translation } map for the msgids the code uses (--all: also
|
||||
// for the Chinese literals not wrapped yet, under the msgid the codemod would give them). Existing entries win.
|
||||
//
|
||||
// Default paths: public/js, shared, server (extract / seed skip server/sim, whose strings are game logic; check reads
|
||||
// its msg() and ctx.toast texts too). Server texts: a Chinese literal passed to m.toast / ctx.toast / tickerText is a
|
||||
// msgid (the client translates the text it receives), as is the first argument of msg(). Message ids: the Chinese text itself; a template literal's expressions become named params (paramName()).
|
||||
// Needs the dev dependencies (acorn, which eslint brings).
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CJK = /[\u3400-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff60]/;
|
||||
const HAN = /[\u3400-\u9fff\uf900-\ufaff]/;
|
||||
const DEFAULT_ROOTS = ['public/js', 'shared', 'server'];
|
||||
const SKIP_DIRS = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']);
|
||||
/** Calls whose first argument is a msgid (tc: the second, keyed `context::msgid`). */
|
||||
const MSGID_CALLS = new Set(['t', 'N_', 'msg']);
|
||||
/**
|
||||
* Server messaging methods (m.toast, ctx.toast, this.tickerText …, called on an object): a Chinese string literal they
|
||||
* get is sent as text and translated by the client as a msgid (main.js translateWire).
|
||||
*/
|
||||
const SERVER_TEXT_CALLS = new Set(['toast', 'tickerText', 'ticker']);
|
||||
/** Calls whose arguments are never UI text to wrap. */
|
||||
const SKIP_CALLS = new Set(['t', 'tc', 'N_', 'msg', 'tName', 'dn', 'format', 'renderMessage', 'require', 'import']);
|
||||
/** String methods: a literal argument is data, not display text. */
|
||||
const STRING_METHODS = new Set(['includes', 'startsWith', 'endsWith', 'indexOf', 'lastIndexOf', 'replace', 'replaceAll', 'split',
|
||||
'match', 'matchAll', 'test', 'search', 'has', 'get', 'set', 'delete', 'localeCompare', 'padStart', 'padEnd', 'join', 'add']);
|
||||
/**
|
||||
* Object tables of msgids defined without N_() (their values are translated where they are shown): file → names.
|
||||
* `check` treats their string values as msgids.
|
||||
*/
|
||||
const MSGID_TABLES = [
|
||||
['shared/constants.js', ['ERR_TEXT', 'DIFFICULTY_NAMES']],
|
||||
['public/js/net.js', ['CLIENT_ERR_TEXT']],
|
||||
['public/js/main.js', ['CLOSE_REASON']],
|
||||
];
|
||||
|
||||
let acornMod = null;
|
||||
async function acorn() {
|
||||
if (acornMod) return acornMod;
|
||||
try { acornMod = await import('acorn'); } catch {
|
||||
throw new Error('tools/i18n.mjs needs the dev dependencies (npm install): acorn comes with eslint');
|
||||
}
|
||||
return acornMod;
|
||||
}
|
||||
|
||||
// ===== message ids ===================================================================================================
|
||||
|
||||
/**
|
||||
* The param name of a template-literal expression: an identifier keeps its name, `a.b.c` → `c` (`x.length` → `n`);
|
||||
* anything else is positional (`0`, `1` …, its index among the expressions).
|
||||
* @param {string} src expression source
|
||||
* @param {number} index
|
||||
*/
|
||||
export function paramName(src, index) {
|
||||
const s = String(src).trim();
|
||||
if (/^[A-Za-z_$][\w$]*$/.test(s)) return s;
|
||||
const m = s.match(/^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+$/);
|
||||
if (m) {
|
||||
const last = s.split(/\??\./).pop();
|
||||
return last === 'length' ? 'n' : last;
|
||||
}
|
||||
return String(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Msgid and params of a template literal from its static parts and expression sources.
|
||||
* @param {string[]} quasis cooked static parts (n + 1)
|
||||
* @param {string[]} exprs expression sources (n)
|
||||
* @returns {{ msgid: string, params: { name: string, src: string }[] }}
|
||||
*/
|
||||
export function templateMsgid(quasis, exprs) {
|
||||
const names = new Map(); // src → name
|
||||
const used = new Set();
|
||||
const params = [];
|
||||
let msgid = quasis[0] ?? '';
|
||||
exprs.forEach((src, i) => {
|
||||
let name = names.get(src);
|
||||
if (!name) {
|
||||
let base = paramName(src, i);
|
||||
name = base;
|
||||
for (let k = 2; used.has(name); k++) name = `${base}${k}`;
|
||||
names.set(src, name);
|
||||
used.add(name);
|
||||
params.push({ name, src });
|
||||
}
|
||||
msgid += `{${name}}${quasis[i + 1] ?? ''}`;
|
||||
});
|
||||
return { msgid, params };
|
||||
}
|
||||
|
||||
/** JS source of a single-quoted string. */
|
||||
export function quote(s) {
|
||||
return `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
|
||||
}
|
||||
|
||||
/** Source of a params object for t(): `{ a, b: expr, 0: expr }`. */
|
||||
function paramsSource(params) {
|
||||
if (!params.length) return '';
|
||||
return `, { ${params.map(({ name, src }) => (name === src ? name : `${name}: ${src}`)).join(', ')} }`;
|
||||
}
|
||||
|
||||
// ===== AST helpers ===================================================================================================
|
||||
|
||||
/** Generic AST walk with ancestors (enter(node, ancestors) → false skips the children). */
|
||||
function walk(node, enter, ancestors = []) {
|
||||
if (!node || typeof node.type !== 'string') return;
|
||||
if (enter(node, ancestors) === false) return;
|
||||
ancestors.push(node);
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'loc' || key === 'range' || key === 'start' || key === 'end') continue;
|
||||
const v = node[key];
|
||||
if (Array.isArray(v)) { for (const c of v) if (c && typeof c.type === 'string') walk(c, enter, ancestors); } else if (v && typeof v.type === 'string') walk(v, enter, ancestors);
|
||||
}
|
||||
ancestors.pop();
|
||||
}
|
||||
|
||||
const calleeName = (call) => {
|
||||
const c = call?.callee;
|
||||
if (!c) return null;
|
||||
if (c.type === 'Identifier') return c.name;
|
||||
if (c.type === 'MemberExpression' && !c.computed && c.property.type === 'Identifier') return c.property.name;
|
||||
return null;
|
||||
};
|
||||
const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console';
|
||||
|
||||
/** Why a literal must not be wrapped (null = wrap it). */
|
||||
function skipReason(node, ancestors) {
|
||||
const parent = ancestors[ancestors.length - 1];
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const a = ancestors[i];
|
||||
if (a.type === 'CallExpression') {
|
||||
if (isConsoleCall(a)) return 'console';
|
||||
const name = calleeName(a);
|
||||
if (SKIP_CALLS.has(name) && a.callee.type === 'Identifier') return 'in-call';
|
||||
}
|
||||
if (a.type === 'NewExpression' && a.callee.type === 'Identifier' && /Error$/.test(a.callee.name)) return 'error';
|
||||
if (a.type === 'ThrowStatement') return 'error';
|
||||
if (a.type === 'ImportDeclaration' || a.type === 'ExportAllDeclaration' || (a.type === 'ExportNamedDeclaration' && a.source)) return 'import';
|
||||
}
|
||||
if (!parent) return null;
|
||||
if (parent.type === 'Property' && parent.key === node && !parent.computed) return 'key';
|
||||
if (parent.type === 'MemberExpression' && parent.property === node) return 'key';
|
||||
if (parent.type === 'BinaryExpression' && ['===', '!==', '==', '!=', 'in', 'instanceof'].includes(parent.operator)) return 'compare';
|
||||
if (parent.type === 'SwitchCase' && parent.test === node) return 'compare';
|
||||
if (parent.type === 'CallExpression' && parent.arguments.includes(node)) {
|
||||
const name = calleeName(parent);
|
||||
if (parent.callee.type === 'MemberExpression' && STRING_METHODS.has(name)) return 'string-method';
|
||||
}
|
||||
if (parent.type === 'TaggedTemplateExpression') return 'tagged';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a node sits in a function (render time) rather than at module level (evaluated once at load). */
|
||||
const inFunction = (ancestors) => ancestors.some((a) => a.type === 'FunctionDeclaration' || a.type === 'FunctionExpression' || a.type === 'ArrowFunctionExpression' || a.type === 'MethodDefinition');
|
||||
|
||||
/**
|
||||
* Split an html`` template into its text runs and attribute values with Chinese (htm syntax: the static parts are
|
||||
* HTML-like, `${}` are children or attribute values). A text run is the text between two tags, `${}` children included
|
||||
* when they are simple values (identifiers, member chains, literals, String(…)); a run with other children (vnodes,
|
||||
* components, conditionals) is split into its static pieces.
|
||||
* @param {any} tpl TemplateLiteral node of an html`` tag
|
||||
* @returns {{ kind: 'text'|'attr', start: number, end: number, quasis: string[], exprs: any[], attr?: string }[]}
|
||||
*/
|
||||
function htmlSegments(tpl) {
|
||||
const out = [];
|
||||
const qs = tpl.quasis;
|
||||
let state = 'TEXT';
|
||||
let quoteCh = null;
|
||||
/** @type {{ parts: ({ text: string, from: number, to: number } | { expr: any, from: number, to: number })[] } | null} */
|
||||
let run = null;
|
||||
let attr = null;
|
||||
const simple = (e) => e.type === 'Identifier' || e.type === 'MemberExpression' || e.type === 'Literal' || (e.type === 'CallExpression' && calleeName(e) === 'String');
|
||||
const textSeg = (parts) => {
|
||||
const quasis = [];
|
||||
const exprs = [];
|
||||
let cur = '';
|
||||
for (const p of parts) { if ('expr' in p) { quasis.push(cur); cur = ''; exprs.push(p.expr); } else cur += p.text; }
|
||||
quasis.push(cur);
|
||||
const lead = quasis[0].match(/^\s*/)[0];
|
||||
const trail = quasis[quasis.length - 1].match(/\s*$/)[0];
|
||||
if (quasis.length === 1 && lead.length === quasis[0].length) return null;
|
||||
quasis[0] = quasis[0].slice(lead.length);
|
||||
quasis[quasis.length - 1] = quasis[quasis.length - 1].slice(0, quasis[quasis.length - 1].length - trail.length);
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
const start = first.from + ('expr' in first ? 0 : lead.length);
|
||||
const end = last.to - ('expr' in last ? 0 : trail.length);
|
||||
return { kind: 'text', start, end, quasis, exprs };
|
||||
};
|
||||
const flushRun = () => {
|
||||
if (!run) return;
|
||||
const parts = run.parts;
|
||||
run = null;
|
||||
if (!parts.some((p) => !('expr' in p) && HAN.test(p.text))) return;
|
||||
if (parts.every((p) => !('expr' in p) || simple(p.expr))) {
|
||||
const seg = textSeg(parts);
|
||||
if (seg) out.push(seg);
|
||||
return;
|
||||
}
|
||||
// split around the non-simple children: each piece of static text (with its simple neighbours) on its own
|
||||
let piece = [];
|
||||
const flushPiece = () => {
|
||||
if (piece.some((p) => !('expr' in p) && HAN.test(p.text))) {
|
||||
// drop simple exprs at the edges of a piece only if they are not adjacent to text? keep them: they are values
|
||||
const seg = textSeg(piece);
|
||||
if (seg) out.push(seg);
|
||||
}
|
||||
piece = [];
|
||||
};
|
||||
for (const p of parts) {
|
||||
if ('expr' in p && !simple(p.expr)) { flushPiece(); continue; }
|
||||
piece.push(p);
|
||||
}
|
||||
flushPiece();
|
||||
};
|
||||
for (let qi = 0; qi < qs.length; qi++) {
|
||||
const q = qs[qi];
|
||||
const text = q.value.raw;
|
||||
const base = q.start;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (state === 'TEXT') {
|
||||
const lt = text.indexOf('<', i);
|
||||
const stop = lt < 0 ? text.length : lt;
|
||||
if (stop > i) {
|
||||
if (!run) run = { parts: [] };
|
||||
run.parts.push({ text: text.slice(i, stop), from: base + i, to: base + stop });
|
||||
}
|
||||
if (lt < 0) break;
|
||||
flushRun();
|
||||
if (text.startsWith('<!--', lt)) { state = 'COMMENT'; i = lt + 4; continue; }
|
||||
state = 'TAG';
|
||||
i = lt + 1;
|
||||
continue;
|
||||
}
|
||||
if (state === 'COMMENT') {
|
||||
const e = text.indexOf('-->', i);
|
||||
if (e < 0) break;
|
||||
state = 'TEXT';
|
||||
i = e + 3;
|
||||
continue;
|
||||
}
|
||||
if (state === 'TAG') {
|
||||
const c = text[i];
|
||||
if (c === '>') { state = 'TEXT'; i++; continue; }
|
||||
if (c === '"' || c === "'") {
|
||||
const nm = text.slice(0, i).match(/([A-Za-z_:][-\w:.]*)\s*=\s*$/);
|
||||
attr = { name: nm ? nm[1] : null, start: base + i, parts: [] };
|
||||
quoteCh = c;
|
||||
state = 'ATTRV';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// ATTRV
|
||||
const e = text.indexOf(quoteCh, i);
|
||||
if (e < 0) { attr.parts.push({ text: text.slice(i) }); break; }
|
||||
attr.parts.push({ text: text.slice(i, e) });
|
||||
const quasis = [];
|
||||
const exprs = [];
|
||||
let cur = '';
|
||||
for (const p of attr.parts) { if ('expr' in p) { quasis.push(cur); cur = ''; exprs.push(p.expr); } else cur += p.text; }
|
||||
quasis.push(cur);
|
||||
if (attr.name && quasis.some((x) => HAN.test(x))) out.push({ kind: 'attr', attr: attr.name, start: attr.start, end: base + e + 1, quasis, exprs });
|
||||
attr = null;
|
||||
state = 'TAG';
|
||||
i = e + 1;
|
||||
}
|
||||
if (qi < tpl.expressions.length) {
|
||||
const ex = tpl.expressions[qi];
|
||||
// the `${…}` spans from the end of this static part to the start of the next one
|
||||
if (state === 'TEXT') {
|
||||
if (!run) run = { parts: [] };
|
||||
run.parts.push({ expr: ex, from: q.end, to: qs[qi + 1].start });
|
||||
} else if (state === 'ATTRV') {
|
||||
attr.parts.push({ expr: ex });
|
||||
}
|
||||
}
|
||||
}
|
||||
flushRun();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===== scanning a file ===============================================================================================
|
||||
|
||||
/**
|
||||
* Scan a source file.
|
||||
* @param {string} src
|
||||
* @param {string} file label
|
||||
* @returns {Promise<{ msgids: { msgid: string, line: number }[], literals: any[] }>}
|
||||
* literals: { kind: 'str'|'tpl'|'text'|'attr', start, end, line, msgid, params, module: boolean, reason: string|null }
|
||||
*/
|
||||
export async function scanSource(src, file = '<src>') {
|
||||
const { parse } = await acorn();
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(src, { ecmaVersion: 'latest', sourceType: 'module', locations: true, allowHashBang: true });
|
||||
} catch (e) {
|
||||
throw new Error(`${file}: cannot parse (${e.message})`, { cause: e });
|
||||
}
|
||||
const msgids = [];
|
||||
const literals = [];
|
||||
const lineOf = (pos) => src.slice(0, pos).split('\n').length;
|
||||
// a line marked `i18n-ignore` (in a comment) holds no UI text (font samples, data keys …)
|
||||
const ignored = new Set(src.split('\n').map((l, i) => (/i18n-ignore/.test(l) ? i + 1 : 0)).filter(Boolean));
|
||||
walk(ast, (node, ancestors) => {
|
||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'tc') {
|
||||
const [c, a] = node.arguments;
|
||||
if (c?.type === 'Literal' && a?.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: `${c.value}::${a.value}`, line: lineOf(a.start), via: 'tc' });
|
||||
}
|
||||
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && MSGID_CALLS.has(node.callee.name)) {
|
||||
const a = node.arguments[0];
|
||||
if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via: node.callee.name });
|
||||
else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via: node.callee.name });
|
||||
}
|
||||
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
|
||||
for (const seg of htmlSegments(node.quasi)) {
|
||||
const { msgid, params } = templateMsgid(seg.quasis, seg.exprs.map((e) => src.slice(e.start, e.end)));
|
||||
literals.push({ kind: seg.kind, attr: seg.attr, start: seg.start, end: seg.end, line: lineOf(seg.start), msgid, params, exprs: seg.exprs.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason: null });
|
||||
}
|
||||
// expressions inside are walked normally (nested html``, strings in ${…})
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'Literal' && typeof node.value === 'string' && HAN.test(node.value)) {
|
||||
const parent = ancestors[ancestors.length - 1];
|
||||
if (parent && parent.type === 'TemplateLiteral') return true;
|
||||
// an argument of m.toast / ctx.toast / tickerText, directly or as a branch of `a ? '…' : '…'` / `x || '…'`
|
||||
let arg = node;
|
||||
let k = ancestors.length - 1;
|
||||
while (k >= 0 && ((ancestors[k].type === 'ConditionalExpression' && ancestors[k].test !== arg) || ancestors[k].type === 'LogicalExpression')) { arg = ancestors[k]; k--; }
|
||||
const call = ancestors[k];
|
||||
if (call && call.type === 'CallExpression' && call.callee.type === 'MemberExpression' && SERVER_TEXT_CALLS.has(calleeName(call)) && call.arguments.includes(arg)) {
|
||||
msgids.push({ msgid: node.value, line: lineOf(node.start), via: 'server' });
|
||||
return true;
|
||||
}
|
||||
const line = lineOf(node.start);
|
||||
const reason = ignored.has(line) ? 'ignored' : skipReason(node, ancestors);
|
||||
literals.push({ kind: 'str', start: node.start, end: node.end, line, msgid: node.value, params: [], module: !inFunction(ancestors), reason });
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'TemplateLiteral') {
|
||||
const parent = ancestors[ancestors.length - 1];
|
||||
if (parent && parent.type === 'TaggedTemplateExpression') return true;
|
||||
const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw);
|
||||
if (!cooked.some((s) => HAN.test(s))) return true;
|
||||
const reason = ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors);
|
||||
const { msgid, params } = templateMsgid(cooked, node.expressions.map((e) => src.slice(e.start, e.end)));
|
||||
literals.push({ kind: 'tpl', start: node.start, end: node.end, line: lineOf(node.start), msgid, params, exprs: node.expressions.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason });
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { msgids, literals };
|
||||
}
|
||||
|
||||
/** String values of `const NAME = { … }` / `Object.freeze({ … })` object tables (MSGID_TABLES). */
|
||||
async function tableMsgids(src, names) {
|
||||
const { parse } = await acorn();
|
||||
const ast = parse(src, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
const out = [];
|
||||
walk(ast, (node) => {
|
||||
if (node.type !== 'VariableDeclarator' || node.id.type !== 'Identifier' || !names.includes(node.id.name)) return true;
|
||||
walk(node.init, (n) => {
|
||||
if (n.type === 'Property' && n.value.type === 'Literal' && typeof n.value.value === 'string' && HAN.test(n.value.value)) out.push(n.value.value);
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* JS files under the paths. server/sim is skipped unless `sim` (its strings are game logic and data; only the messages
|
||||
* it sends through ctx.toast matter, and `check` reads those).
|
||||
*/
|
||||
function listFiles(paths, { sim = false } = {}) {
|
||||
const out = [];
|
||||
const visit = (abs) => {
|
||||
const st = statSync(abs);
|
||||
if (st.isDirectory()) {
|
||||
for (const name of readdirSync(abs).sort()) {
|
||||
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
|
||||
const rel = path.relative(ROOT, path.join(abs, name)).split(path.sep).join('/');
|
||||
if (rel === 'server/sim' && !sim) continue;
|
||||
visit(path.join(abs, name));
|
||||
}
|
||||
} else if (/\.(m?js)$/.test(abs)) out.push(abs);
|
||||
};
|
||||
for (const p of paths) visit(path.resolve(ROOT, p));
|
||||
return out;
|
||||
}
|
||||
|
||||
const rel = (abs) => path.relative(ROOT, abs).split(path.sep).join('/');
|
||||
|
||||
// ===== codemod =======================================================================================================
|
||||
|
||||
/** Relative import path from a file to shared/i18n.js. */
|
||||
function importPath(absFile) {
|
||||
let r = path.relative(path.dirname(absFile), path.join(ROOT, 'shared', 'i18n.js')).split(path.sep).join('/');
|
||||
if (!r.startsWith('.')) r = `./${r}`;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a source: wrap its Chinese literals. Returns the new source and what was done.
|
||||
* @param {string} src
|
||||
* @param {string} absFile
|
||||
*/
|
||||
export async function codemodSource(src, absFile) {
|
||||
const { literals } = await scanSource(src, rel(absFile));
|
||||
const manual = [];
|
||||
const skipped = [];
|
||||
let needT = false;
|
||||
let needN = false;
|
||||
/** literal → { start, end, make(paramSrc) } (the text is built once nested edits are known) */
|
||||
const plans = [];
|
||||
for (const l of literals) {
|
||||
if (l.reason) { skipped.push(l); continue; }
|
||||
if (l.kind === 'text' || l.kind === 'attr') {
|
||||
plans.push({ l, start: l.start, end: l.end, make: (p) => `\${t(${quote(l.msgid)}${p})}` });
|
||||
needT = true;
|
||||
if (l.module) manual.push(l);
|
||||
continue;
|
||||
}
|
||||
if (l.module) {
|
||||
if (l.kind === 'str') { plans.push({ l, start: l.start, end: l.end, make: () => `N_(${src.slice(l.start, l.end)})` }); needN = true; }
|
||||
manual.push(l);
|
||||
continue;
|
||||
}
|
||||
plans.push({ l, start: l.start, end: l.end, make: (p) => `t(${quote(l.msgid)}${p})` });
|
||||
needT = true;
|
||||
}
|
||||
// a plan inside another one (a string in a template's ${…}) is applied to that template's param source instead
|
||||
const inner = (outer, x) => x !== outer && x.start >= outer.start && x.end <= outer.end;
|
||||
const top = plans.filter((p) => !plans.some((o) => inner(o, p)));
|
||||
const rewrite = (from, to, nested) => {
|
||||
let text = src.slice(from, to);
|
||||
for (const n of nested.filter((x) => x.start >= from && x.end <= to).sort((a, b) => b.start - a.start)) {
|
||||
text = text.slice(0, n.start - from) + build(n) + text.slice(n.end - from);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
const build = (plan) => {
|
||||
const nested = plans.filter((x) => inner(plan, x) && !plans.some((o) => o !== plan && inner(plan, o) && inner(o, x)));
|
||||
const { params = [], exprs = [] } = plan.l;
|
||||
// params come from the expressions in order; the same expression source maps to one param
|
||||
const srcOf = new Map();
|
||||
exprs.forEach(([a, b]) => { const orig = src.slice(a, b); if (!srcOf.has(orig)) srcOf.set(orig, rewrite(a, b, nested)); });
|
||||
const ps = params.map(({ name, src: orig }) => ({ name, src: srcOf.get(orig) ?? orig }));
|
||||
return plan.make(paramsSource(ps));
|
||||
};
|
||||
const edits = top.map((p) => ({ start: p.start, end: p.end, text: build(p) })).sort((a, b) => b.start - a.start);
|
||||
let out = src;
|
||||
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
||||
const names = [needT && 't', needN && 'N_'].filter(Boolean);
|
||||
if (names.length) {
|
||||
const imp = importPath(absFile);
|
||||
const re = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${imp.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"];?`);
|
||||
const m = out.match(re);
|
||||
if (m) {
|
||||
const have = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const want = [...new Set([...have, ...names])];
|
||||
out = out.replace(re, `import { ${want.join(', ')} } from '${imp}';`);
|
||||
} else {
|
||||
// after the last import statement
|
||||
const imports = [...out.matchAll(/^import[^;]*;[^\n]*\n/gm)];
|
||||
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
||||
out = `${out.slice(0, at)}import { ${names.join(', ')} } from '${imp}';\n${out.slice(at)}`;
|
||||
}
|
||||
}
|
||||
return { src: out, edits: plans.length, manual, skipped };
|
||||
}
|
||||
|
||||
// ===== commands ======================================================================================================
|
||||
|
||||
function parseFlags(argv) {
|
||||
const flags = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const [k, v] = a.slice(2).split('=');
|
||||
if (v !== undefined) flags[k] = v;
|
||||
else if (['from', 'lang'].includes(k)) flags[k] = argv[++i];
|
||||
else flags[k] = true;
|
||||
} else flags._.push(a);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const catalogPath = (lang) => path.join(ROOT, 'public', 'i18n', `${lang}.json`);
|
||||
function readCatalog(lang) {
|
||||
const p = catalogPath(lang);
|
||||
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
||||
}
|
||||
function writeCatalog(lang, cat) {
|
||||
const meta = Object.entries(cat).filter(([k]) => k.startsWith('_'));
|
||||
const rest = Object.entries(cat).filter(([k]) => !k.startsWith('_'));
|
||||
writeFileSync(catalogPath(lang), `${JSON.stringify(Object.fromEntries([...meta, ...rest]), null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function usedMsgids(files) {
|
||||
const used = new Map(); // msgid → first "file:line"
|
||||
for (const f of files) {
|
||||
const { msgids } = await scanSource(readFileSync(f, 'utf8'), rel(f));
|
||||
// server code never translates: its t() / N_() are other helpers (blackboard lookups in the sim); only msg() and
|
||||
// the texts it sends count there
|
||||
const server = rel(f).startsWith('server/');
|
||||
for (const m of msgids) {
|
||||
if (server && m.via !== 'msg' && m.via !== 'server') continue;
|
||||
if (!used.has(m.msgid)) used.set(m.msgid, `${rel(f)}:${m.line}`);
|
||||
}
|
||||
}
|
||||
for (const [file, names] of MSGID_TABLES) {
|
||||
const abs = path.join(ROOT, file);
|
||||
if (!existsSync(abs)) continue;
|
||||
for (const m of await tableMsgids(readFileSync(abs, 'utf8'), names)) if (!used.has(m)) used.set(m, file);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
async function cmdExtract(flags) {
|
||||
const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS);
|
||||
const rows = [];
|
||||
let tCount = 0, litCount = 0;
|
||||
for (const f of files) {
|
||||
const { msgids, literals } = await scanSource(readFileSync(f, 'utf8'), rel(f));
|
||||
const open = literals.filter((l) => !l.reason || l.reason === 'compare' || l.reason === 'string-method' || l.reason === 'key');
|
||||
const shown = literals.filter((l) => !l.reason);
|
||||
tCount += msgids.length;
|
||||
litCount += shown.length;
|
||||
if (msgids.length || shown.length) rows.push({ file: rel(f), t: msgids.length, untranslated: shown.length, literals: shown, other: open.length - shown.length });
|
||||
}
|
||||
if (flags.json) { console.log(JSON.stringify(rows.map(({ literals, ...r }) => ({ ...r, literals: literals.map((l) => ({ line: l.line, kind: l.kind, msgid: l.msgid, module: l.module })) })), null, 1)); return; }
|
||||
for (const r of rows) {
|
||||
console.log(`${r.file.padEnd(48)} t() ${String(r.t).padStart(4)} untranslated ${String(r.untranslated).padStart(4)}`);
|
||||
if (flags.list) for (const l of r.literals) console.log(` ${String(l.line).padStart(5)} ${l.kind.padEnd(4)}${l.module ? ' (module)' : ''} ${l.msgid.replace(/\n/g, '⏎').slice(0, 90)}`);
|
||||
}
|
||||
const cat = readCatalog(flags.lang || 'en');
|
||||
const ready = rows.reduce((n, r) => n + r.literals.filter((l) => typeof cat[l.msgid] === 'string' && cat[l.msgid]).length, 0);
|
||||
console.log(`total: ${tCount} msgids in t() / tc() / N_() / msg(); ${litCount} Chinese literals not wrapped, in ${rows.filter((r) => r.untranslated).length} files`
|
||||
+ ` (${ready} of them already translated in public/i18n/${flags.lang || 'en'}.json, ready for the codemod)`);
|
||||
}
|
||||
|
||||
async function cmdCheck(flags) {
|
||||
const lang = flags.lang || 'en';
|
||||
const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS, { sim: true });
|
||||
const used = await usedMsgids(files);
|
||||
const cat = readCatalog(lang);
|
||||
const missing = [...used].filter(([m]) => !(typeof cat[m] === 'string' && cat[m]));
|
||||
for (const [m, where] of missing) console.log(`missing ${where.padEnd(40)} ${m.replace(/\n/g, '⏎')}`);
|
||||
if (flags.stale) {
|
||||
for (const k of Object.keys(cat)) if (!k.startsWith('_') && !used.has(k)) console.log(`unused ${k.replace(/\n/g, '⏎')}`);
|
||||
}
|
||||
console.log(`${used.size} msgids used, ${used.size - missing.length} translated in public/i18n/${lang}.json, ${missing.length} missing`);
|
||||
if (flags.strict && missing.length) process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function cmdCodemod(flags) {
|
||||
if (!flags._.length) throw new Error('codemod needs file paths');
|
||||
for (const p of flags._) {
|
||||
const abs = path.resolve(ROOT, p);
|
||||
const src = readFileSync(abs, 'utf8');
|
||||
const res = await codemodSource(src, abs);
|
||||
console.log(`${rel(abs)}: ${res.edits} literals wrapped, ${res.skipped.length} left (compare / key / string method / console / error)`);
|
||||
for (const l of res.manual) console.log(` manual line ${l.line}: module-level ${l.kind} — call t() where it is shown: ${l.msgid.slice(0, 70)}`);
|
||||
if (flags.write && res.src !== src) writeFileSync(abs, res.src);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSeed(flags) {
|
||||
if (!flags.from) throw new Error('seed needs --from <file.json>');
|
||||
const lang = flags.lang || 'en';
|
||||
const from = JSON.parse(readFileSync(path.resolve(ROOT, flags.from), 'utf8'));
|
||||
const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS);
|
||||
const want = new Map(await usedMsgids(files));
|
||||
if (flags.all) {
|
||||
for (const f of files) {
|
||||
const { literals } = await scanSource(readFileSync(f, 'utf8'), rel(f));
|
||||
for (const l of literals) if ((!l.reason || l.reason === 'tagged') && !want.has(l.msgid)) want.set(l.msgid, `${rel(f)}:${l.line}`);
|
||||
}
|
||||
}
|
||||
const cat = readCatalog(lang);
|
||||
let added = 0;
|
||||
for (const [m] of want) {
|
||||
if (typeof cat[m] === 'string' && cat[m]) continue;
|
||||
const tr = from[m];
|
||||
if (typeof tr === 'string' && tr && !HAN.test(tr)) { cat[m] = tr; added++; }
|
||||
}
|
||||
console.log(`${want.size} msgids considered, ${added} added to public/i18n/${lang}.json${flags.write ? '' : ' (dry run: --write saves)'}`);
|
||||
if (flags.write) writeCatalog(lang, cat);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const flags = parseFlags(rest);
|
||||
const cmds = { extract: cmdExtract, check: cmdCheck, codemod: cmdCodemod, seed: cmdSeed };
|
||||
if (!cmds[cmd]) {
|
||||
console.log('usage: node tools/i18n.mjs extract|check|codemod|seed … (see the header of tools/i18n.mjs)');
|
||||
process.exitCode = cmd ? 2 : 0;
|
||||
return;
|
||||
}
|
||||
await cmds[cmd](flags);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((e) => { console.error(`i18n: ${e.message}`); process.exitCode = 2; });
|
||||
}
|
||||
|
||||
export { CJK, MSGID_TABLES };
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"_about": "Fallback English for data/*.json texts the official EN tables lack (tools/build-i18n.mjs step 5). Taken from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (the owner's decision of 2026-10-05; NOTICE.md). Entries PR #70 took from ak-spa-database.pages.dev (no licence) are not included. Regenerate the list of untranslated texts with node tools/build-i18n.mjs (report: .cache/build-i18n-report.json `missing`).",
|
||||
"“新车和新生活”": "\"New Car, New Life\"",
|
||||
"“独属自己的一隅”": "\"A Corner of One's Own\"",
|
||||
"“老朋友”": "\"Old Friend\"",
|
||||
"【炎】6名成员激活时召唤的友方单位;开战时攻击力/生命值增加【炎】干员攻击力/生命值总和的30%(见 bonds.json yanShip)": "A friendly unit summoned when 6 members of [Yan] are active; at battle start, ATK/HP increased by 30% of the total ATK/HP of [Yan] Operators (see bonds.json yanShip)",
|
||||
"伤心": "Sad",
|
||||
"再见!": "Bye!",
|
||||
"剑舞": "Sword Dance",
|
||||
"博士士 1": "Doctor-tor 1",
|
||||
"博士士 2": "Doctor-tor 2",
|
||||
"博士士 3": "Doctor-tor 3",
|
||||
"博士士 4": "Doctor-tor 4",
|
||||
"博士士 5": "Doctor-tor 5",
|
||||
"博士士 6": "Doctor-tor 6",
|
||||
"合作愉快": "Nice teamwork",
|
||||
"合作愉快!": "Nice teamwork!",
|
||||
"在<@ba.kw>生息演算</>中,首次开启技能后,生命上限、攻击力、防御力提升至150%": "In <@ba.kw>Reclamation Algorithm</>, after the skill is activated for the first time, Max HP, ATK, and DEF increase to 150%",
|
||||
"在<@ba.kw>集成战略</>中,自身和<替身>造成的物理和法术伤害提升40%,受到的物理和法术伤害减少40%": "In <@ba.kw>Integrated Strategies</>, Physical and Arts damage dealt by self and the <Substitute> +40%, Physical and Arts damage taken -40%",
|
||||
"在生息演算中,首次开启技能后,生命上限、攻击力、防御力提升至150%": "In Reclamation Algorithm, after the skill is activated for the first time, Max HP, ATK, and DEF increase to 150%",
|
||||
"在集成战略中,自身和<替身>造成的物理和法术伤害提升40%,受到的物理和法术伤害减少40%": "In Integrated Strategies, Physical and Arts damage dealt by self and the <Substitute> +40%, Physical and Arts damage taken -40%",
|
||||
"害怕": "Scared",
|
||||
"对不起": "Sorry",
|
||||
"对不起!": "Sorry!",
|
||||
"对敌方领袖造成伤害最高(仅胜利时)": "Highest damage dealt to the Enemy Leader (victory only)",
|
||||
"开心": "Happy",
|
||||
"归溟幽灵鲨特限证章": "Skadi the Corrupting Heart Limited Badge",
|
||||
"很快就好!": "Almost done!",
|
||||
"快死了": "Dying",
|
||||
"思考": "Thinking",
|
||||
"战场#04 活性源石": "Battlefield #04 Active Originium",
|
||||
"战场#05(上半) 涨潮/特制水上平台": "Battlefield #05 (First Half) High Tide/Special Water Platform",
|
||||
"战场#07(下半) 排气格栅": "Battlefield #07 (Second Half) Exhaust Grille",
|
||||
"攻击力+8%,攻击被<$ba.tremble>战栗</>的目标时,无视目标33%的防御力,地面敌人首次进入自身攻击范围时,使其<$ba.tremble>战栗</>6秒": "ATK +8%; when attacking <$ba.tremble>Trembling</> targets, ignores 33% of the target's DEF; when a ground enemy first enters Attack Range, inflicts <$ba.tremble>Tremble</> on it for 6 seconds",
|
||||
"攻击力+8%,攻击被战栗的目标时,无视目标33%的防御力,地面敌人首次进入自身攻击范围时,使其战栗6秒": "ATK +8%; when attacking Trembling targets, ignores 33% of the target's DEF; when a ground enemy first enters Attack Range, inflicts Tremble on it for 6 seconds",
|
||||
"攻击命中生命值在80%以上的敌人时,3秒内异客对其造成的伤害提升20%;在<@ba.kw>生息演算</>中,每10秒选择场上5个目标进行雷击(密集的目标更易被选中),对范围内所有单位造成异客攻击力500%的法术伤害,并获得该范围内的视野12秒,使命中的友方单位获得2点技力": "When hitting an enemy with more than 80% HP, Passenger deals 20% more damage to it for 3 seconds; in <@ba.kw>Reclamation Algorithm</>, every 10 seconds strikes 5 targets on the field with lightning (dense targets are more likely to be chosen), dealing Arts damage equal to 500% of Passenger's ATK to all units in the area, granting vision of that area for 12 seconds, and giving hit friendly units 2 SP",
|
||||
"攻击命中生命值在80%以上的敌人时,3秒内异客对其造成的伤害提升20%;在生息演算中,每10秒选择场上5个目标进行雷击(密集的目标更易被选中),对范围内所有单位造成异客攻击力500%的法术伤害,并获得该范围内的视野12秒,使命中的友方单位获得2点技力": "When hitting an enemy with more than 80% HP, Passenger deals 20% more damage to it for 3 seconds; in Reclamation Algorithm, every 10 seconds strikes 5 targets on the field with lightning (dense targets are more likely to be chosen), dealing Arts damage equal to 500% of Passenger's ATK to all units in the area, granting vision of that area for 12 seconds, and giving hit friendly units 2 SP",
|
||||
"攻击时有25%的几率使目标<$ba.root>束缚</>1.5秒,首次命中敌人时提升至100%且<$ba.root>束缚</>时间提升至3秒": "Attacks have a 25% chance to <$ba.root>Bind</> the target for 1.5 seconds; the first hit on each enemy has a 100% chance and the <$ba.root>Bind</> duration is increased to 3 seconds",
|
||||
"攻击时有25%的几率使目标束缚1.5秒,首次命中敌人时提升至100%且束缚时间提升至3秒": "Attacks have a 25% chance to Bind the target for 1.5 seconds; the first hit on each enemy has a 100% chance and the Bind duration is increased to 3 seconds",
|
||||
"攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至110%": "When there is an injured allied Operator within Attack Range, damage dealt increases to 110%",
|
||||
"攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至<@ba.kw>110%</>": "When there is an injured allied Operator within Attack Range, damage dealt increases to <@ba.kw>110%</>",
|
||||
"敬礼!": "Salute!",
|
||||
"新合同": "New Contract",
|
||||
"晋升精锐次数最多": "Most Elite promotions",
|
||||
"欢呼!": "Cheers!",
|
||||
"欲雪时": "When Snow Is Coming",
|
||||
"沟通小助手": "Little Communication Helper",
|
||||
"没问题!": "No problem!",
|
||||
"消耗资金最多": "Most Funds spent",
|
||||
"激活的盟约层数总和最高": "Highest total activated Alliance Stacks",
|
||||
"甄选干员": "Selected Operator",
|
||||
"百炼嘉维尔特限证章": "Gavial the Invincible Limited Badge",
|
||||
"目标生命值损失最少": "Least LP lost",
|
||||
"策略【不稳定要素】(昆图斯)": "Strategy [Unstable Element] (Quintus)",
|
||||
"米米子 1": "Mimiko 1",
|
||||
"米米子 2": "Mimiko 2",
|
||||
"米米子 3": "Mimiko 3",
|
||||
"米米子 4": "Mimiko 4",
|
||||
"米米子 5": "Mimiko 5",
|
||||
"米米子 6": "Mimiko 6",
|
||||
"维维美 1": "Wiwime 1",
|
||||
"维维美 2": "Wiwime 2",
|
||||
"维维美 3": "Wiwime 3",
|
||||
"维维美 4": "Wiwime 4",
|
||||
"维维美 5": "Wiwime 5",
|
||||
"维维美 6": "Wiwime 6",
|
||||
"表情套组:博士士": "Emote Set: Doctor-tor",
|
||||
"表情套组:米米子": "Emote Set: Mimiko",
|
||||
"表情套组:维维美": "Emote Set: Wiwime",
|
||||
"装备配发数量最多": "Most Equipment equipped",
|
||||
"谢谢": "Thanks",
|
||||
"谢谢!": "Thanks!",
|
||||
"造成伤害时有30%概率对敌人施加灼痕效果:攻击力-20%、38%的<$ba.magicfragile>【法术脆弱】</>,不可叠加持续8秒": "When dealing damage, 30% chance to inflict Scorch on the enemy: ATK -20% and 38% <$ba.magicfragile>[Arts Fragility]</>, non-stackable, lasts 8 seconds",
|
||||
"造成伤害时有30%概率对敌人施加灼痕效果:攻击力-20%、38%的【法术脆弱】,不可叠加持续8秒": "When dealing damage, 30% chance to inflict Scorch on the enemy: ATK -20% and 38% [Arts Fragility], non-stackable, lasts 8 seconds",
|
||||
"酷!": "Cool!"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"_about": "Fallback English for the remake's own data texts (names and notes tools/build-data.mjs writes; no official counterpart). Official EN terms where they exist: 'Active Originium', 'High Tide', 'Mutated Cells', 'Rockrock', 'Stronghold Protocol: Alliance' (the EN activity name). '排气格栅' follows PR #70's wording (no official EN occurrence found).",
|
||||
"卫戍协议:盟约": "Stronghold Protocol: Alliance",
|
||||
"活性源石": "Active Originium",
|
||||
"涨潮": "High Tide",
|
||||
"排气格栅": "Exhaust Grille",
|
||||
"联防阵地(1名玩家)": "Unite Field (1 player)",
|
||||
"联防阵地(2名玩家)": "Unite Field (2 players)",
|
||||
"维多利亚盟约每25层 / 洛洛的定制品": "Victoria Alliance (every 25 stacks) / Rockrock's custom items",
|
||||
"生效时原干员销毁,突变细胞与其他装备退回整备区,可再次配发;随后获得一名高一阶的随机初始干员(最高6阶),进入整备区,需要重新部署": "When it takes effect, the Operator is destroyed and the Mutated Cells and other equipment return to the Bench, ready to be equipped again; then a random starting Operator one Tier higher (up to Tier 6) joins the Bench and has to be deployed again"
|
||||
}
|
||||
Reference in new issue
Block a user