From 25b2a87b263afecf3af6245977ffc5c74b90cf11 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 02:22:16 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20WAD:=20language=20packs=20are=20the?= =?UTF-8?q?=20first=20type=20of=20content=20packs=20=E2=80=94=20a=20langua?= =?UTF-8?q?ge=20is=20a=20file=20in=20a=20language=20folder=20(public/i18n/?= =?UTF-8?q?.json,=20optional=20data/i18n/.json)=20or=20a=20pac?= =?UTF-8?q?k=20folder=20(packs//pack.json);=20the=20server=20lists=20t?= =?UTF-8?q?hem=20live=20at=20/packs/index.json=20(server/packs.js),=20the?= =?UTF-8?q?=20menu=20shows=20every=20pack,=20strings=20fall=20back=20pack?= =?UTF-8?q?=20=E2=86=92=20base=20=E2=86=92=20declared=20fallback=20(Englis?= =?UTF-8?q?h)=20=E2=86=92=20Chinese,=20broken=20placeholders=20are=20skipp?= =?UTF-8?q?ed,=20plurals=20follow=20Intl.PluralRules=20(English=20unchange?= =?UTF-8?q?d),=20English=20carries=20a=20=5Fmeta=20manifest=20like=20any?= =?UTF-8?q?=20pack;=20tools:=20i18n.mjs=20template=20/=20check=20,?= =?UTF-8?q?=20packs.mjs=20list=20/=20index,=20build-i18n=20--lang=20ja|ko|?= =?UTF-8?q?zh-TW=20(the=20official=20JP=20/=20KR=20/=20TW=20clients);=20th?= =?UTF-8?q?e=20release=20zip=20ships=20packs/=20and=20a=20generated=20pack?= =?UTF-8?q?s/index.json;=20golden=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + packs/README.md | 28 +++ public/css/screens/title.css | 8 +- public/i18n/en.json | 15 ++ public/js/data.js | 87 +++++-- public/js/screens/title.js | 15 +- public/js/ui/gameLogic/format.js | 14 +- public/js/ui/lang.js | 221 +++++++++++++---- server/http/config.js | 9 +- server/http/static.js | 24 +- server/index.js | 18 +- server/packs.js | 245 +++++++++++++++++++ shared/i18n.js | 347 +++++++++++++++++++++++---- shared/i18nData.js | 55 +++-- shared/i18nPacks.js | 171 ++++++++++++++ shared/packs.js | 285 ++++++++++++++++++++++ test/i18n-packs.test.js | 391 +++++++++++++++++++++++++++++++ test/package.test.js | 37 +++ tools/build-i18n.mjs | 125 ++++++---- tools/i18n.mjs | 251 +++++++++++++++++--- tools/package.mjs | 29 ++- tools/packs.mjs | 84 +++++++ 22 files changed, 2222 insertions(+), 241 deletions(-) create mode 100644 packs/README.md create mode 100644 server/packs.js create mode 100644 shared/i18nPacks.js create mode 100644 shared/packs.js create mode 100644 test/i18n-packs.test.js create mode 100644 tools/packs.mjs diff --git a/.gitignore b/.gitignore index 6721a54d..767b4166 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ venv/ __pycache__/ *.py[cod] +# --- content packs (docs/PACKS.md): the pack index for a static host, generated by `node tools/packs.mjs index` +# (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) ----------- +packs/index.json + # --- tests & logs ---------------------------------------------------------------------------------- test/e2e/out/ logs/ diff --git a/packs/README.md b/packs/README.md new file mode 100644 index 00000000..586e6112 --- /dev/null +++ b/packs/README.md @@ -0,0 +1,28 @@ +# packs/ — content packs + +Put a content pack here as its own folder: `packs//pack.json` (the manifest) next to the files it names. The +server lists every pack it finds at `/packs/index.json` and picks up a new or changed pack on the next page load — no +restart, no build step. `node tools/packs.mjs list` shows what was found, what was skipped and why. + +In this version one type loads: **`lang`**, a language for the interface (and, optionally, the game texts): + +```json +{ + "id": "ja", + "type": "lang", + "lang": "ja", + "name": "日本語", + "englishName": "Japanese", + "version": "1.0.0", + "app": ">=0.2.0", + "authors": ["your name"], + "fallback": ["en"], + "files": { "ui": "ui.json", "data": "data.json" } +} +``` + +A language can also be one file, `public/i18n/.json`, with the same fields in its `_meta` block — that is how +English ships. Types `assets` (art, audio, fonts) and `data` (data patches) are planned; this version lists such a pack +as not supported and does not load it. + +The format, the rules and how later types plug in: [docs/PACKS.md](../docs/PACKS.md). Translating: [docs/I18N.md](../docs/I18N.md). diff --git a/public/css/screens/title.css b/public/css/screens/title.css index 108f6f27..ed29a4ab 100644 --- a/public/css/screens/title.css +++ b/public/css/screens/title.css @@ -192,7 +192,7 @@ animation: rise-in 600ms 140ms var(--ease-out) backwards; } .title-cn__colon { color: #f4f6f5; } -/* English title (ui/lang.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */ +/* a title in an alphabetic script (English …; screens/title.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */ .title-cn--latin { font-family: var(--font-display); font-size: .6rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; } .title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); } .title-tag { @@ -243,10 +243,12 @@ .title-invite .icon { width: .2rem; height: .2rem; } .title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; } .title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); } -/* language switch (中文 | English) in the top-right corner */ +/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */ .title-lang { display: inline-flex; margin-bottom: .1rem; } .title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; } -html[data-lang="en"] .title-login .btn--xl { letter-spacing: .3em; } +.lang-select select { height: .38rem; min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: .16rem; cursor: pointer; } +.title-lang.lang-select select { height: .3rem; font-size: .14rem; } +html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; } .title-conn .ping { height: .26rem; font-size: .15rem; } .title-foot { diff --git a/public/i18n/en.json b/public/i18n/en.json index 01b2db31..807a8b24 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1,4 +1,18 @@ { + "_meta": { + "type": "lang", + "lang": "en", + "name": "English", + "englishName": "English", + "version": "0.2.0", + "app": ">=0.2.0", + "authors": [ + "@YuriRestia (GitHub PR #70, the seed of these strings)", + "the Stronghold Protocol contributors" + ], + "credits": "Game texts (data/i18n/en.json): the official EN client data (© Hypergryph / Yostar) via ArknightsAssets/ArknightsGamedata.", + "complete": true + }, "_about": "English UI strings: Chinese msgid → English (shared/i18n.js t(); docs/I18N.md). Placeholders {name} and {n|one|other} as in the msgid; a 'context::msgid' key is a tc() variant. Seeded from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (NOTICE.md). Game texts are not here: data/i18n/en.json (tools/build-i18n.mjs).", "你已在其他同盟中,请先离开当前同盟": "You are already in another Alliance. Leave it first.", "你已不在该同盟中": "You are no longer in this Alliance", @@ -116,6 +130,7 @@ "请输入博士代号": "Please enter your Doctor callsign", "卫戍协议": "Stronghold Protocol", "盟约": "Alliance", + "卫戍协议:盟约 · STRONGHOLD PROTOCOL": "Stronghold Protocol: Alliance · Web Simulation", "调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。": "Allocate Funds and Operators, coordinate defenses with your allies, hold off wave after wave, and defeat the Enemy Leader.", "收到同盟邀请": "Alliance invite received", "· 输入代号后将自动加入": "· You'll join automatically after entering your callsign", diff --git a/public/js/data.js b/public/js/data.js index 71630939..f3b725b0 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -18,15 +18,18 @@ // Synchronous getters (getChess, getBond, …) return null until the file has loaded; use // `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive. // -// Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale('en')` downloads the overlay -// data/i18n/en.json once (tools/build-i18n.mjs: the official EN texts by record id and field, shared/i18nData.js) and -// from then on `get`, `lookup` and `list` — and every getter built on them — return the records with their English -// texts (a copy per file, built on first use; untranslated fields keep the Chinese text). `setLocale('zh')` goes back. -// Each switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps -// a Chinese game-data name to the current language (server messages name operators / items in Chinese). +// Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale(lang, chain)` downloads (once each) the +// game-text overlays data/i18n/.json of the chain — the language's own, its base's, a fallback's (ui/lang.js +// passes the languages of the pack's chain that have one; default: the language alone) — built by tools/build-i18n.mjs +// (the official texts by record id and field, shared/i18nData.js). From then on `get`, `lookup` and `list` — and every +// getter built on them — return the records with their texts in that language (a copy per file, built on first use): +// each text from the first overlay of the chain that has it, else the Chinese one. `setLocale('zh')` goes back. Each +// switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps a +// Chinese game-data name to the current language (server messages name operators / items in Chinese). import { useEffect, useReducer } from '../vendor/hooks.module.js'; import { applyFileOverlay } from '../../shared/i18nData.js'; +import { canonicalLang } from '../../shared/i18nPacks.js'; /** Known data files (name → URL basename). Unknown names are allowed too (`/data/.json`). */ export const DATA_FILES = Object.freeze({ @@ -134,6 +137,10 @@ export function createDataStore(opts = {}) { const warned = new Set(); /** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */ let locale = 'zh'; + /** The languages whose overlays apply, best first (a subset of the chain setLocale got: those that downloaded). */ + let chain = []; + /** setLocale calls so far: a slower earlier call never overrides a later one. */ + let switches = 0; /** @type {Map>} */ const overlayLoads = new Map(); /** @type {Map} */ @@ -237,16 +244,22 @@ export function createDataStore(opts = {}) { return entry.promise; } - /** The localized copy of a ready file (null in Chinese or without an overlay for the file). */ + /** + * The localized copy of a ready file (null in Chinese or without an overlay for the file): the overlays of the chain + * applied from the last to the first, each text checked against the Chinese file, so the best overlay wins per text. + */ function localizedEntry(name) { - if (locale === 'zh') return null; + if (locale === 'zh' || !chain.length) return null; const e = entries.get(name); - const fileOverlay = overlays.get(locale)?.files?.[name]; - if (!e || e.status !== 'ready' || !fileOverlay) return null; + if (!e || e.status !== 'ready') return null; + const fileOverlays = chain.map((c) => overlays.get(c)?.files?.[name]).filter(Boolean); + if (!fileOverlays.length) return null; let l = localized.get(name); if (!l || l.lang !== locale || l.base !== e.value) { let value = e.value; - try { value = applyFileOverlay(e.value, fileOverlay).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); } + for (const fo of fileOverlays.reverse()) { + try { value = applyFileOverlay(e.value, fo, value).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); } + } l = { lang: locale, base: e.value, value, index: null }; localized.set(name, l); } @@ -265,13 +278,16 @@ export function createDataStore(opts = {}) { return e.index; } - /** Download (once per language) the overlay data/i18n/.json; resolves to it, or null when unavailable. */ - function loadOverlay(lang) { + /** + * Download (once per language) a game-text overlay — `url` (a pack folder's file, from the pack index) or the language + * folder's data/i18n/.json; resolves to it, or null when unavailable. + */ + function loadOverlay(lang, url = `${base}i18n/${lang}.json`) { if (overlayLoads.has(lang)) return overlayLoads.get(lang); const p = (async () => { for (let attempt = 0; ; attempt++) { try { - const res = await doFetch(`${base}i18n/${lang}.json`, { cache: 'no-cache' }); + const res = await doFetch(url, { cache: 'no-cache' }); if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null }); const json = await res.json(); if (!json || typeof json !== 'object' || !json.files) throw Object.assign(new Error('not an overlay'), { badJson: true }); @@ -279,7 +295,7 @@ export function createDataStore(opts = {}) { return json; } catch (err) { if (transientFailure(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); continue; } - console.warn(`[data] ${base}i18n/${lang}.json unavailable (${err?.message || err}); game texts stay Chinese`); + console.warn(`[data] ${url} unavailable (${err?.message || err}); its game texts are not applied`); overlayLoads.delete(lang); // a later switch may try again return null; } @@ -333,33 +349,52 @@ export function createDataStore(opts = {}) { listeners.add(fn); return () => listeners.delete(fn); }, - /** Current game-text locale ('zh' or a language whose overlay is applied). */ + /** Current game-text locale ('zh' or the language of the last switch). */ locale: () => locale, + /** The languages whose overlays apply now, best first (empty in Chinese). */ + localeChain: () => [...chain], /** - * Switch the game texts' language: 'zh' at once; another language once its overlay has downloaded (it stays Chinese - * when the overlay is unavailable). Every loaded file's subscribers are notified. Resolves to the locale in effect. + * Switch the game texts' language: 'zh' at once; another language once the overlays of `chain` (default: the + * language alone) have downloaded — those that fail are left out. When every overlay of a non-empty chain fails the + * locale does not change (the texts stay as they are); an empty chain (a pack without game texts) switches with + * the Chinese texts. Every loaded file's subscribers are notified. Resolves to the locale in effect. * @param {string} lang + * @param {(string | { code: string, url?: string })[]} [chainIn] overlay languages, best first (with the URL of + * each overlay when it is not the language folder's data/i18n/.json) * @returns {Promise} */ - async setLocale(lang) { - const want = typeof lang === 'string' && /^[a-z]{2}$/.test(lang) ? lang : 'zh'; - if (want !== 'zh' && !(await loadOverlay(want))) return locale; - if (want === locale) return locale; + async setLocale(lang, chainIn) { + const code = canonicalLang(lang); + const want = code && code === lang ? code : 'zh'; + const items = (Array.isArray(chainIn) ? chainIn : [want]).map((c) => (typeof c === 'string' ? { code: c, url: undefined } : c)); + const seen = new Set(); + const asked = want === 'zh' ? [] : items.filter((c) => c && canonicalLang(c.code) === c.code && c.code !== 'zh' && !seen.has(c.code) && seen.add(c.code)); + const ask = asked.map((c) => c.code); + const seq = ++switches; + const got = await Promise.all(asked.map((c) => loadOverlay(c.code, typeof c.url === 'string' && c.url ? c.url : undefined))); + if (seq !== switches) return locale; // a later switch is under way + const next = ask.filter((_, i) => got[i]); + if (ask.length && !next.length) return locale; + if (want === locale && next.join('|') === chain.join('|')) return locale; locale = want; + chain = next; localized.clear(); for (const name of [...entries.keys()]) notify(name); return locale; }, /** - * A Chinese game-data name (operator, item, bond, token …) in the current locale (itself in Chinese or when the - * overlay does not know it). + * A Chinese game-data name (operator, item, bond, token …) in the current locale: the first overlay of the chain + * that knows it, else the name itself (also in Chinese). * @param {string} zh * @returns {string} */ localeName(zh) { if (locale === 'zh' || typeof zh !== 'string') return zh; - const names = overlays.get(locale)?.names; - return (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) || zh; + for (const c of chain) { + const names = overlays.get(c)?.names; + if (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) return names[zh]; + } + return zh; }, }; } diff --git a/public/js/screens/title.js b/public/js/screens/title.js index 9d3f3917..2ff7873d 100644 --- a/public/js/screens/title.js +++ b/public/js/screens/title.js @@ -1,5 +1,6 @@ -// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language switch -// (中文 | English, ui/lang.js; in English the big title is the English one and the small wordmark above it hides). +// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language menu +// (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and +// the small wordmark above it hides). // // Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it, // marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which @@ -20,6 +21,7 @@ import { data, useData } from '../data.js'; import { FullscreenButton, detectFeatures } from '../ui/device.js'; import { LangToggle, useLang } from '../ui/lang.js'; import { t, N_ } from '../../../shared/i18n.js'; +import { scriptOf } from '../../../shared/i18nPacks.js'; import { GIcon } from '../ui/gameComponents.js'; import { SettingsModal } from '../ui/settings.js'; @@ -187,7 +189,7 @@ const STATUS_TEXT = { export function TitleScreen() { const conn = useStore((s) => s.connection, shallowEqual); const pendingJoin = useStore((s) => s.ui.pendingJoin); - const lang = useLang(); + useLang(); // re-render on a language switch const [name, setName] = useState(() => store.get().me.name || identity.loadName() || ''); const [settingsOpen, setSettingsOpen] = useState(false); const assetsSettled = useData('assets'); @@ -215,6 +217,9 @@ export function TitleScreen() { // touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view) const touchUi = useMemo(() => detectFeatures().coarse, []); + // a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it + // hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag) + const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic'; return html`