fb5 UI-TW: the machine-translation mark — _meta.machineTranslated: true, a new optional manifest field of a language pack (docs/PACKS.md): shared/i18nPacks.js langFields reads it (true or false), shared/packs.js normalizeManifest warns about a value that is not a boolean (read as false; the pack still loads) and packIndexEntry passes it into the index entry like complete (only when true), so the client's registry has it from the index (langMetaOf → registerLangs → langInfo) or from the pack's own _meta without one; 设置 shows a note under the language switch while the CURRENT language's pack has it (ui/lang.js machineTranslationNote, msgid 「当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。」 — English in en.json, 目前語言的介面文字為機器翻譯… in zh-TW.json; ja / ko leave it to the integrator); tests: test/i18n-packs.test.js (the field from the manifest through the index to langInfo, the warning, the shipped zh-TW manifest, the note shown / hidden by language), test/ui/i18n.e2e.test.js (the shipped packs' own UI strings read from their files — zh-TW now 開始 — and the 设置 note, none in Chinese); docs/I18N.md says how the zh-TW pack is made (OpenCC twp, the official TW terms, a manual pass; a later msgid the same way) and that the ja / ko strings are machine translations open to corrections (edit public/i18n/<code>.json, node tools/i18n.mjs check <code> --strict)

This commit is contained in:
sganggs committed 2026-10-07 11:47:09 +08:00
1 parent 6613c3d0fb
commit c11ba03da4
11 files changed
+173 -38

No files matched your search

