diff --git a/docs/I18N.md b/docs/I18N.md index 0d772eb0..187d4815 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -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=` 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=` 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 ` 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/.json`, run `node tools/i18n.mjs check --strict` (0 errors, + 0 missing) and send a pull request on GitHub. + +A new language is welcome too: `node tools/i18n.mjs template ` 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 diff --git a/docs/PACKS.md b/docs/PACKS.md index f6d29462..7f6b48ec 100644 --- a/docs/PACKS.md +++ b/docs/PACKS.md @@ -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). diff --git a/public/i18n/en.json b/public/i18n/en.json index 487f74e7..3e2c6c1d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 5690059c..24023b66 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -788,6 +788,7 @@ "出售选中干员": "出售選中幹員", "取消 / 关闭": "取消 / 關閉", "语言": "語言", + "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "目前語言的介面文字為機器翻譯,可能不夠準確,歡迎在 GitHub 上指正。", "右键查看详情": "右鍵查看詳情", "刷新商店": "更新商店", "冻结 / 解冻商店": "凍結 / 解凍商店", diff --git a/public/js/ui/lang.js b/public/js/ui/lang.js index ede78361..5f95bfbf 100644 --- a/public/js/ui/lang.js +++ b/public/js/ui/lang.js @@ -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 diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js index bae6117c..d7db406d 100644 --- a/public/js/ui/settings.js +++ b/public/js/ui/settings.js @@ -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 }) { ${t('语言')}<${MicroLabel}>LANGUAGE <${LangToggle} class="set-lang" /> + ${mtNote ? html`

${mtNote}

` : 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} diff --git a/shared/i18n.js b/shared/i18n.js index e521abb9..499614b8 100644 --- a/shared/i18n.js +++ b/shared/i18n.js @@ -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} 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; /** diff --git a/shared/i18nPacks.js b/shared/i18nPacks.js index 17ac0723..9fb8bf2f 100644 --- a/shared/i18nPacks.js +++ b/shared/i18nPacks.js @@ -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, + }; } /** diff --git a/shared/packs.js b/shared/packs.js index ab9046ba..129ac85f 100644 --- a/shared/packs.js +++ b/shared/packs.js @@ -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: "", 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, - * 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; } diff --git a/test/i18n-packs.test.js b/test/i18n-packs.test.js index 5b23715f..7ea69335 100644 --- a/test/i18n-packs.test.js +++ b/test/i18n-packs.test.js @@ -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()] }); diff --git a/test/ui/i18n.e2e.test.js b/test/ui/i18n.e2e.test.js index 298586f0..dfd76425 100644 --- a/test/ui/i18n.e2e.test.js +++ b/test/ui/i18n.e2e.test.js @@ -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/.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(); });