feedback5 WAD: language packs are the first type of content packs — a language is a file in a language folder (public/i18n/<code>.json, optional data/i18n/<code>.json) or a pack folder (packs/<id>/pack.json); the server lists them live at /packs/index.json (server/packs.js), the menu shows every pack, strings fall back pack → base → declared fallback (English) → Chinese, broken placeholders are skipped, plurals follow Intl.PluralRules (English unchanged), English carries a _meta manifest like any pack; tools: i18n.mjs template / check <code>, packs.mjs list / index, build-i18n --lang ja|ko|zh-TW (the official JP / KR / TW clients); the release zip ships packs/ and a generated packs/index.json; golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 02:38:22 +08:00
1 parent f1df0d359d
commit 25b2a87b26
22 files changed
+2222 -241

No files matched your search

+5 -3
View File
@@ -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 {
+15
View File
@@ -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",
+61 -26
View File
@@ -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/<code>.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/<name>.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<string, Promise<any>>} */
const overlayLoads = new Map();
/** @type {Map<string, any>} */
@@ -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/<lang>.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/<lang>.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/<code>.json)
* @returns {Promise<string>}
*/
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;
},
};
}
+10 -5
View File
@@ -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`<div class="screen title-screen">
<div class=${`title-bg${bgLoaded ? ' has-art' : ''}${ridgesLoaded ? ' has-ridges' : ''}`} aria-hidden="true">
${backdrop ? html`<img class="title-bg__art" src=${backdrop} alt="" draggable=${false}
@@ -247,11 +252,11 @@ export function TitleScreen() {
<main class="title-main">
<${Emblem} />
${lang === 'en' ? null : html`<div class="title-en">
${alphabetic ? null : html`<div class="title-en">
<span class="title-en__a">STRONGHOLD PROTOCOL</span>
<span class="title-en__b">ALLIANCE</span>
</div>`}
<h1 class=${`title-cn${lang === 'en' ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${lang === 'en' ? ': ' : ':'}</span><em>${t('盟约')}</em></h1>
<h1 class=${`title-cn${alphabetic ? ' title-cn--latin' : ''}`}>${t('卫戍协议')}<span class="title-cn__colon">${alphabetic ? ': ' : ':'}</span><em>${t('盟约')}</em></h1>
<p class="title-tag">${t('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}</p>
<div class="title-login">
+8 -6
View File
@@ -2,7 +2,7 @@
import { UF } from '../../../../shared/constants.js';
import { clamp, isObj, tileKey } from './shared.js';
import { N_, getLang } from '../../../../shared/i18n.js';
import { N_, langInfo } from '../../../../shared/i18n.js';
// ---- players, statuses, fields ------------------------------------------------------------------------
@@ -71,20 +71,22 @@ export function attackInterval(bat, aspd = 100) {
}
/**
* Compact number: 12345 → '12,345'; 1.5e6 → '150万' — in English the thousands-based units: 1.5e6 → '1.5M', 2.5e5 →
* '250K', 3e9 → '3B' (万 / 亿 count in 10⁴ / 10⁸ steps, no template can move the decimal point).
* Compact number: 12345 → '12,345'; 1.5e6 → '150万' in Chinese, whose units count in 10⁴ / 10⁸ steps (万 / 亿; a language
* pack names its own pair in `_meta.numberUnits`, shared/i18nPacks.js — no template can move the decimal point); a
* language without them uses the thousands-based units: 1.5e6 → '1.5M', 2.5e5 → '250K', 3e9 → '3B' (English).
*/
export function fmtNum(v) {
const n = Number(v);
if (!Number.isFinite(n)) return '—';
if (getLang() === 'en') {
const units = langInfo()?.numberUnits;
if (!units) {
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(Math.abs(n) >= 1e10 ? 0 : 1)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(Math.abs(n) >= 1e7 ? 0 : 1)}M`;
if (Math.abs(n) >= 1e5) return `${Math.round(n / 1e3)}K`;
return Math.round(n).toLocaleString('en-US');
}
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}亿`; // i18n-ignore: the Chinese units (English above)
if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}万`; // i18n-ignore
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}${units[1]}`;
if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}${units[0]}`;
return Math.round(n).toLocaleString('en-US');
}
+180 -41
View File
@@ -1,47 +1,90 @@
// Language switch of the client (docs/I18N.md): picks the language at boot, loads the UI translations
// (public/i18n/<lang>.json, shared/i18n.js) and the localized game data (data/i18n/<lang>.json through data.js), keeps
// the choice, and translates what the server sends (m.toast / m.ticker / error codes).
// Language switch of the client (docs/I18N.md): lists the language packs — the "lang" entries of the pack index
// /packs/index.json (shared/packs.js; the server's registry, server/packs.js, scans the language folders and the pack
// folders; a static host serves the file `node tools/packs.mjs index` writes) — picks the language at boot, loads the UI
// strings of the chosen pack and of its fallback chain (shared/i18n.js; the URLs the index gives: /i18n/<code>.json or
// /packs/<id>/<file>) and its game texts (data.js), keeps the choice, and translates what the server sends (m.toast /
// m.ticker / error codes). No language is known by name here: a pack is a file in a folder (the owner's decision of
// 2026-10-07).
//
// Chinese is the default; English is a switch (the owner's decision of 2026-10-05) — the browser's language is not
// consulted. Order at boot: `?lang=en|zh` in the URL (then removed from the address bar and kept as the choice), the
// stored choice (localStorage `sp.pref.lang`), else Chinese. A switch re-renders the app in place (main.js App
// subscribes with useLang) — no reload; the game texts follow as soon as the data overlay has downloaded (data.js
// notifies its subscribers).
// Chinese is the default (the owner's decision of 2026-10-05) — the browser's language is not consulted. Order at boot:
// `?lang=<code>` in the URL (then removed from the address bar and kept as the choice), the stored choice (localStorage
// `sp.pref.lang`), else Chinese; a code no pack has falls back to Chinese (`en-US` takes the `en` pack). A switch
// re-renders the app in place (main.js App subscribes with useLang) — no reload; the game texts follow as soon as their
// overlays have downloaded (data.js notifies its subscribers). Without an index (a static host that lacks the file) the
// stored or requested pack still loads by its code, and the menu offers it beside Chinese.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { LANGS, DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire } from '../../../shared/i18n.js';
import {
DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire, t,
registerLangs, getLangs, langInfo, langChain, onLangsChange, setI18nWarn,
} from '../../../shared/i18n.js';
import { canonicalLang, computeChain, scriptOf } from '../../../shared/i18nPacks.js';
import { PACKS_URL, PACK_INDEX_FILE, readPackIndex, langMetaOf } from '../../../shared/packs.js';
import { DEV_BUILD } from '../../../shared/constants.js';
import { loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { html } from './components.js';
/** Labels of the language switch (each in its own language). */
export const LANG_LABELS = Object.freeze({ zh: '中文', en: 'English' }); // i18n-ignore: each language in its own name
/** The switch's own label, in both languages (whoever opens it may not read the current one). */
const SWITCH_LABEL = 'Language / 语言'; // i18n-ignore
const PREF_KEY = 'lang';
/** How long boot waits for the UI translations before rendering in Chinese anyway (they apply when they arrive). */
/** How long boot waits for the index and the UI strings before rendering in Chinese anyway (they apply when they arrive). */
const BOOT_WAIT_MS = 2500;
/** Up to this many languages the switch is a row of buttons (中文 | English …); more make it a list. */
export const SEGMENTED_MAX = 4;
/** @type {Map<string, Promise<boolean>>} */
const uiLoads = new Map();
/** @type {Promise<boolean> | null} */
let indexLoad = null;
/** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */
let indexOk = false;
const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`;
const defaultFetch = (...a) => globalThis.fetch(...a);
/**
* Download (once) the UI translations of a language.
* Download (once) the pack index and register its language packs (shared/i18n.js registerLangs), so the menu lists
* them.
* @param {typeof fetch} [doFetch]
* @returns {Promise<boolean>} false when unavailable (the menu then offers Chinese and the stored language)
*/
export function loadLangIndex(doFetch = defaultFetch) {
if (indexLoad) return indexLoad;
indexLoad = (async () => {
try {
const res = await doFetch(INDEX_URL, { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
registerLangs(readPackIndex(await res.json(), 'lang').map(langMetaOf));
indexOk = true;
return true;
} catch (err) {
console.warn(`[i18n] ${INDEX_URL} unavailable (${err?.message || err}); the menu offers Chinese and the stored language`);
return false;
}
})();
return indexLoad;
}
/**
* Download (once) the UI strings of one language pack: the URL its index entry gives, else the language folder's file
* /i18n/<code>.json (whose `_meta` then registers it).
* @param {string} lang
* @param {typeof fetch} [doFetch]
* @returns {Promise<boolean>} false when unavailable (the UI stays Chinese)
* @returns {Promise<boolean>} false when unavailable (the interface stays as it is)
*/
export function loadUiMessages(lang, doFetch = (...a) => globalThis.fetch(...a)) {
export function loadUiMessages(lang, doFetch = defaultFetch) {
if (lang === DEFAULT_LANG) return Promise.resolve(true);
if (uiLoads.has(lang)) return uiLoads.get(lang);
const url = langInfo(lang)?.ui || `/i18n/${lang}.json`;
const p = (async () => {
try {
const res = await doFetch(`/i18n/${lang}.json`, { cache: 'no-cache' });
const res = await doFetch(url, { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
addMessages(lang, await res.json());
return true;
} catch (err) {
console.warn(`[i18n] /i18n/${lang}.json unavailable (${err?.message || err}); the interface stays Chinese`);
console.warn(`[i18n] ${url} unavailable (${err?.message || err}); the interface stays as it is`);
uiLoads.delete(lang);
return false;
}
@@ -51,17 +94,44 @@ export function loadUiMessages(lang, doFetch = (...a) => globalThis.fetch(...a))
}
/**
* The language to start in: the URL's `?lang=`, then the stored choice, then Chinese.
* Load a pack and the packs of its chain (its base, its fallbacks — shared/i18n.js langChain). A fallback that fails
* only leaves its strings to the next language of the chain.
* @param {string} lang
* @param {typeof fetch} [doFetch]
* @returns {Promise<boolean>} whether the pack itself loaded
*/
export async function loadLangChain(lang, doFetch = defaultFetch) {
if (lang === DEFAULT_LANG) return true;
if (!(await loadUiMessages(lang, doFetch))) return false;
// without the index, the chain's codes are not registered yet: try them by the pack's own `_meta`
const chain = indexOk ? langChain(lang) : computeChain(lang, (c) => langInfo(c), () => true);
await Promise.all(chain.filter((c) => c !== lang).map((c) => loadUiMessages(c, doFetch)));
return true;
}
/**
* The game-text overlays that apply for `lang`, best first: the chain's packs that have one (the index's `files.data`;
* unknown without the index → the language folder's data/i18n/<code>.json is tried), with their URLs (data.js).
* @param {string} lang
* @returns {{ code: string, url?: string }[]}
*/
export const dataChain = (lang) => langChain(lang).filter((c) => langInfo(c)?.data !== false).map((c) => ({ code: c, url: langInfo(c)?.dataUrl }));
/**
* The language to start in: the URL's `?lang=`, then the stored choice, then Chinese. `tentative`: also a well-formed
* code no pack is known for (boot without an index tries to load it).
* @param {string} [search] location.search
* @param {(key: string, fallback: any) => any} [load]
* @param {{ tentative?: boolean }} [opts]
* @returns {{ lang: string, fromUrl: boolean }}
*/
export function initialLang(search = globalThis.location?.search || '', load = loadPref) {
export function initialLang(search = globalThis.location?.search || '', load = loadPref, { tentative = false } = {}) {
const pick = (v) => normalizeLang(v) || (tentative ? canonicalLang(v) : null);
let fromUrl = null;
try { fromUrl = normalizeLang(new URLSearchParams(search).get('lang')); } catch { /* ignore */ }
try { fromUrl = pick(new URLSearchParams(search).get('lang')); } catch { /* ignore */ }
if (fromUrl) return { lang: fromUrl, fromUrl: true };
let stored = null;
try { stored = normalizeLang(load(PREF_KEY, null)); } catch { /* ignore */ }
try { stored = pick(load(PREF_KEY, null)); } catch { /* ignore */ }
return { lang: stored || DEFAULT_LANG, fromUrl: false };
}
@@ -74,46 +144,76 @@ function stripLangParam() {
} catch { /* ignore */ }
}
/** <html lang> and the tab title follow the language. */
/** The `lang` attribute of a language: its code ('zh-CN' for the Chinese source). @param {string} code */
export const htmlLang = (code) => (code === DEFAULT_LANG ? 'zh-CN' : code);
/**
* <html lang>, data-lang, data-script (the title screen's layout: CJK or alphabetic, from the pack's title) and the tab
* title follow the language.
*/
function applyDocument(lang) {
const doc = globalThis.document;
if (!doc) return;
doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
doc.documentElement.lang = htmlLang(lang);
doc.documentElement.dataset.lang = lang;
doc.title = lang === 'en' ? 'Stronghold Protocol: Alliance · Web Simulation' : '卫戍协议:盟约 · STRONGHOLD PROTOCOL'; // i18n-ignore: one title per language
doc.documentElement.dataset.script = scriptOf(t('卫戍协议'));
doc.title = t('卫戍协议:盟约 · STRONGHOLD PROTOCOL');
}
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
setNameResolver((name) => data.localeName(name));
/** A development page (a dev build, or served from this machine): report translations t() skips (shared/i18n.js). */
function isDevPage() {
if (DEV_BUILD) return true;
try {
const h = String(globalThis.location?.hostname || '');
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1' || h.endsWith('.localhost');
} catch { return false; }
}
let wired = false;
function wire() {
if (wired) return;
wired = true;
onLangChange((lang) => { applyDocument(lang); });
if (isDevPage()) {
setI18nWarn(({ lang, key, problems }) => console.warn(`[i18n] ${lang}: the translation of "${key}" is skipped (${problems.join('; ')}); the next language of the chain shows`));
}
}
/** Make a loaded language current: the interface now, the game texts once their overlays are in. */
function applyLang(lang) {
setLang(lang);
data.setLocale(getLang(), dataChain(getLang())).catch(() => {});
}
/**
* Boot: choose the language and load its UI translations (waits at most BOOT_WAIT_MS) before the first render; the
* game-data overlay downloads in the background.
* Boot: list the packs, choose the language and load its UI strings (waits at most BOOT_WAIT_MS) before the first
* render; the game-text overlays download in the background.
* @returns {Promise<string>} the language in effect
*/
export async function initLang() {
wire();
const { lang, fromUrl } = initialLang();
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
applyDocument(getLang());
if (lang === DEFAULT_LANG) return getLang();
const ok = await Promise.race([loadUiMessages(lang), new Promise((r) => setTimeout(() => r(null), BOOT_WAIT_MS))]);
if (ok === null) loadUiMessages(lang).then((loaded) => { if (loaded && normalizeLang(loadPref(PREF_KEY, null)) === lang) setLang(lang); });
else if (ok) setLang(lang);
data.setLocale(lang).catch(() => {});
const TIMEOUT = Symbol('timeout');
const boot = (async () => {
const listed = await loadLangIndex();
const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed });
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
if (lang === DEFAULT_LANG) return null;
return (await loadLangChain(lang)) ? lang : null;
})();
const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]);
if (got === TIMEOUT) {
boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
} else if (got) applyLang(got);
return getLang();
}
/**
* Switch the language (the switch on the title screen and in 设置): keeps the choice, loads the translations, then
* re-renders; the game texts follow when their overlay has downloaded.
* Switch the language (the menu on the title screen and in 设置): keeps the choice, loads the pack and its chain, then
* re-renders; the game texts follow when their overlays have downloaded.
* @param {string} lang
* @returns {Promise<string>} the language in effect
*/
@@ -121,9 +221,8 @@ export async function switchLang(lang) {
wire();
const want = normalizeLang(lang) || DEFAULT_LANG;
savePref(PREF_KEY, want);
if (want !== DEFAULT_LANG && !(await loadUiMessages(want))) return getLang();
setLang(want);
data.setLocale(want).catch(() => {});
if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang();
applyLang(want);
return getLang();
}
@@ -141,14 +240,54 @@ export function useLang() {
}
/**
* The language switch (segmented 中文 | English).
* Preact hook: the known languages (shared/i18n.js getLangs); re-renders when the index or a pack arrives.
* @returns {ReturnType<typeof getLangs>}
*/
export function useLangs() {
const [list, setList] = useState(getLangs);
useEffect(() => {
setList(getLangs());
return onLangsChange(() => setList(getLangs()));
}, []);
return list;
}
/**
* What the language menu shows: every known language under its own name (the English name as a tooltip when it reads
* differently), current one marked — a row of buttons up to SEGMENTED_MAX languages, a list beyond.
* @param {ReturnType<typeof getLangs>} langs
* @param {string} current
* @returns {{ kind: 'buttons' | 'select', items: { code: string, label: string, title: string, htmlLang: string, on: boolean }[] }}
*/
export function langMenuModel(langs, current) {
const items = (Array.isArray(langs) ? langs : []).filter(Boolean).map((m) => ({
code: m.code,
label: m.name || m.code,
title: m.englishName && m.englishName !== m.name ? m.englishName : '',
htmlLang: htmlLang(m.code),
on: m.code === current,
}));
return { kind: items.length > SEGMENTED_MAX ? 'select' : 'buttons', items };
}
/**
* The language menu: 中文 | English | … as buttons, or a list when there are many packs.
* @param {{ class?: string }} props
*/
export function LangToggle({ class: cls }) {
const lang = useLang();
const menu = langMenuModel(useLangs(), lang);
const pick = (code) => { if (code && code !== getLang()) switchLang(code); };
if (menu.kind === 'select') {
return html`<label class=${`set-seg lang-toggle lang-select${cls ? ` ${cls}` : ''}`} data-testid="lang-toggle">
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value)}>
${menu.items.map((it) => html`<option key=${it.code} value=${it.code} lang=${it.htmlLang} title=${it.title || undefined} selected=${it.on}>${it.label}</option>`)}
</select>
</label>`;
}
return html`<div class=${`set-seg lang-toggle${cls ? ` ${cls}` : ''}`} role="radiogroup" aria-label=${SWITCH_LABEL} data-testid="lang-toggle">
${LANGS.map((l) => html`<button key=${l} type="button" role="radio" lang=${l === 'zh' ? 'zh-CN' : 'en'} aria-checked=${lang === l ? 'true' : 'false'}
class=${lang === l ? 'is-on' : ''} data-lang=${l} onClick=${() => { if (lang !== l) switchLang(l); }}>${LANG_LABELS[l]}</button>`)}
${menu.items.map((it) => html`<button key=${it.code} type="button" role="radio" lang=${it.htmlLang} aria-checked=${it.on ? 'true' : 'false'}
class=${it.on ? 'is-on' : ''} data-lang=${it.code} title=${it.title || undefined} onClick=${() => pick(it.code)}>${it.label}</button>`)}
</div>`;
}