+27 -13
View File
@@ -4,9 +4,9 @@ Chinese is the default; every other language is a switch (the owner's decision o
opens in Chinese until the player picks a language on the title screen (top right) or in 设置 / Settings. Every
language but Chinese is a **language pack** — a file in the language folder, nothing else (the owner's decision of
2026-10-07; §5 "Adding a language"): the menu lists every pack the server finds, each under its own name (buttons up to
four languages, a list beyond). English is a pack like any other; it ships with the game and is the complete one.
Japanese, Korean and Traditional Chinese ship too, with the official game texts and no UI strings yet (§5 "Shipped
packs"). The choice is kept in `localStorage` (`sp.pref.lang`); `?lang=<code>` in the address bar (`?lang=en`, `?lang=zh`) sets it
four languages, a list beyond). English is a pack like any other; it ships with the game, complete. Japanese, Korean
and Traditional Chinese ship too, with the official game texts and complete UI strings made by machine, open to
corrections (§5 "Shipped packs"). The choice is kept in `localStorage` (`sp.pref.lang`); `?lang=<code>` in the address bar (`?lang=en`, `?lang=zh`) sets it
once and is then removed; a stored language whose pack is gone opens in Chinese. A switch re-renders the page in place
(no reload). Language packs are the first type of the content packs (docs/PACKS.md: the manifest, the registry, the
index, the pack folders).
@@ -240,13 +240,24 @@ which are bilingual on purpose.
## 5. Adding a language
**Shipped packs** (0.2.0; the owner's decision of 2026-10-07): English (complete) and three packs that carry the
official game texts but no UI strings yet — 日本語 (`ja`: the JP client, 96.5 % of the game texts), 한국어 (`ko`: KR,
97.4 %) and 繁體中文 (`zh-TW`: TW, 91.1 %; its data is older, and a Traditional text equal to the Simplified one counts as
untranslated). Their UI falls back to English (`ja`, `ko`: `"fallback": ["en"]`) or, for `zh-TW`, to the Simplified
Chinese msgids (no fallback — the owner's choice); `zh-TW` counts large numbers in 萬 / 億. **UI translations are
welcome from the community**: start from the shipped file — `node tools/i18n.mjs template <code>` keeps its `_meta` and
adds every msgid with an empty value — then follow the steps below and send a pull request.
**Shipped packs** (0.2.0; the owner's decisions of 2026-10-07): English and three packs with the official game texts
and complete UI strings — 日本語 (`ja`: the JP client, 96.5 % of the game texts), 한국어 (`ko`: KR, 97.4 %) and 繁體中文
(`zh-TW`: TW, 91.1 %; its data is older, and a Traditional text equal to the Simplified one counts as untranslated).
The three are made by machine and marked so (`"machineTranslated": true`: 设置 shows a note under the language switch
while one is in use); a string a pack lacks falls back to English (`ja`, `ko`: `"fallback": ["en"]`) or, for `zh-TW`, to
the Simplified Chinese msgid (no fallback — the owner's choice); `zh-TW` counts large numbers in 萬 / 億.
- **繁體中文** is a conversion, not a free translation: each msgid converted from the Simplified Chinese with OpenCC
(`cn` → `twp`, Traditional characters with Taiwan phrases), the official TW client's word kept where the phrase step
words a game concept differently (調度中心, not 排程中心; 更新 for the shop refresh; 啟動 for a bond; 遠程位; 查看; 目前 …;
a whole string the TW tables have, such as 時間較短的模擬訓練, as it is there), then a manual pass over the result
(one-to-many characters such as 只 / 隻, 重新整理 for a page refresh …). A msgid added later is converted the same way.
- **日本語 / 한국어** are machine translations that use the official JP / KR game terms. **Corrections are welcome**, to
any pack: edit the string in `public/i18n/<code>.json`, run `node tools/i18n.mjs check <code> --strict` (0 errors,
0 missing) and send a pull request on GitHub.
A new language is welcome too: `node tools/i18n.mjs template <code>` starts its pack (run on an existing pack, it keeps
the translations and `_meta` and adds every missing msgid with an empty value); then follow the steps below.
A language is a file in the language folder; the server lists it on the next page load (no restart, no build step, no
code change). For a community translator:
@@ -257,7 +268,8 @@ code change). For a community translator:
and every msgid (about 1 000) with an empty value, in the order of the English pack (`--fill en` fills in the English
to overwrite). Fill in `_meta`: `name` (the language in itself, shown in the menu), `englishName`, `authors`,
`version`; keep `"fallback": ["en"]` to show English where a string is still empty (drop it to show Chinese); set
`numberUnits` if the language counts large numbers in 10⁴ steps like Chinese (`["万", "億"]`).
`numberUnits` if the language counts large numbers in 10⁴ steps like Chinese (`["万", "億"]`), and
`"machineTranslated": true` when the strings come from a machine (players then see a note in 设置; docs/PACKS.md).
3. **Translate** the values (empty = not yet: it falls back). Keep every `{placeholder}` of the msgid; plural forms as
in §1; `public/i18n/en.json` is the reference for meaning and context (`ctx::msgid` keys are the same Chinese text in
another context). Use the official terms of your language's client where it has one.
@@ -273,8 +285,10 @@ code change). For a community translator:
again: it keeps the translations and adds the new msgids.
The tests: `test/i18n-packs.test.js` (discovery, the fallback chain, placeholders, plurals in English and Russian, the
template and check commands, and that the Chinese and English output of `t()` is what it was before packs: every msgid
of `public/i18n/en.json` rendered with sample params, compared with the renderer of 0.2.0 before packs).
template and check commands, the `machineTranslated` mark — manifest, index, the client's registry, the 设置 note — and
that the Chinese and English output of `t()` is what it was before packs: every msgid of `public/i18n/en.json` rendered
with sample params, compared with the renderer of 0.2.0 before packs); `test/ui/i18n.e2e.test.js` (`SP_E2E=1`: the
shipped packs in the browser, their UI strings and the 设置 note).
## Credits
+6 -3
View File
@@ -63,7 +63,8 @@ would see one folder).
| `name`, `englishName` | the name in its own language (a language pack: the language's own name, shown in the menu) and in English (the menu's tooltip) |
| `authors`, `credits`, `license` | who made it; free text (`license`: an SPDX id or text) |
| `files` | a folder pack: role → path inside the folder. Each type lists its roles and their extensions (`lang`: `ui` required, `data` optional, both `.json`) |
| the type's fields | `lang`: `lang`, `base`, `fallback`, `complete`, `numberUnits` (docs/I18N.md) |
| the type's fields | `lang`: `lang`, `base`, `fallback`, `complete`, `machineTranslated`, `numberUnits` (docs/I18N.md) |
| `machineTranslated` | `lang`, optional: `true` when the pack's UI strings come from a machine (a translation, or a conversion such as the Traditional Chinese pack's). The index entry carries it (only when true, like `complete`) and the client shows a note under the language switch in 设置 while that language is in use (`public/js/ui/lang.js` `machineTranslationNote`). A value other than `true` / `false` is a warning and reads as false |
A **problem** keeps a pack from loading (unknown type, no `lang`, a file missing or outside the folder, broken JSON, an
id or a language already taken — the language folders' files come first, then the folders by id); a **warning** does
@@ -158,7 +159,9 @@ that ship. `test/package.test.js` checks both layouts and that a local pack stay
## Tests
`test/i18n-packs.test.js` (discovery of both layouts, broken / planned / duplicate packs, a pack dropped in or removed
while the server runs, the file allowlist, the manifest and app ranges, the fallback chain, placeholders, plurals,
while the server runs, the file allowlist, the manifest and app ranges, the `machineTranslated` mark from the manifest
to the 设置 note, the fallback chain, placeholders, plurals,
the per-text game-text fallback, the client loader and menu, the tools, build-i18n `--lang` / `--dict`),
`test/package.test.js`, `test/ui/i18n.e2e.test.js`
(`SP_E2E=1`: a pack file and a pack folder dropped in while the server runs show in the menu and switch).
(`SP_E2E=1`: a pack file and a pack folder dropped in while the server runs show in the menu and switch; the shipped
packs with their UI strings and the 设置 note of a machine-translated one).
+1
View File
@@ -784,6 +784,7 @@
"出售选中干员": "Sell selected Operator",
"取消 / 关闭": "Cancel / Close",
"语言": "Language",
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "The interface text in this language is machine-translated and may be inaccurate. Corrections are welcome on GitHub.",
"右键查看详情": "Right-click for details",
"刷新商店": "Refresh Shop",
"冻结 / 解冻商店": "Freeze / Unfreeze Shop",
+1
View File
@@ -788,6 +788,7 @@
"出售选中干员": "出售選中幹員",
"取消 / 关闭": "取消 / 關閉",
"语言": "語言",
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "目前語言的介面文字為機器翻譯,可能不夠準確,歡迎在 GitHub 上指正。",
"右键查看详情": "右鍵查看詳情",
"刷新商店": "更新商店",
"冻结 / 解冻商店": "凍結 / 解凍商店",
+14 -1
View File
@@ -15,7 +15,7 @@
import { useEffect, useState } from '../../vendor/hooks.module.js';
import {
DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire, t,
DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire, t, N_,
registerLangs, getLangs, langInfo, langChain, onLangsChange, setI18nWarn,
} from '../../../shared/i18n.js';
import { canonicalLang, computeChain, scriptOf } from '../../../shared/i18nPacks.js';
@@ -270,6 +270,19 @@ export function langMenuModel(langs, current) {
return { kind: items.length > SEGMENTED_MAX ? 'select' : 'buttons', items };
}
/** The note itself (a msgid: each pack words it in its own language). */
const MACHINE_TRANSLATION_NOTE = N_('当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。');
/**
* The note under the language switch in 设置 (ui/settings.js): while the current language's pack is marked as a machine
* translation (`_meta.machineTranslated`, docs/PACKS.md) it says so, in that language; null otherwise (Chinese, English,
* a pack translated by hand). Only the current pack counts, not its base or fallbacks.
* @returns {string | null}
*/
export function machineTranslationNote() {
return langInfo(getLang())?.machineTranslated === true ? t(MACHINE_TRANSLATION_NOTE) : null;
}
/**
* The language menu: 中文 | English | … as buttons, or a list when there are many packs.
* @param {{ class?: string }} props
+5 -2
View File
@@ -1,6 +1,7 @@
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality, the shortcut keys): a tiny observable
// store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`) and the 快捷键
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07).
@@ -11,7 +12,7 @@ import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindH
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
import { LangToggle } from './lang.js';
import { LangToggle, machineTranslationNote } from './lang.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -151,6 +152,7 @@ export function SettingsModal({ open, onClose }) {
const s = useSettings();
const [tested, setTested] = useState(false);
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
const mtNote = machineTranslationNote(); // a pack marked as machine translation says so under the switch
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('完成')}<//>`}>
@@ -159,6 +161,7 @@ export function SettingsModal({ open, onClose }) {
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
<${LangToggle} class="set-lang" />
</div>
${mtNote ? html`<p class="set-hint set-lang-note" data-testid="lang-mt-note">${mtNote}</p>` : null}
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
<${Slider} label=${t('干员语音')} micro="VOICE" icon="mic" value=${s.voice} onInput=${(v) => updateSettings({ voice: v })} />
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
+6 -2
View File
@@ -43,7 +43,7 @@ export const DEFAULT_LANG = SOURCE_LANG;
/** The source language: built in, no pack, no catalog. */
const SOURCE_META = Object.freeze({
code: SOURCE_LANG, name: '中文', englishName: 'Chinese (Simplified)', authors: [], credits: '', version: '', app: '', // i18n-ignore: its own name
base: null, fallback: [], complete: true, numberUnits: ['万', '亿'], builtin: true, // i18n-ignore: the Chinese number units
base: null, fallback: [], complete: true, machineTranslated: false, numberUnits: ['万', '亿'], builtin: true, // i18n-ignore: the Chinese number units
});
/** @type {Map<string, LangMeta>} code → metadata: the source, the packs of the index, languages given messages */
@@ -109,7 +109,11 @@ export function getLangs() {
return [registry.get(SOURCE_LANG), ...rest];
}
/** The metadata of a known language, or null. @param {string} [lang] */
/**
* The metadata of a known language (its names, chain fields, `complete`, `machineTranslated` …: what registerLangs or its
* pack's `_meta` gave), or null.
* @param {string} [lang]
*/
export const langInfo = (lang = current) => registry.get(String(lang)) || null;
/**
+8 -2
View File
@@ -20,6 +20,8 @@
// pack for it — pt-BR → pt; never the Chinese source, so zh-TW has none); null switches it off
// fallback packs tried after the base, before the Chinese msgid (["en"]: untranslated strings show English)
// complete true: `node tools/i18n.mjs check` requires every msgid (English declares it)
// machineTranslated true: the UI strings are machine translations (or a machine conversion, zh-TW); the 设置 dialog
// says so under the language switch while the language is in use (ui/lang.js machineTranslationNote)
// numberUnits two units for numbers counted in 10⁴ / 10⁸ steps (Chinese 万 / 亿; e.g. a Japanese pack ["万", "億"]);
// without them large numbers use K / M / B (ui/gameLogic/format.js fmtNum)
// The common fields (id, version, app, name, englishName, authors, credits, license) are shared/packs.js's.
@@ -84,7 +86,8 @@ export function langList(v) {
}
/**
* @typedef {{ lang: string, base: string|null|undefined, fallback: string[], complete: boolean, numberUnits: string[]|null }} LangFields
* @typedef {{ lang: string, base: string|null|undefined, fallback: string[], complete: boolean, machineTranslated: boolean,
* numberUnits: string[]|null }} LangFields
*/
/**
@@ -101,7 +104,10 @@ export function langFields(code, m) {
else if (typeof o.base === 'string') base = canonicalLang(o.base);
if (base === SOURCE_LANG || base === code) base = null;
const units = Array.isArray(o.numberUnits) && o.numberUnits.length === 2 && o.numberUnits.every((u) => str(u, 8)) ? o.numberUnits.map((u) => str(u, 8)) : null;
return { lang: code, base, fallback: langList(o.fallback).filter((c) => c !== code), complete: o.complete === true, numberUnits: units };
return {
lang: code, base, fallback: langList(o.fallback).filter((c) => c !== code), complete: o.complete === true,
machineTranslated: o.machineTranslated === true, numberUnits: units,
};
}
/**
+9 -3
View File
@@ -23,13 +23,14 @@
// englishName its English name
// authors a string or a list; credits free text; license an SPDX id or free text
// files a folder pack: role → path inside the folder (type "lang": { "ui": "ui.json", "data": "data.json" })
// … the type's own fields (type "lang": lang, base, fallback, complete, numberUnits — shared/i18nPacks.js)
// … the type's own fields (type "lang": lang, base, fallback, complete, machineTranslated, numberUnits —
// shared/i18nPacks.js; a machineTranslated that is not a boolean is warned about and reads as false)
//
// Index (GET /packs/index.json — the server's live list; `node tools/packs.mjs index` writes packs/index.json for a
// static host): { version: 1, app: "<the server's app version>", packs: [entry …] } — the packs of supported types,
// sorted by type then id. An entry: id, type, name, englishName, version?, app?, compatible, authors?, credits?,
// license?, files (role → URL on this server) and the type's fields; a language entry: lang, base?, fallback,
// complete?, numberUnits?, strings (how many translations its UI file holds).
// complete?, machineTranslated?, numberUnits?, strings (how many translations its UI file holds).
import { APP_VERSION } from './constants.js';
import { SOURCE_LANG, canonicalLang, isLangCode, languageName, langFields } from './i18nPacks.js';
@@ -134,7 +135,8 @@ export function appVersionMatches(range, version = APP_VERSION) {
/**
* @typedef {{ id: string, type: string, name: string, englishName: string, version: string, app: string,
* compatible: boolean, authors: string[], credits: string, license: string, files: Record<string, string>,
* lang?: string, base?: string|null, fallback?: string[], complete?: boolean, numberUnits?: string[]|null }} PackManifest
* lang?: string, base?: string|null, fallback?: string[], complete?: boolean, machineTranslated?: boolean,
* numberUnits?: string[]|null }} PackManifest
*/
/**
@@ -178,6 +180,9 @@ export function normalizeManifest(raw, ctx) {
else {
if (m.lang !== undefined && !isLangCode(m.lang) && !ctx.lang) warnings.push(`"lang": "${m.lang}" read as ${code}`);
Object.assign(out, langFields(code, m));
// anything but true reads as false: say so when another value was meant
const mt = m.machineTranslated;
if (mt !== undefined && typeof mt !== 'boolean') warnings.push(`"machineTranslated": ${String(JSON.stringify(mt)).slice(0, 40)} is not true or false (read as false)`);
out.name ||= languageName(code);
out.englishName ||= languageName(code, 'en');
}
@@ -220,6 +225,7 @@ export function packIndexEntry(manifest, extra) {
if (manifest.base !== undefined) e.base = manifest.base;
e.fallback = manifest.fallback || [];
if (manifest.complete) e.complete = true;
if (manifest.machineTranslated) e.machineTranslated = true;
if (manifest.numberUnits) e.numberUnits = manifest.numberUnits;
e.strings = extra.strings ?? 0;
}
+69 -6
View File
@@ -3,9 +3,10 @@
// allowlist), the manifest (shared/packs.js: types, app ranges), the fallback chain (shared/i18n.js: base, fallback, the
// Chinese msgid), placeholder validation, plural categories (Intl.PluralRules: English unchanged, Russian named forms),
// the per-text fallback of game texts (shared/i18nData.js onto, data.js chains), the client loader and menu
// (ui/lang.js), the translator tools (tools/i18n.mjs template / check, tools/packs.mjs index), and that the Chinese and
// English output of t() is what it was before packs. The example packs live only here, in temporary folders ('qaa' …
// 'qtz' are the ISO 639 codes reserved for local use).
// (ui/lang.js), the translator tools (tools/i18n.mjs template / check, tools/packs.mjs index), the machine-translation
// mark (`_meta.machineTranslated`: the manifest, the index, the client's registry, the 设置 note), and that the Chinese
// and English output of t() is what it was before packs. The example packs live only here, in temporary folders ('qaa'
// … 'qtz' are the ISO 639 codes reserved for local use).
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
@@ -19,8 +20,8 @@ import {
t, tc, tParts, setLang, getLang, addMessages, setMessages, setI18nWarn, registerLangs, getLangs, langChain, langInfo, normalizeLang,
checkTranslation, parsePluralForms, pluralCategory, format,
} from '../shared/i18n.js';
import { canonicalLang, computeChain, scriptOf, langFields } from '../shared/i18nPacks.js';
import { normalizeManifest, appVersionMatches, isVersionRange, readPackIndex, langMetaOf, PACK_TYPES, isPackPath } from '../shared/packs.js';
import { canonicalLang, computeChain, scriptOf, langFields, packMeta } from '../shared/i18nPacks.js';
import { normalizeManifest, appVersionMatches, isVersionRange, readPackIndex, langMetaOf, PACK_TYPES, isPackPath, packIndexEntry } from '../shared/packs.js';
import { buildRecordOverlay, applyFileOverlay, applyRecordOverlay } from '../shared/i18nData.js';
import { buildOverlay, LANG_SOURCES } from '../tools/build-i18n.mjs';
import { scanPacks, createPackRegistry, packIndexOf } from '../server/packs.js';
@@ -30,6 +31,9 @@ import { writePackIndex } from '../tools/packs.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const EN = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
const ZH_TW = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/i18n/zh-TW.json'), 'utf8'));
/** The 设置 note of a pack marked as machine translation (ui/lang.js machineTranslationNote). */
const MT_NOTE = '当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。';
afterEach(() => {
setLang('zh');
@@ -172,6 +176,44 @@ test('manifest: common fields, type fields, app ranges; English is a pack like a
assert.ok(en.manifest.authors.some((a) => a.includes('@YuriRestia')), 'PR #70 credited');
});
test('manifest: machineTranslated — a boolean, carried into the index entry like complete and into the client\'s registry (from the index or the pack\'s own _meta); the shipped zh-TW pack has it', () => {
const ctx = { id: 'qam', type: 'lang', lang: 'qam', app: '0.2.0' };
const on = normalizeManifest({ name: 'Qam', machineTranslated: true }, ctx);
assert.deepEqual([on.problems, on.warnings, on.manifest.machineTranslated], [[], [], true]);
assert.equal(normalizeManifest({ name: 'Qam' }, ctx).manifest.machineTranslated, false, 'optional: absent reads as false');
const odd = normalizeManifest({ name: 'Qam', machineTranslated: 'yes' }, ctx);
assert.deepEqual(odd.problems, [], 'a warning, not a problem: the pack still loads');
assert.deepEqual(odd.warnings, ['"machineTranslated": "yes" is not true or false (read as false)']);
assert.equal(odd.manifest.machineTranslated, false);
assert.match(normalizeManifest({ machineTranslated: 1 }, ctx).warnings.join(), /"machineTranslated": 1 is not true or false/);
// the index entry: only when true (like complete)
const files = { ui: '/i18n/qam.json' };
assert.equal(packIndexEntry(on.manifest, { files, strings: 1 }).machineTranslated, true);
assert.equal(Object.hasOwn(packIndexEntry(odd.manifest, { files }), 'machineTranslated'), false);
// a checkout's packs → the index → readPackIndex / langMetaOf → the client's registry (langInfo)
const root = tmpdir('mt');
try {
put(root, 'public/i18n/qam.json', { _meta: { name: 'Qam', fallback: ['en'], machineTranslated: true }, 开始: 'Qam-Start' });
put(root, 'public/i18n/qan.json', { _meta: { name: 'Qan' }, 开始: 'Qan-Start' });
const index = packIndexOf(scanPacks(dirsOf(root), { app: '0.2.0' }).packs, { app: '0.2.0' });
const entries = readPackIndex(index, 'lang');
assert.deepEqual(entries.map((e) => [e.id, e.machineTranslated]), [['qam', true], ['qan', undefined]]);
registerLangs(entries.map(langMetaOf));
assert.deepEqual([langInfo('qam').machineTranslated, langInfo('qan').machineTranslated, langInfo('zh').machineTranslated], [true, false, false]);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
// without an index the pack's own `_meta` registers it (addMessages → packMeta)
assert.equal(packMeta('qao', { _meta: { machineTranslated: true } }).machineTranslated, true);
addMessages('qao', { _meta: { name: 'Qao', machineTranslated: true }, 开始: 'Qao-Start' });
assert.equal(langInfo('qao').machineTranslated, true);
// the shipped Traditional Chinese pack: a machine conversion (OpenCC, the official TW terms, a manual pass), complete,
// no fallback (the Simplified Chinese msgids), the note in Traditional Chinese
const tw = normalizeManifest(ZH_TW._meta, { id: 'zh-TW', type: 'lang', lang: 'zh-TW', app: '0.2.0' });
assert.deepEqual([tw.problems, tw.warnings], [[], []]);
assert.deepEqual([tw.manifest.machineTranslated, tw.manifest.complete, tw.manifest.fallback, tw.manifest.numberUnits], [true, true, [], ['萬', '億']]);
assert.equal(ZH_TW[MT_NOTE], '目前語言的介面文字為機器翻譯,可能不夠準確,歡迎在 GitHub 上指正。');
assert.ok(EN[MT_NOTE], 'English has the note');
});
test('fallback chain: the pack, its base (pt-BR → pt; zh-TW none), its fallbacks, then the Chinese msgid; cycles end', () => {
const metas = { 'pt-BR': { fallback: ['en'] }, pt: { fallback: ['es'] }, es: {}, en: {}, 'zh-TW': { fallback: [] }, a: { fallback: ['b'] }, b: { fallback: ['a'] } };
const known = (c) => Object.hasOwn(metas, c);
@@ -346,10 +388,31 @@ test('client: the index registers the language packs; the menu lists them (butto
assert.deepEqual(lang.initialLang('', () => 'qzz', { tentative: true }), { lang: 'qzz', fromUrl: false }, 'without an index: tried by its code');
assert.deepEqual([scriptOf('Stronghold Protocol'), scriptOf('卫戍协议'), scriptOf('堅守協定'), scriptOf('위수 협의'), scriptOf('Протокол')], ['alphabetic', 'cjk', 'cjk', 'cjk', 'alphabetic']);
assert.deepEqual(langMetaOf(readPackIndex(index, 'lang')[1]).numberUnits, ['W', 'Y']);
assert.deepEqual(langFields('qab', { base: 'zh', fallback: 'en' }), { lang: 'qab', base: null, fallback: ['en'], complete: false, numberUnits: null });
assert.deepEqual(langFields('qab', { base: 'zh', fallback: 'en' }), { lang: 'qab', base: null, fallback: ['en'], complete: false, machineTranslated: false, numberUnits: null });
assert.equal(canonicalLang('zh-hant-tw'), 'zh-Hant-TW');
});
test('设置: the machine-translation note shows while the current language\'s pack is marked (ui/lang.js machineTranslationNote), in that language; none for Chinese, English or an unmarked pack', async () => {
const { machineTranslationNote } = await import('../public/js/ui/lang.js');
registerLangs([{ code: 'qap', name: 'Qap', fallback: ['en'], machineTranslated: true }, { code: 'qaq', name: 'Qaq', fallback: ['en'] }, { code: 'en', name: 'English' }]);
addMessages('en', { [MT_NOTE]: EN[MT_NOTE] });
addMessages('qap', { [MT_NOTE]: 'Qap-Hinweis' });
assert.equal(machineTranslationNote(), null, 'Chinese: no note');
setLang('qap');
assert.equal(machineTranslationNote(), 'Qap-Hinweis', 'the marked pack, in its own words');
setMessages('qap', {});
assert.equal(machineTranslationNote(), EN[MT_NOTE], 'a marked pack without the string: the next language of its chain');
setLang('qaq');
assert.equal(machineTranslationNote(), null, 'a pack that is not marked');
setLang('en');
assert.equal(machineTranslationNote(), null, 'English is not marked');
// the shipped zh-TW pack (no index here: its `_meta` registers it)
addMessages('zh-TW', ZH_TW);
setLang('zh-TW');
assert.equal(machineTranslationNote(), ZH_TW[MT_NOTE]);
assert.equal(t('设置'), '設定', 'and the rest of the interface in Traditional Chinese');
});
test('tools: template writes a skeleton (or adds the missing msgids to a pack), check reports coverage and errors per pack; packs index writes the static index', () => {
const used = new Map([['开始', { where: 'a.js:1', params: [] }], ['还剩 {n} 秒', { where: 'b.js:2', params: new Set(['n']) }], ['{n} 名', { where: 'c.js:3', params: new Set(['n', 'count']) }], ['表里的', { where: 'd.js:4', params: null }]]);
const skel = packTemplate('qaa', { msgids: [...used.keys()] });
+27 -6
View File
@@ -7,8 +7,9 @@
// game-data overlay (data/i18n/en.json) is applied; `?lang=zh` switches back and leaves the address bar. Language packs
// (docs/I18N.md "Adding a language", docs/PACKS.md): a pack file dropped into public/i18n/ and a pack folder dropped
// into packs/ while the server runs show in the menu and switch, with English filling what they lack; a removed pack
// sends a stored choice back to Chinese. The shipped Japanese, Korean and Traditional Chinese packs (the owner's decision of
// 2026-10-07) switch to their official game texts with the UI of their fallback (English; Simplified Chinese for zh-TW).
// sends a stored choice back to Chinese. The shipped Japanese, Korean and Traditional Chinese packs (the owner's decisions of
// 2026-10-07) switch to their official game texts and their own UI strings (what a pack lacks shows its fallback: English
// for ja / ko, the Simplified Chinese for zh-TW); a pack marked `machineTranslated` says so in 设置 (none in Chinese).
// With more than four languages the menu is a list (ui/lang.js SEGMENTED_MAX): the helpers read and pick either form.
// No console / page / request errors. docs/I18N.md.
@@ -50,6 +51,23 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
else await page.click(`[data-testid="lang-toggle"] button[data-lang="${code}"]`);
};
const SHIPPED = [['zh', '中文'], ['en', 'English'], ['ja', '日本語'], ['ko', '한국어'], ['zh-TW', '繁體中文']];
const pack = (code) => JSON.parse(fs.readFileSync(path.join(ROOT, `public/i18n/${code}.json`), 'utf8'));
/** What a shipped pack shows for a msgid: its own string, else its fallbacks' (English for ja / ko), else the Chinese msgid. */
const uiText = (code, msgid) => {
const own = pack(code);
for (const p of [own, ...(own._meta?.fallback || []).map(pack)]) if (typeof p[msgid] === 'string' && p[msgid]) return p[msgid];
return msgid;
};
const MT_NOTE = '当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。';
/** The note under the language switch in 设置 (null when there is none); the dialog opens from the title screen and closes. */
const settingsNote = async (page) => {
await page.click('.title-settings');
await page.waitForSelector('.modal .set-list');
const note = await page.$eval('.modal [data-testid="lang-mt-note"]', (el) => el.textContent.trim()).catch(() => null);
await page.click('.modal__actions .btn--primary'); // 完成 (an Esc right after the dialog shows may beat its key listener)
await page.waitForSelector('.modal', { hidden: true });
return note;
};
/** A game text of a shipped pack's overlay (data/i18n/<code>.json; a leaf is a string or { _s }). */
const gameText = (code) => { const v = JSON.parse(fs.readFileSync(path.join(ROOT, `data/i18n/${code}.json`), 'utf8')).files.config.modes.mode_multi_abyss.name; return typeof v === 'string' ? v : v._s; };
@@ -139,7 +157,7 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
await page.close();
}
});
test('the shipped 日本語 / 한국어 / 繁體中文 packs: the official game texts, the UI of their fallback (English; Simplified Chinese for zh-TW)', async () => {
test('the shipped 日本語 / 한국어 / 繁體中文 packs: the official game texts, their UI strings, the 设置 note of a machine-translated pack', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
const problems = [];
@@ -148,13 +166,16 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
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}/?lang=zh`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.title-screen [data-testid="lang-toggle"]');
for (const [code, ui, htmlLang] of [['ja', 'Start', 'ja'], ['ko', 'Start', 'ko'], ['zh-TW', '开始', 'zh-TW']]) {
assert.equal(await settingsNote(page), null, 'Chinese: no machine-translation note');
for (const code of ['ja', 'ko', 'zh-TW']) {
await pick(page, code);
await page.waitForFunction((c) => globalThis.__SP__?.data?.locale() === c, { timeout: 8000 }, code);
assert.equal(await page.evaluate(() => globalThis.__SP__.data.get('config').modes.mode_multi_abyss.name), gameText(code), `${code}: the official game text`);
assert.equal(await text(page, '.title-login .btn--primary'), ui, `${code}: the fallback UI`);
assert.equal(await page.evaluate(() => document.documentElement.lang), htmlLang);
assert.equal(await text(page, '.title-login .btn--primary'), uiText(code, '开始'), `${code}: the UI`);
assert.equal(await page.evaluate(() => document.documentElement.lang), code);
assert.equal(await settingsNote(page), pack(code)._meta.machineTranslated === true ? uiText(code, MT_NOTE) : null, `${code}: the 设置 note`);
}
assert.equal(uiText('zh-TW', '开始'), '開始', 'zh-TW: its own Traditional Chinese UI');
assert.deepEqual(problems, []);
await page.close();
});