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:
1 parent
6613c3d0fb
commit
c11ba03da4
11 files changed
+173
-38
No files matched your search
+27
-13
@@ -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
@@ -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).
|
||||
@@ -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",
|
||||
|
||||
@@ -788,6 +788,7 @@
|
||||
"出售选中干员": "出售選中幹員",
|
||||
"取消 / 关闭": "取消 / 關閉",
|
||||
"语言": "語言",
|
||||
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "目前語言的介面文字為機器翻譯,可能不夠準確,歡迎在 GitHub 上指正。",
|
||||
"右键查看详情": "右鍵查看詳情",
|
||||
"刷新商店": "更新商店",
|
||||
"冻结 / 解冻商店": "凍結 / 解凍商店",
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()] });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user