feedback5 W5S: i18n phase 2 coverage — test/i18n.test.js scans the whole client, shared and the server messages (no Chinese literal unwrapped, every msgid translated: extract 0 and check --strict as a test; floors 1 000 catalog entries, 1 350 msgid uses), public/i18n/en.json drops the 64 fragments / renamed msgids the sweep replaced (none is a data text), docs/I18N.md: phase-2 coverage, what stays Chinese and why, the server's game texts, the audit's skip rules

This commit is contained in:
sganggs committed 2026-10-06 18:31:28 +08:00
1 parent 0025ceac16
commit c2e77c7fe9
3 files changed
+67 -90

No files matched your search

+41 -15
View File
@@ -46,8 +46,9 @@ html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">$
### Translating ### Translating
Edit `public/i18n/en.json` (`"msgid": "English"`, two-space JSON; `_about` is metadata). Keep every `{placeholder}`; 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 `test/i18n.test.js` checks that, that no Chinese is left in a value, and that every msgid the code uses is present. A
present. Use the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts): translation may use more params than its msgid (`'{n} {count|unit|units}'` with `count` passed beside a vnode `n`). Use
the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts):
| 中文 | English | 中文 | English | | 中文 | English | 中文 | English |
|---|---|---|---| |---|---|---|---|
@@ -80,7 +81,15 @@ present. Use the official EN terms of the game (the EN client's own wording, e.g
code uses (`--all`: also the literals not wrapped yet, under the msgid the codemod will give them). 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` / 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). `tickerText` are msgids (`server/sim` only for those — its own `t()` helpers are not translation calls). Not player text,
so neither wrapped nor counted: `console.*` / logger calls (`log.warn(…)`, `this.m.log?.info?.(…)`), `Error` messages,
the `detail` of an error result (`{ error: 'BAD_TARGET', detail: '…' }`, `ev.detail = '…'` — players see `ERR_TEXT`), a
line marked `// i18n-ignore` and a file marked `i18n-ignore-file` in a comment (the developer invariant reports
`server/match/audit.js` / `invariants.js`, the bilingual HTTP error pages `server/http/*`). An aliased import
(`import { t as tr }`, where `t` is a local variable) is read as the function it names.
`test/i18n.test.js` scans what `extract` / `check` scan (public/js, shared, server) and fails on a Chinese literal left
unwrapped or a msgid missing from `public/i18n/en.json` — `extract` 0 and `check --strict` are part of the tests.
## 2. Game texts: `data/i18n/en.json` ## 2. Game texts: `data/i18n/en.json`
@@ -138,21 +147,38 @@ protocol is backward compatible both ways (new fields are optional).
this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) })); this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
``` ```
## 4. Phase 2 (after the 0.2.0 feature screens land) Game texts the server copies into `m.public` / `m.private` in Chinese — a 机变 card's name and description, the draft's
name, an effect entry of the effects column, a reward offer's label, a result title — are shown from the localized data
on the client: names through `tName()` (every record name is in the overlay's `names`), texts through
`ui/lang.js sentText(sent, raw, local)`, which takes the localized record's text when the sent text is that record's own
Chinese text (`data.lookupRaw` / `data.getRaw`), else the sent text (a line the server reworded — the two-battle text of a
multi-round 悬赏, `server/match/choices.js bountyText` — stays Chinese). In Chinese both return what the server sent.
## 4. Coverage of the UI strings (phase 2, 0.2.0)
Phase 1 converted the title screen, the lobby, the room, the settings panel (with the language switch), the app shell 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: (main.js: toasts, close reasons, the crash screen), `ui/toasts.js`, `DifficultyTag` and the server messages. Phase 2
(2026-10-06) converted the rest: of the 1 128 Chinese literals `extract` listed, 1 056 go through `t()` / `tParts()` /
`N_()` — the briefing, the band draft, the game screen and its overlays, 干员调配 / 干员持有, the result (275), the HUD,
shop bar, detail card, bond strip, 机变 overlay, enemy drawer, team panel, guide, emotes, components and the client
models (579), the phase banners, refusal reasons and formats of `ui/gameLogic` (106), the canvas / fallback-field / facing
labels (16) and the msgid tables of `shared/constants.js` (80) — and 72 are marked as no player text (above). Now
`node tools/i18n.mjs extract` reports 0, `check` 1 002 msgids, all in `public/i18n/en.json` (1 015 entries). The Chinese
output did not change: a render of every converted file in Chinese (each `t()` call put back as the string it renders,
html`` parsed with htm, text runs compared) equals the code before the sweep; the few rewrites it cannot follow (a
variable folded into whole sentences, a list param in place of `.join('、')`, `fmtNum`'s English branch) were checked by
hand. Numbers: `fmtNum` writes 万 / 亿 in Chinese and K / M / B in English.
1. `node tools/i18n.mjs extract` lists the rest (2026-10-05: 1 033 literals in 56 files — briefing, band draft, game What stays Chinese in English, and why:
screen and its HUD / panels / overlays, loadout, result, guide, connection banner, components …; 964 of them already
have a translation seeded from PR #70). - player names, including the AI seats' (`AI·华法琳` …, `server/lobby.js BOT_NAMES`) — a name is never translated;
2. Per file (one stream per file group to avoid conflicts): `node tools/i18n.mjs codemod <file> --write`, review the - a server text the client cannot match to a data record (the reworded multi-round 悬赏 text above);
diff (module-level tables: `t()` at the use site; join split sentences; `tc()` where one Chinese text needs two - game texts the official EN client does not have yet (§2: some 自选 operators, newer modules);
English ones), then `node tools/i18n.mjs check` and translate what is missing, in the official terms above. - the 玩法说明 pages, which are the official Chinese pictures (their titles are translated).
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. Not player text, never translated: developer details and logs (`detail`, `log.*`, the invariant reports), the data keys
4. `public/js/ui/guide.js` chapter / page titles, the `GuideButton` / `LoadoutButton` default labels and tooltips. (`shared/highGround.js` reads the trait text 「可以放置于远程位」), the development STUB match, and the HTTP error pages,
5. When the sweep is complete: `node tools/i18n.mjs check --strict` in CI, and `extract` reporting 0. which are bilingual on purpose.
## Credits ## Credits
-64
View File
@@ -183,10 +183,7 @@
"战场": "Battlefield", "战场": "Battlefield",
"本局禁用": "Disabled this match", "本局禁用": "Disabled this match",
"{skip};本局不限时": "{skip}; no time limit this match", "{skip};本局不限时": "{skip}; no time limit this match",
"每位博士有 {0} 秒": "each Doctor has {0} s",
"每位博士限时决策": "each Doctor has a time limit", "每位博士限时决策": "each Doctor has a time limit",
"当前选中的": "the currently highlighted ",
"已选择「{0}」{1}": "Selected \"{0}\"{1}",
",等待其他博士": ", waiting for other Doctors", ",等待其他博士": ", waiting for other Doctors",
"轮到你决策": "Your turn to decide", "轮到你决策": "Your turn to decide",
"{turnName} 决策中": "{turnName} is deciding", "{turnName} 决策中": "{turnName} is deciding",
@@ -211,8 +208,6 @@
"战场随机(共{poolN}张)": "Random Battlefield ({poolN} total)", "战场随机(共{poolN}张)": "Random Battlefield ({poolN} total)",
"确认本局信息": "Confirm Match Info", "确认本局信息": "Confirm Match Info",
"ENEMY LEADER // 敌方领袖": "ENEMY LEADER // Enemy Leader", "ENEMY LEADER // 敌方领袖": "ENEMY LEADER // Enemy Leader",
"第": "Round",
"回合 · 最终攻势": "· Final Assault",
"特训敌人": "Tactical Training Enemies", "特训敌人": "Tactical Training Enemies",
"未知领袖": "Unknown Leader", "未知领袖": "Unknown Leader",
"本局没有特训敌人": "No Tactical Training Enemies this match", "本局没有特训敌人": "No Tactical Training Enemies this match",
@@ -228,11 +223,9 @@
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)", "当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)",
"已配发的装备无法销毁": "Equipped Equipment cannot be destroyed", "已配发的装备无法销毁": "Equipped Equipment cannot be destroyed",
"销毁道具": "Destroy Item", "销毁道具": "Destroy Item",
"道具无法出售。确定要销毁「{0}」吗?": "Items cannot be sold. Destroy \"{0}\"?",
"道具": "Item", "道具": "Item",
"销毁": "Destroy", "销毁": "Destroy",
"正在查看": "Viewing", "正在查看": "Viewing",
"的阵地(只读)": "'s Field (read-only)",
"返回自己": "Back to Mine", "返回自己": "Back to Mine",
"你已被淘汰 · 可继续观战队友": "You have been eliminated · You can keep spectating your Teammates", "你已被淘汰 · 可继续观战队友": "You have been eliminated · You can keep spectating your Teammates",
"已调整": "Customized", "已调整": "Customized",
@@ -274,7 +267,6 @@
"按盟约筛选": "Filter by Alliance", "按盟约筛选": "Filter by Alliance",
"全部盟约": "All Alliances", "全部盟约": "All Alliances",
"仅看已调整": "Customized Only", "仅看已调整": "Customized Only",
"{t}阶": "Tier {t}",
"保存中…": "Saving…", "保存中…": "Saving…",
"同步中…": "Syncing…", "同步中…": "Syncing…",
"已同步": "Synced", "已同步": "Synced",
@@ -308,7 +300,6 @@
"把导出的内容粘贴到下方,或点「选择文件」。": "Paste exported data below, or tap [Choose File]. ", "把导出的内容粘贴到下方,或点「选择文件」。": "Paste exported data below, or tap [Choose File]. ",
"覆盖": "overwrite", "覆盖": "overwrite",
"在此粘贴导出的调配内容…": "Paste the exported loadout here…", "在此粘贴导出的调配内容…": "Paste the exported loadout here…",
"调整干员携带的技能与模组": "Change the Skills and Modules your Operators carry",
"造成伤害": "Damage Dealt", "造成伤害": "Damage Dealt",
"击倒敌人": "Enemies Defeated", "击倒敌人": "Enemies Defeated",
"领袖伤害": "Leader Damage", "领袖伤害": "Leader Damage",
@@ -335,8 +326,6 @@
"防线已被突破": "The defense line has been breached", "防线已被突破": "The defense line has been breached",
"敌方领袖": "Enemy Leader", "敌方领袖": "Enemy Leader",
"隐秘核心": "Hidden Core", "隐秘核心": "Hidden Core",
"本局耗时": "Match duration:",
"分钟": "min",
"返回大厅": "Back to Lobby", "返回大厅": "Back to Lobby",
"暂无结算数据": "No results data", "暂无结算数据": "No results data",
"服务器会话已重置,上一局模拟已结束": "Server session was reset. The previous simulation has ended.", "服务器会话已重置,上一局模拟已结束": "Server session was reset. The previous simulation has ended.",
@@ -349,8 +338,6 @@
"(含调和 +{harmony})": " (incl. Harmony +{harmony})", "(含调和 +{harmony})": " (incl. Harmony +{harmony})",
"层数叠加已禁用": "Stack accumulation disabled", "层数叠加已禁用": "Stack accumulation disabled",
"调和": "Harmony", "调和": "Harmony",
"{0} 在场": "{0} on field",
"{harmonyName}已激活": "{harmonyName} active",
"层数": "Stacks", "层数": "Stacks",
"盟约效果": "Alliance Effect", "盟约效果": "Alliance Effect",
"成员": "Members", "成员": "Members",
@@ -358,12 +345,9 @@
"盟约:{name}": "Alliance: {name}", "盟约:{name}": "Alliance: {name}",
"CORE BOND // 核心盟约": "CORE BOND // Core Alliance", "CORE BOND // 核心盟约": "CORE BOND // Core Alliance",
"ADD-ON BOND // 附加盟约": "ADD-ON BOND // Add-on Alliance", "ADD-ON BOND // 附加盟约": "ADD-ON BOND // Add-on Alliance",
"的盟约": "'s Alliance",
"在场": "On field", "在场": "On field",
"(含整备区)": "(incl. Bench)", "(含整备区)": "(incl. Bench)",
"(含{harmonyName} +{harmony})": "(incl. {harmonyName} +{harmony})", "(含{harmonyName} +{harmony})": "(incl. {harmonyName} +{harmony})",
"已激活{0}": "Active{0}",
" · {tier} 阶": " · Tier {tier}",
"未激活": "Inactive", "未激活": "Inactive",
"本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)": "This Alliance never activates in this mode (its Operators may still appear through their other Alliances)", "本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)": "This Alliance never activates in this mode (its Operators may still appear through their other Alliances)",
"名及以下": " or fewer", "名及以下": " or fewer",
@@ -440,7 +424,6 @@
"无": "None", "无": "None",
"攻击范围": "Attack Range", "攻击范围": "Attack Range",
"全场": "Whole field", "全场": "Whole field",
"基础 {0}": "Base {0}",
"当前作战中的实时数值(绿色为增益,红色为减益)": "Live values in the current battle (green: buffed, red: debuffed)", "当前作战中的实时数值(绿色为增益,红色为减益)": "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)", "下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)": "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", "实时": "Live",
@@ -496,7 +479,6 @@
"作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)": "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)", "作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)": "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)", "所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)": "Appears on its placed tile only when its Operator activates a skill (does not appear if not placed)",
"出售精锐干员": "Sell Elite Operator", "出售精锐干员": "Sell Elite Operator",
"确定要出售精锐干员「{0}」吗?出售后获得 {1} 资金。": "Sell the Elite Operator \"{0}\"? You will get {1} Funds.",
"详情": "Details", "详情": "Details",
"退出全屏": "Exit Fullscreen", "退出全屏": "Exit Fullscreen",
"全屏": "Fullscreen", "全屏": "Fullscreen",
@@ -509,8 +491,6 @@
"表情主题": "Emote themes", "表情主题": "Emote themes",
"悬赏": "Bounty", "悬赏": "Bounty",
"护卫": "Escort", "护卫": "Escort",
"回合 · 即将迎击": "·",
"名敌人": "incoming enemies",
"暂无敌方情报": "No enemy intel yet", "暂无敌方情报": "No enemy intel yet",
"本局禁用干员情况": "Banned Operators this match", "本局禁用干员情况": "Banned Operators this match",
"我的策略": "My Strategy", "我的策略": "My Strategy",
@@ -522,22 +502,16 @@
"该干员": "This Operator", "该干员": "This Operator",
"将被销毁": "Will be destroyed", "将被销毁": "Will be destroyed",
"替换装备": "Replace Equipment", "替换装备": "Replace Equipment",
"」的装备栏已满(2/2)。选择一件装备进行替换,": "]'s Equipment slots are full (2/2). Choose a piece of Equipment to replace.",
"被替换的装备将被销毁": "The replaced Equipment will be destroyed", "被替换的装备将被销毁": "The replaced Equipment will be destroyed",
"新装备": "New Equipment", "新装备": "New Equipment",
"选择被替换的装备": "Choose Equipment to replace", "选择被替换的装备": "Choose Equipment to replace",
"将销毁「": "Destroy [",
"」并配发「": "] and equip [",
"点击选择要替换的装备": "Tap to choose the Equipment to replace", "点击选择要替换的装备": "Tap to choose the Equipment to replace",
"上": "Up", "上": "Up",
"右": "Right", "右": "Right",
"下": "Down", "下": "Down",
"左": "Left", "左": "Left",
"选择{0}朝向": "Choose facing{0}",
"「{name}」的": " for [{name}]",
"点击取消": "Tap to cancel", "点击取消": "Tap to cancel",
"拖回中心区域取消": "Drag back to center to cancel", "拖回中心区域取消": "Drag back to center to cancel",
"朝向:{0}": "Facing: {0}",
"干员": "Operator", "干员": "Operator",
"整备区": "Bench", "整备区": "Bench",
"召唤": "Summon", "召唤": "Summon",
@@ -546,7 +520,6 @@
"已淘汰": "Eliminated", "已淘汰": "Eliminated",
"已离开": "Left", "已离开": "Left",
"{name}:本局禁用(该盟约不会激活)": "{name}: Disabled this match (this Alliance will not activate)", "{name}:本局禁用(该盟约不会激活)": "{name}: Disabled this match (this Alliance will not activate)",
"本局禁用{0}盟约,此策略效果可能无法发挥": "{0} Alliance disabled this match; this Strategy's effect may not work",
"行动中": "Acting", "行动中": "Acting",
"作战中": "In Combat", "作战中": "In Combat",
"联防中": "Uniting", "联防中": "Uniting",
@@ -561,7 +534,6 @@
"作战开始": "Battle Start", "作战开始": "Battle Start",
"各自行动阶段": "Individual action phase", "各自行动阶段": "Individual action phase",
"联防阶段": "Unite Phase", "联防阶段": "Unite Phase",
"联防:{0}": "Unite: {0}",
"完美作战的博士迎战突破防线的敌人": "Doctors with a flawless battle take on the enemies that broke through", "完美作战的博士迎战突破防线的敌人": "Doctors with a flawless battle take on the enemies that broke through",
"最终攻势": "Final Assault", "最终攻势": "Final Assault",
"击败敌方领袖": "Defeat the Enemy Leader", "击败敌方领袖": "Defeat the Enemy Leader",
@@ -626,28 +598,16 @@
"下一页": "Next page", "下一页": "Next page",
"页码": "Pages", "页码": "Pages",
"联防": "Unite", "联防": "Unite",
"{name} 漏过的敌人": "Enemies that got past {name}",
"你漏过的敌人": "Enemies that got past you",
"队友正在迎战": "teammates are engaging",
"{who}已全部被击倒": "{who}: all defeated",
"剩余敌人 {v}": "Enemies remaining: {v}", "剩余敌人 {v}": "Enemies remaining: {v}",
"剩余不足 {cap} 个后,队友每击倒一个少扣 1 点": "once fewer than {cap} remain, each one your teammates defeat costs 1 less", "剩余不足 {cap} 个后,队友每击倒一个少扣 1 点": "once fewer than {cap} remain, each one your teammates defeat costs 1 less",
"队友每击倒一个就少扣 1 点": "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}:联防中,你漏过的敌人还剩 {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)", "目标生命值 {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", "放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁": "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", "取消准备": "Cancel Ready",
"临时整备区": "Temporary Bench:",
"个单位待处理": "unit(s) pending",
"本局信息(策略 / 禁用盟约 / 干员)": "Match Info (Strategies / Banned Alliances / Operators)", "本局信息(策略 / 禁用盟约 / 干员)": "Match Info (Strategies / Banned Alliances / Operators)",
"查看即将迎击的敌方单位": "View the enemies you are about to face", "查看即将迎击的敌方单位": "View the enemies you are about to face",
"休整期可以查看即将迎击的敌方单位": "Upcoming enemies can be viewed during the Rest Phase", "休整期可以查看即将迎击的敌方单位": "Upcoming enemies can be viewed during the Rest Phase",
"秒后全队生命值开始流失": "s",
"超时 · 生命值": "Overtime · LP",
"/秒": "/s",
"已流失": "Lost", "已流失": "Lost",
"暂停": "Pause", "暂停": "Pause",
"继续作战(Space)": "Resume Battle (Space)", "继续作战(Space)": "Resume Battle (Space)",
@@ -667,15 +627,12 @@
"这不是干员调配的数据": "This is not Operator Loadout data", "这不是干员调配的数据": "This is not Operator Loadout data",
"里面没有有效的调配条目": "It contains no valid loadout entries", "里面没有有效的调配条目": "It contains no valid loadout entries",
"弹药": "Ammo", "弹药": "Ammo",
"{0}秒": "{0}s",
"本局的干员调配已锁定,修改将在下一局生效": "Operator loadout is locked for this match; changes will apply next match", "本局的干员调配已锁定,修改将在下一局生效": "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.", "独立模拟的休整期没有时间限制,你可以随时继续。": "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.", "放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。": "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", "离开模拟": "Leave Simulation",
"暂离(AI 托管)": "Step Away (AI Autopilot)", "暂离(AI 托管)": "Step Away (AI Autopilot)",
"暂离": "Step Away", "暂离": "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", "AI 托管中": "AI Autopilot active",
"AUTOPILOT // AI 托管": "AUTOPILOT // AI AUTOPILOT", "AUTOPILOT // AI 托管": "AUTOPILOT // AI AUTOPILOT",
"AI 正在代为操作你的席位": "AI is playing your seat for you", "AI 正在代为操作你的席位": "AI is playing your seat for you",
@@ -697,7 +654,6 @@
",再次点击确认": ", tap again to confirm", ",再次点击确认": ", tap again to confirm",
"已拥有 {copies}/{need}": "Owned {copies}/{need}", "已拥有 {copies}/{need}": "Owned {copies}/{need}",
"可晋升": "Promotable", "可晋升": "Promotable",
"技能{0}:{1}{2}{3}": "Skill{0}: {1}{2}{3}",
"(已调配)": " (Assigned)", "(已调配)": " (Assigned)",
" · 模组:{name}": " · Module: {name}", " · 模组:{name}": " · Module: {name}",
" · 未装备模组": " · No Module equipped", " · 未装备模组": " · No Module equipped",
@@ -719,23 +675,17 @@
"解冻": "Unfreeze", "解冻": "Unfreeze",
"刷新商店 · R": "Refresh Shop · R", "刷新商店 · R": "Refresh Shop · R",
"免费 ×{free}": "Free ×{free}", "免费 ×{free}": "Free ×{free}",
"联防中,": "In Unite; ",
"至多": "up to ",
"查看 {name} 的战场": "View {name}'s Battlefield", "查看 {name} 的战场": "View {name}'s Battlefield",
"查看自己的阵地": "View your Field", "查看自己的阵地": "View your Field",
"查看 {name} 的阵地": "View {name}'s Field", "查看 {name} 的阵地": "View {name}'s Field",
"连接已断开": "Disconnected", "连接已断开": "Disconnected",
"前往查看": "Go View", "前往查看": "Go View",
"{0} 操作": "{0} actions",
"单位": "Unit", "单位": "Unit",
"撤退": "Retreat", "撤退": "Retreat",
"撤退至整备区": "Retreat to Bench", "撤退至整备区": "Retreat to Bench",
"出售,获得 {sell} 资金": "Sell for {sell} Funds", "出售,获得 {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)", "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)": "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", "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁": "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", "结算": "Settlement",
"模拟结束": "Simulation Over", "模拟结束": "Simulation Over",
"表情套组:卫戍协议": "Emote Set: Stronghold Protocol", "表情套组:卫戍协议": "Emote Set: Stronghold Protocol",
@@ -778,20 +728,6 @@
"维维美 4": "Wiwime 4", "维维美 4": "Wiwime 4",
"维维美 5": "Wiwime 5", "维维美 5": "Wiwime 5",
"维维美 6": "Wiwime 6", "维维美 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", "精打细算玩偶": "Budget-Savvy Doll",
"悬赏决策": "Bounty Decision", "悬赏决策": "Bounty Decision",
"道具补给": "Item Supply", "道具补给": "Item Supply",
+26 -11
View File
@@ -4,7 +4,7 @@
import { test, afterEach } from 'node:test'; import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
@@ -116,7 +116,7 @@ test('server wire format: text keeps the Chinese rendering for older clients; pa
test('public/i18n/en.json: valid, English values, placeholders kept, the pilot screens and server messages translated', () => { 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('_')); const entries = Object.entries(EN).filter(([k]) => !k.startsWith('_'));
assert.ok(entries.length > 800, `seeded from PR #70: ${entries.length}`); assert.ok(entries.length >= 1000, `PR #70's seed + phase 2: ${entries.length}`);
for (const [k, v] of entries) { for (const [k, v] of entries) {
assert.equal(typeof v, 'string', k); assert.equal(typeof v, 'string', k);
assert.ok(v.trim(), `empty translation for ${k}`); assert.ok(v.trim(), `empty translation for ${k}`);
@@ -126,25 +126,40 @@ test('public/i18n/en.json: valid, English values, placeholders kept, the pilot s
for (const p of want) assert.ok(have.has(p), `${k}: {${p}} missing in "${v}"`); for (const p of want) assert.ok(have.has(p), `${k}: {${p}} missing in "${v}"`);
} }
for (const k of ['开始', '博士代号', '设置', '语言', '同盟模拟', '创建同盟', '加入同盟', '开始模拟', 'toggle::关闭', '整备区已满', for (const k of ['开始', '博士代号', '设置', '语言', '同盟模拟', '创建同盟', '加入同盟', '开始模拟', 'toggle::关闭', '整备区已满',
'第 {r} 回合 · 最终攻势', '本局禁用{names}盟约,此策略效果可能无法发挥', 'bond-threshold::名', 'list::、', '干员持有', '先锋', '开心',
'{names}只能部署在召唤者攻击范围内,已退回整备区', '{name}博士的目标生命值已耗尽', '联防阶段:{names} 迎战突破防线的敌人', '【维多利亚】获得{n}件维式重锤']) { '{names}只能部署在召唤者攻击范围内,已退回整备区', '{name}博士的目标生命值已耗尽', '联防阶段:{names} 迎战突破防线的敌人', '【维多利亚】获得{n}件维式重锤']) {
assert.ok(EN[k], `en.json lacks ${k}`); 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 () => { test('the whole client, shared and the server messages: no Chinese literal left unwrapped, every msgid in public/i18n/en.json (extract 0, check --strict)', async () => {
const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js', // what tools/i18n.mjs scans: public/js (not dev / vendor / assets / fonts), shared, server — server/sim only for the
'public/js/screens/diy.js', // texts it sends (msg() / ctx.toast), its own t() / N_() are other helpers
'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js', const SKIP = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']);
'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js', const files = [];
'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js', const visit = (rel) => {
'server/sim/content/bonds/addon/meta.js', 'server/sim/content/bonds/core.js']; for (const name of readdirSync(path.join(ROOT, rel)).sort()) {
if (SKIP.has(name) || name.startsWith('.')) continue;
const r = `${rel}/${name}`;
if (statSync(path.join(ROOT, r)).isDirectory()) visit(r);
else if (/\.m?js$/.test(name)) files.push(r);
}
};
for (const root of ['public/js', 'shared', 'server']) visit(root);
assert.ok(files.length > 300, `${files.length} files`);
const missing = []; const missing = [];
let used = 0;
for (const f of files) { for (const f of files) {
const { msgids, literals } = await scanSource(readFileSync(path.join(ROOT, f), 'utf8'), f); const { msgids, literals } = await scanSource(readFileSync(path.join(ROOT, f), 'utf8'), f);
const server = f.startsWith('server/'); 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}`); for (const m of msgids) {
if (!server) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`); if (server && m.via !== 'msg' && m.via !== 'server') continue;
used++;
if (!EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`);
}
if (!f.startsWith('server/sim/')) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`);
} }
assert.ok(used >= 1350, `msgid uses: ${used} (1 404 at phase 2)`);
assert.deepEqual(missing, []); assert.deepEqual(missing, []);
}); });