feedback5 WI: English game texts from the official EN client data (data/i18n/en.json) and the i18n core

shared/i18n.js: gettext-style t() / tc() with the Chinese text as msgid (untranslated -> Chinese), {name},
{0} and {n|one|other} params, lists, game-data names (dn / tName), setLang / getLang / onLangChange, and the
server message wire format (msg -> wireMessage -> translateWire). Chinese stays the default, English is a
switch (the owner's decision of 2026-10-05).

tools/build-i18n.mjs (build-data.mjs untouched) walks the zh_CN and EN tables side by side and writes
data/i18n/en.json: the official English of every data/*.json text by record id and field. Blackboard texts
are matched by template (the remake's numbers read back and filled into the English template); stage names
are composed; what the EN client lacks falls back to tools/i18n/fallback-remake.json and fallback-pr70.json
(PR #70 by @YuriRestia). Default EN source ArknightsAssets/ArknightsGamedata (has act2autochess);
Kengxxiao/ArknightsGameData_YoStar is archived (2025-11) and has no 盟约 season (--source yostar).
Coverage: 14 922 / 14 931 texts (99.9 %), 99.3 % official; only 9 CN-only 甄选 operator names stay Chinese.

shared/i18nData.js: the overlay format (index-aligned leaves + a 2-char hash of each Chinese source, so a
stale leaf is never applied). public/js/data.js: setLocale('en') downloads the overlay once; get / lookup /
list return localized copies; getRaw keeps the Chinese file; localeName maps names.
This commit is contained in:
sganggs committed 2026-10-05 20:19:43 +08:00
1 parent a98b02477d
commit e596478bef
8 files changed
+1593 -3

No files matched your search

+1
View File
@@ -0,0 +1 @@
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":1166,"translated":1166,"official":1148,"pct":100},"operators":{"texts":635,"translated":626,"official":618,"pct":98.6},"skills":{"texts":3057,"translated":3057,"official":3057,"pct":100},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":2103,"translated":2103,"official":2095,"pct":100},"tokens":{"texts":442,"translated":442,"official":441,"pct":100},"traits":{"texts":1024,"translated":1024,"official":1024,"pct":100}},"totals":{"texts":14931,"translated":14922,"official":14828,"pct":99.9}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","丰川祥子":"Sakiko Togawa","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"MilitarLine truncated
+95 -3
View File
@@ -17,8 +17,16 @@
// - or either of those wrapped as { <name>: … } / { list: … } / { data: … }.
// 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).
import { useEffect, useReducer } from '../vendor/hooks.module.js';
import { applyFileOverlay } from '../../shared/i18nData.js';
/** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */
export const DATA_FILES = Object.freeze({
@@ -122,6 +130,14 @@ export function createDataStore(opts = {}) {
const entries = new Map();
const listeners = new Set();
const warned = new Set();
/** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */
let locale = 'zh';
/** @type {Map<string, Promise<any>>} */
const overlayLoads = new Map();
/** @type {Map<string, any>} */
const overlays = new Map();
/** name → { lang, value, index } localized copy of a ready file (dropped on every switch / reload of the file) */
const localized = new Map();
const notify = (name) => {
for (const fn of [...listeners]) {
@@ -219,20 +235,67 @@ export function createDataStore(opts = {}) {
return entry.promise;
}
/** The localized copy of a ready file (null in Chinese or without an overlay for the file). */
function localizedEntry(name) {
if (locale === 'zh') return null;
const e = entries.get(name);
const fileOverlay = overlays.get(locale)?.files?.[name];
if (!e || e.status !== 'ready' || !fileOverlay) 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); }
l = { lang: locale, base: e.value, value, index: null };
localized.set(name, l);
}
return l;
}
function index(name) {
const e = entries.get(name);
if (!e || e.status !== 'ready') return null;
const l = localizedEntry(name);
if (l) {
if (!l.index) l.index = buildIndex(name, l.value);
return l.index;
}
if (!e.index) e.index = buildIndex(name, e.value);
return e.index;
}
/** Download (once per language) the overlay data/i18n/<lang>.json; resolves to it, or null when unavailable. */
function loadOverlay(lang) {
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' });
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 });
overlays.set(lang, json);
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`);
overlayLoads.delete(lang); // a later switch may try again
return null;
}
}
})();
overlayLoads.set(lang, p);
return p;
}
return {
/** Fetch (once) and return a file's JSON, or null when missing. */
load,
/** Load several files; resolves when all settled. */
loadAll: (...names) => Promise.all(names.flat().map(load)),
/** Raw JSON of a loaded file (null when missing / not loaded yet). */
get: (name) => entries.get(name)?.value ?? null,
/** JSON of a loaded file in the current locale (null when missing / not loaded yet). */
get: (name) => localizedEntry(name)?.value ?? entries.get(name)?.value ?? null,
/** The file exactly as downloaded (Chinese texts), whatever the locale. */
getRaw: (name) => entries.get(name)?.value ?? null,
/** 'idle' | 'loading' | 'ready' | 'missing' */
status: (name) => entries.get(name)?.status ?? 'idle',
/** Record by id from a loaded file (null when unknown / not loaded). */
@@ -246,16 +309,45 @@ export function createDataStore(opts = {}) {
invalidate(name) {
if (!entries.has(name)) return Promise.resolve(null);
entries.delete(name);
localized.delete(name);
warned.delete(name);
const p = load(name);
notify(name); // status is 'loading' again, never a stuck 'idle'
return p;
},
/** Called with the file name whenever a file finishes loading (or is invalidated). */
/** Called with the file name whenever a file finishes loading (or is invalidated, or the locale changes). */
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
/** Current game-text locale ('zh' or a language whose overlay is applied). */
locale: () => locale,
/**
* 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.
* @param {string} lang
* @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;
locale = want;
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).
* @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;
},
};
}
+304
View File
@@ -0,0 +1,304 @@
// shared/i18n.js — gettext-style translation for the browser and Node (no framework, no dependency, no Node builtin).
//
// The Chinese source string is the message id: `t('整备区已满')`, `t('还剩 {n} 秒', { n })`. A message without a
// translation in the current language falls back to the Chinese text, so an untranslated string is never blank.
// Chinese ('zh') is the default language; English ('en') is a switch (the owner's decision of 2026-10-05: an English
// browser still opens in Chinese until the player picks English). docs/I18N.md explains how to add and translate
// strings.
//
// Context: the same Chinese text may need two translations ('关闭' = "Close" on a button, "Off" on a switch):
// `tc('toggle', '关闭')` looks up the key 'toggle::关闭' first, then '关闭'; Chinese shows '关闭'.
// Module-level tables are evaluated once, before a language can change: mark their strings N_('…') (a no-op that
// tools/i18n.mjs extracts) and call t() where they are shown.
//
// Placeholders (the same in msgids and translations):
// {name} the value of params.name (params may also be an array: {0}, {1} …)
// {n|one|other} `one` when Number(params.n) === 1, else `other` — the only plural form (English needs no more)
// A value that is an array renders as a list joined with the language's separator ('、' / ', '); a value `{ dn: '…' }`
// (made by `dn()`) is a game-data name, translated through the name resolver the client installs (setNameResolver).
// A placeholder whose value is missing stays verbatim, so official templates such as '{0}博士…' survive untouched.
//
// Server → client messages (m.toast / m.ticker): the server builds `msg(msgid, params)` and sends
// `wireMessage(m)` = { text: <the Chinese rendering>, msgid, params } — `text` keeps older clients working, the client
// renders `translateWire(frame)` in its own language. A plain string message is its own msgid.
/** Supported languages; the first one is the default and the source language of every msgid. */
export const LANGS = Object.freeze(['zh', 'en']);
export const DEFAULT_LANG = 'zh';
/** List separator per language (array params). */
const LIST_SEP = Object.freeze({ zh: '、', en: ', ' });
/** @type {Map<string, Map<string, string>>} lang → msgid → translation */
const catalogs = new Map();
let current = DEFAULT_LANG;
/** @type {Set<(lang: string, prev: string) => void>} */
const listeners = new Set();
/** @type {((name: string, lang: string) => string) | null} */
let nameResolver = null;
/**
* A supported language code from loose input ('en-US' → 'en', 'zh_CN' → 'zh'), or null.
* @param {unknown} v
* @returns {string|null}
*/
export function normalizeLang(v) {
const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
if (!s) return null;
for (const l of LANGS) if (s === l || s.startsWith(`${l}-`) || s.startsWith(`${l}_`)) return l;
return null;
}
/** The current language code. */
export function getLang() { return current; }
/**
* Switch the language (unknown codes fall back to the default). Listeners run when it actually changed.
* @param {unknown} lang
* @returns {boolean} true when the language changed
*/
export function setLang(lang) {
const next = normalizeLang(lang) || DEFAULT_LANG;
if (next === current) return false;
const prev = current;
current = next;
for (const fn of [...listeners]) {
try { fn(next, prev); } catch (err) { /** @type {any} */ (globalThis).console?.error('[i18n] listener failed', err); }
}
return true;
}
/**
* Subscribe to language changes.
* @param {(lang: string, prev: string) => void} fn
* @returns {() => void} unsubscribe
*/
export function onLangChange(fn) {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
/**
* Add translations for a language (merged into what is there). Keys starting with '_' are metadata and skipped; only
* non-empty string values count.
* @param {string} lang
* @param {Record<string, unknown> | null | undefined} dict msgid → translation
* @returns {number} entries added
*/
export function addMessages(lang, dict) {
const l = normalizeLang(lang);
if (!l || !dict || typeof dict !== 'object') return 0;
let cat = catalogs.get(l);
if (!cat) { cat = new Map(); catalogs.set(l, cat); }
let n = 0;
for (const [k, v] of Object.entries(dict)) {
if (k.startsWith('_') || typeof v !== 'string' || !v) continue;
cat.set(k, v);
n++;
}
return n;
}
/**
* Replace a language's translations.
* @param {string} lang
* @param {Record<string, unknown> | null | undefined} dict
* @returns {number}
*/
export function setMessages(lang, dict) {
const l = normalizeLang(lang);
if (!l) return 0;
catalogs.delete(l);
return addMessages(l, dict);
}
/**
* Whether a msgid has a translation in a language (the default language always "has" its own msgids).
* @param {string} msgid
* @param {string} [lang]
*/
export function hasMessage(msgid, lang = current) {
const l = normalizeLang(lang) || DEFAULT_LANG;
return l === DEFAULT_LANG || !!catalogs.get(l)?.has(String(msgid));
}
/**
* Install the translator of game-data names (`{ dn: '…' }` params and `tName`). The client maps a Chinese name to the
* current language through the localized game data; without a resolver names stay Chinese.
* @param {((name: string, lang: string) => string) | null} fn
*/
export function setNameResolver(fn) { nameResolver = typeof fn === 'function' ? fn : null; }
/**
* A game-data name (operator, item, bond …) in the current language.
* @param {unknown} name the Chinese name from the data
* @param {string} [lang]
* @returns {string}
*/
export function tName(name, lang = current) {
const s = name == null ? '' : String(name);
if (!s || lang === DEFAULT_LANG || !nameResolver) return s;
try { return nameResolver(s, lang) || s; } catch { return s; }
}
/**
* Mark a param as a game-data name (translated by the client's name resolver).
* @param {unknown} name
* @returns {{ dn: string }}
*/
export const dn = (name) => ({ dn: name == null ? '' : String(name) });
const own = (o, k) => o != null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
/**
* Render one param value.
* @param {unknown} v
* @param {string} lang
* @param {((name: string, lang: string) => string) | null} resolve
* @returns {string}
*/
function renderValue(v, lang, resolve) {
if (v == null) return '';
if (Array.isArray(v)) return v.map((x) => renderValue(x, lang, resolve)).filter((s) => s !== '').join(LIST_SEP[lang] || LIST_SEP.en);
if (typeof v === 'object') {
if (own(v, 'dn')) {
const name = String(/** @type {any} */ (v).dn ?? '');
if (!resolve || lang === DEFAULT_LANG || !name) return name;
try { return resolve(name, lang) || name; } catch { return name; }
}
return '';
}
return String(v);
}
const PLACEHOLDER = /\{([A-Za-z0-9_$]+)(?:\|([^{}|]*)\|([^{}|]*))?\}/g;
/**
* Fill a template's placeholders (pure: no global language state).
* @param {unknown} template
* @param {Record<string, unknown> | unknown[] | null | undefined} [params]
* @param {{ lang?: string, resolveName?: ((name: string, lang: string) => string) | null }} [opts]
* @returns {string}
*/
export function format(template, params, opts = {}) {
const s = template == null ? '' : String(template);
if (!params || typeof params !== 'object' || !s.includes('{')) return s;
const lang = normalizeLang(opts.lang) || DEFAULT_LANG;
const resolve = opts.resolveName || null;
return s.replace(PLACEHOLDER, (m, key, one, other) => {
if (!own(params, key)) return m;
const v = /** @type {any} */ (params)[key];
if (one !== undefined) return Number(v) === 1 ? one : other;
return renderValue(v, lang, resolve);
});
}
/**
* Translate a message into the current language (gettext style: the msgid is the Chinese text).
* @param {unknown} msgid
* @param {Record<string, unknown> | unknown[] | null} [params]
* @returns {string}
*/
export function t(msgid, params) {
const id = msgid == null ? '' : String(msgid);
let text = id;
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(id);
if (tr) text = tr;
}
return params ? format(text, params, { lang: current, resolveName: nameResolver }) : text;
}
/**
* Translate a message with a context (see the header): the key `${context}::${msgid}` first, then the msgid alone.
* @param {string} context
* @param {unknown} msgid
* @param {Record<string, unknown> | unknown[] | null} [params]
* @returns {string}
*/
export function tc(context, msgid, params) {
const id = msgid == null ? '' : String(msgid);
if (current !== DEFAULT_LANG) {
const tr = catalogs.get(current)?.get(`${context}::${id}`);
if (tr) return params ? format(tr, params, { lang: current, resolveName: nameResolver }) : tr;
}
return t(id, params);
}
/**
* Mark a string as a msgid without translating it (module-level tables: translate with t() where it is shown).
* @template {string} S
* @param {S} s
* @returns {S}
*/
export const N_ = (s) => s;
/**
* A structured message (server → client): the msgid with its params.
* @param {string} msgid
* @param {Record<string, unknown> | unknown[]} [params]
* @returns {{ msgid: string, params: Record<string, unknown> | unknown[] | undefined }}
*/
export const msg = (msgid, params) => ({ msgid: String(msgid), params });
/** @param {unknown} m @returns {m is { msgid: string, params?: any }} */
const isMsg = (m) => !!m && typeof m === 'object' && typeof (/** @type {any} */ (m).msgid) === 'string';
/**
* The Chinese text of a message (a plain string is returned as is).
* @param {unknown} m a string or msg()
* @returns {string}
*/
export function renderMessage(m) {
if (isMsg(m)) return format(m.msgid, m.params, { lang: DEFAULT_LANG });
return m == null ? '' : String(m);
}
/**
* JSON-safe copy of message params: strings, finite numbers, booleans, `{ dn }` and arrays of those (anything else is
* dropped), so a frame never carries an object the client would have to trust.
* @param {unknown} v
* @param {number} [depth]
* @returns {unknown}
*/
function cleanParam(v, depth = 0) {
if (typeof v === 'string') return v.slice(0, 200);
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'boolean') return v;
if (Array.isArray(v) && depth < 1) return v.slice(0, 32).map((x) => cleanParam(x, depth + 1));
if (v && typeof v === 'object' && own(v, 'dn')) return { dn: String(/** @type {any} */ (v).dn ?? '').slice(0, 200) };
return null;
}
/**
* The wire fields of a message for m.toast / m.ticker: `{ text }` for a plain string, `{ text, msgid, params }` for a
* msg() — `text` is the Chinese rendering (older clients show it as before).
* @param {unknown} m
* @returns {{ text: string, msgid?: string, params?: Record<string, unknown> | unknown[] }}
*/
export function wireMessage(m) {
if (!isMsg(m)) return { text: renderMessage(m) };
const out = { text: renderMessage(m), msgid: m.msgid };
const p = m.params;
if (Array.isArray(p)) return { ...out, params: p.slice(0, 16).map((x) => cleanParam(x)) };
if (p && typeof p === 'object') {
/** @type {Record<string, unknown>} */
const params = {};
for (const [k, v] of Object.entries(p).slice(0, 16)) if (/^[A-Za-z0-9_$]{1,32}$/.test(k)) params[k] = cleanParam(v);
return { ...out, params };
}
return out;
}
/**
* The text of a received m.toast / m.ticker frame in the current language: its msgid with params when present, else
* its text taken as a msgid (static server texts are their own msgid). Missing translations show the Chinese text.
* @param {{ text?: unknown, msgid?: unknown, params?: unknown } | null | undefined} frame
* @returns {string}
*/
export function translateWire(frame) {
if (!frame || typeof frame !== 'object') return '';
const params = frame.params && typeof frame.params === 'object' ? /** @type {any} */ (frame.params) : null;
if (typeof frame.msgid === 'string' && frame.msgid) return t(frame.msgid, params);
return typeof frame.text === 'string' ? t(frame.text) : '';
}
+182
View File
@@ -0,0 +1,182 @@
// shared/i18nData.js — the localized game-data overlay (data/i18n/<lang>.json): its format, written by
// tools/build-i18n.mjs and applied by public/js/data.js (also usable under Node).
//
// { version: 1, lang: 'en', meta: { … sources, coverage … },
// names: { <Chinese name>: <name> }, // every translated game-data name (ticker / toast args)
// files: { <data file>: { <record id>: RecordOverlay } } }
//
// A RecordOverlay mirrors the record of data/<file>.json: a string leaf replaces the Chinese string at the same path,
// an object recurses, an array is index-aligned (null = keep the Chinese entry). `_h` (on every record overlay) holds
// the 2-character hash (textHash2) of each Chinese string a leaf replaces, in walk order (object keys in their order,
// array entries by index, `_h` itself skipped). A record that is a plain string (config `seasonName`) has { _s, _h }. A leaf whose Chinese source no longer hashes the same — data/*.json was
// rebuilt after the overlay — is not applied: the player sees the current Chinese text rather than a stale translation.
export const OVERLAY_VERSION = 1;
/**
* 32-bit FNV-1a of a string, as 2 base-36 characters (1296 buckets): the per-leaf staleness check.
* @param {string} s
* @returns {string}
*/
export function textHash2(s) {
let h = 0x811c9dc5;
const str = String(s);
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return (h % 1296).toString(36).padStart(2, '0');
}
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
/**
* Visit every string leaf of a record overlay in walk order.
* @param {any} ov record overlay (or a sub-tree of it)
* @param {(path: (string|number)[], text: string) => void} visit
* @param {(string|number)[]} [path]
*/
export function walkOverlay(ov, visit, path = []) {
if (typeof ov === 'string') { visit(path, ov); return; }
if (Array.isArray(ov)) {
for (let i = 0; i < ov.length; i++) if (ov[i] != null) walkOverlay(ov[i], visit, [...path, i]);
return;
}
if (isObj(ov)) {
for (const k of Object.keys(ov)) {
if (path.length === 0 && k === '_h') continue;
walkOverlay(ov[k], visit, [...path, k]);
}
}
}
/**
* The value at a path of a record, or undefined.
* @param {any} base
* @param {(string|number)[]} path
*/
export function getPath(base, path) {
let cur = base;
for (const k of path) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = cur[k];
}
return cur;
}
/** A path as a lookup key (an array record's top-level indexes become object keys, so numbers and strings match). */
const pathKey = (path) => path.map(String).join('\u0000');
/**
* Build a record overlay from its leaves (the build tool): sets each `en` at its path and computes `_h` from the
* Chinese sources in the final walk order. A record that is itself an array (config `tips`, `titles` …) gets an object
* overlay keyed by index ("0", "1" …: integer keys keep index order).
* @param {{ path: (string|number)[], zh: string, en: string }[]} leaves
* @returns {any|null} null when there is no leaf
*/
export function buildRecordOverlay(leaves) {
if (!leaves.length) return null;
// a record that is a plain string (config `seasonName`): { _s, _h }
if (leaves.length === 1 && leaves[0].path.length === 0) return { _s: leaves[0].en, _h: textHash2(leaves[0].zh) };
/** @type {any} */
const root = {};
const zhAt = new Map();
for (const { path, zh, en } of leaves) {
let cur = root;
for (let i = 0; i < path.length - 1; i++) {
const k = path[i];
const nextIsIndex = typeof path[i + 1] === 'number';
if (cur[k] == null) cur[k] = nextIsIndex ? [] : {};
cur = cur[k];
}
cur[path[path.length - 1]] = en;
zhAt.set(pathKey(path), zh);
}
// sparse arrays → explicit nulls (JSON has no holes)
const fill = (v) => {
if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) v[i] = v[i] === undefined ? null : fill(v[i]); return v; }
if (isObj(v)) { for (const k of Object.keys(v)) v[k] = fill(v[k]); return v; }
return v;
};
fill(root);
let h = '';
walkOverlay(root, (path) => { h += textHash2(zhAt.get(pathKey(path)) ?? ''); });
return { ...root, _h: h };
}
/**
* Apply a record overlay to a Chinese record: a copy that shares every untouched branch (copy-on-write), with each leaf
* whose Chinese source still matches its hash replaced. The input record is never modified.
* @param {any} base
* @param {any} ov
* @returns {{ value: any, applied: number, stale: number }}
*/
export function applyRecordOverlay(base, ov) {
if (isObj(ov) && typeof ov._s === 'string') {
const ok = typeof base === 'string' && textHash2(base) === ov._h;
return { value: ok ? ov._s : base, applied: ok ? 1 : 0, stale: ok ? 0 : 1 };
}
if (!isObj(ov) || base == null || typeof base !== 'object') return { value: base, applied: 0, stale: 0 };
const hashes = typeof ov._h === 'string' ? ov._h : '';
let i = 0;
let applied = 0;
let stale = 0;
const rec = (b, o, depth) => {
if (typeof o === 'string') {
const want = hashes.slice(i * 2, i * 2 + 2);
i++;
if (typeof b === 'string' && want && textHash2(b) === want) { applied++; return o; }
stale++;
return b;
}
if (Array.isArray(o)) {
if (!Array.isArray(b)) { walkOverlay(o, () => { i++; stale++; }); return b; }
let out = b;
for (let k = 0; k < o.length; k++) {
if (o[k] == null) continue;
const nv = rec(b[k], o[k], depth + 1);
if (nv !== b[k]) { if (out === b) out = b.slice(); out[k] = nv; }
}
return out;
}
if (isObj(o)) {
if (b == null || typeof b !== 'object') { walkOverlay(o, () => { i++; stale++; }); return b; }
// an array record's overlay is keyed by index (see buildRecordOverlay)
const arr = Array.isArray(b);
let out = b;
for (const k of Object.keys(o)) {
if (depth === 0 && k === '_h') continue;
const at = arr ? (/^\d+$/.test(k) ? Number(k) : -1) : k;
const cur = at === -1 ? undefined : b[at];
const nv = rec(cur, o[k], depth + 1);
if (at !== -1 && nv !== cur) { if (out === b) out = arr ? b.slice() : { ...b }; out[at] = nv; }
}
return out;
}
return b;
};
const value = rec(base, ov, 0);
return { value, applied, stale };
}
/**
* Apply a file's overlay to a whole data file (the `{ id: record }` maps of data/*.json; other shapes are returned
* unchanged). Untouched records are shared with the input.
* @param {any} json the Chinese file
* @param {Record<string, any> | null | undefined} fileOverlay `files[<name>]` of the overlay
* @returns {{ value: any, applied: number, stale: number }}
*/
export function applyFileOverlay(json, fileOverlay) {
if (!isObj(json) || !isObj(fileOverlay)) return { value: json, applied: 0, stale: 0 };
let out = json;
let applied = 0;
let stale = 0;
for (const [id, ov] of Object.entries(fileOverlay)) {
if (!Object.prototype.hasOwnProperty.call(json, id)) continue;
const r = applyRecordOverlay(json[id], ov);
applied += r.applied;
stale += r.stale;
if (r.value !== json[id]) { if (out === json) out = { ...json }; out[id] = r.value; }
}
return { value: out, applied, stale };
}
+207
View File
@@ -0,0 +1,207 @@
// Localized game data: the overlay format (shared/i18nData.js), its build (tools/build-i18n.mjs → data/i18n/en.json:
// shape, freshness against data/*.json, coverage, rich text) and the client accessors (public/js/data.js setLocale).
// docs/I18N.md.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildRecordOverlay, applyRecordOverlay, applyFileOverlay, walkOverlay, getPath, textHash2 } from '../shared/i18nData.js';
import { buildOverlay, inverseFormat, shapeKey, DATA_FILES, stripRich } from '../tools/build-i18n.mjs';
import { createDataStore } from '../public/js/data.js';
import { parseRichText, richTextPlain } from '../public/js/ui/richText.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
const OVERLAY = readJson('data/i18n/en.json');
const HAN = /[一-鿿]/;
test('overlay records: build → apply round trip, copy-on-write, array and string records', () => {
const base = { name: '炎', n: 3, skills: [{ name: '甲', desc: '一' }, { name: '乙', desc: '二' }], keep: { x: '中' } };
const ov = buildRecordOverlay([
{ path: ['name'], zh: '炎', en: 'Yan' },
{ path: ['skills', 1, 'desc'], zh: '二', en: 'two' },
]);
assert.deepEqual(ov.skills, [null, { desc: 'two' }], 'arrays are index-aligned, null keeps the entry');
assert.equal(ov._h, textHash2('炎') + textHash2('二'));
const r = applyRecordOverlay(base, ov);
assert.equal(r.applied, 2);
assert.equal(r.value.name, 'Yan');
assert.equal(r.value.skills[1].desc, 'two');
assert.equal(r.value.skills[0], base.skills[0], 'untouched branches are shared');
assert.equal(r.value.keep, base.keep);
assert.equal(base.name, '炎', 'the input is never modified');
// an array record (config tips) and a plain-string record (config seasonName)
const tips = [{ tip: '甲' }, { tip: '乙' }];
const tov = buildRecordOverlay([{ path: [1, 'tip'], zh: '乙', en: 'B' }]);
assert.deepEqual(applyRecordOverlay(tips, tov).value, [{ tip: '甲' }, { tip: 'B' }]);
const sov = buildRecordOverlay([{ path: [], zh: '卫戍协议:盟约', en: 'Stronghold Protocol: Alliance' }]);
assert.equal(applyRecordOverlay('卫戍协议:盟约', sov).value, 'Stronghold Protocol: Alliance');
});
test('overlay records: a leaf whose Chinese source changed stays Chinese (no stale translation)', () => {
const ov = buildRecordOverlay([{ path: ['desc'], zh: '攻击力+15%', en: 'ATK +15%' }, { path: ['name'], zh: '维式重锤', en: 'Victorian Hammer' }]);
const r = applyRecordOverlay({ name: '维式重锤', desc: '攻击力+20%' }, ov);
assert.deepEqual(r.value, { name: 'Victorian Hammer', desc: '攻击力+20%' });
assert.equal(r.applied, 1);
assert.equal(r.stale, 1);
const f = applyFileOverlay({ a: { name: '甲' }, b: { name: '乙' } }, { a: buildRecordOverlay([{ path: ['name'], zh: '甲', en: 'A' }]), gone: { name: 'x', _h: '00' } });
assert.deepEqual(f.value, { a: { name: 'A' }, b: { name: '乙' } }, 'records missing from the data are skipped');
});
test('build-i18n matching: exact, blackboard templates, plain from raw, composites, fallback order, notes, names', () => {
const zh = [{ chars: { char_x: { name: '测试员', description: '攻击造成<@ba.kw>法术伤害</>', skill: { levels: [{ name: '强攻', description: '攻击力<@ba.vup>+{atk:0%}</>,持续{duration}秒' }] } } } }];
const en = [{ chars: { char_x: { name: 'Tester', description: 'Attacks deal <@ba.kw>Arts damage</>', skill: { levels: [{ name: 'Assault', description: 'ATK <@ba.vup>+{atk:0%}</> for {duration} seconds' }] } } } }];
const data = {
chess: {
chess_x_a: {
charId: 'char_x', name: '测试员',
trait: { desc: '攻击造成法术伤害', descRaw: '攻击造成<@ba.kw>法术伤害</>' },
skill: { skillId: 'sk_x', name: '强攻', descRaw: '攻击力<@ba.vup>+80%</>,持续30秒', desc: '攻击力+80%,持续30秒', bb: { atk: 0.8 }, duration: 30 },
spec: { note: '研究笔记' },
},
},
stages: { m1: { name: '战场#05(下半) 测试员' }, m2: { name: '战场#04 活性源石' } },
config: { seasonName: '卫戍协议:盟约', tips: [{ tip: '无官方的提示' }, { tip: '界面里的话' }] },
};
const { overlay, report } = buildOverlay({
zh, en, data,
fallback: { remake: { 活性源石: 'Active Originium', '卫戍协议:盟约': 'Stronghold Protocol: Alliance' }, pr70: { 无官方的提示: 'A tip', 界面里的话: 'PR wording' }, ui: { 界面里的话: 'UI wording' } },
});
const c = applyRecordOverlay(data.chess.chess_x_a, overlay.files.chess.chess_x_a).value;
assert.equal(c.name, 'Tester');
assert.equal(c.trait.descRaw, 'Attacks deal <@ba.kw>Arts damage</>');
assert.equal(c.trait.desc, 'Attacks deal Arts damage', 'the plain text is the stripped raw translation');
assert.equal(c.skill.descRaw, 'ATK <@ba.vup>+80%</> for 30 seconds', 'numbers read back from the Chinese and filled in');
assert.equal(c.skill.desc, 'ATK +80% for 30 seconds');
assert.equal(c.spec.note, '研究笔记', 'research notes are not translated');
const st = applyFileOverlay(data.stages, overlay.files.stages).value;
assert.equal(st.m1.name, 'Battlefield #05 (Second Half) Tester');
assert.equal(st.m2.name, 'Battlefield #04 Active Originium');
const cfg = applyFileOverlay(data.config, overlay.files.config).value;
assert.equal(cfg.seasonName, 'Stronghold Protocol: Alliance');
assert.deepEqual(cfg.tips.map((x) => x.tip), ['A tip', 'PR wording'], 'remake > PR #70 > UI table');
assert.equal(overlay.names['测试员'], 'Tester');
assert.equal(report.coverage.operators.translated, 1);
assert.equal(report.coverage.skills.template, 2);
assert.equal(report.coverage.operators.notes, 1);
assert.deepEqual(inverseFormat('攻击力+{atk:0%},{-x}格', '攻击力+80%,-2格'), { bb: { atk: 0.8, x: 2 }, bbStr: {} });
assert.equal(inverseFormat('攻击力+{atk:0%}', '攻击力+80'), null);
assert.equal(shapeKey('攻击力<@ba.vup>+{atk:0%}</>'), shapeKey('攻击力+80%'));
});
test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and the names map', () => {
assert.equal(OVERLAY.version, 1);
assert.equal(OVERLAY.lang, 'en');
assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半');
assert.ok(OVERLAY.meta.totals.pct >= 99, `coverage ${OVERLAY.meta.totals.pct} %`);
for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) {
const c = OVERLAY.meta.coverage[kind];
assert.ok(c && c.texts > 0, `coverage of ${kind}`);
assert.ok(c.pct >= 98, `${kind}: ${c.pct} %`);
}
assert.deepEqual(Object.keys(OVERLAY.files).filter((f) => !DATA_FILES.includes(f)), []);
assert.equal(OVERLAY.names['琳琅诗怀雅'], 'Swire the Elegant Wit');
assert.equal(OVERLAY.names['维式重锤'], 'Victorian Hammer');
assert.equal(OVERLAY.names['奇迹'], 'Marvel');
for (const [zh, en] of Object.entries(OVERLAY.names)) assert.ok(HAN.test(zh) && !HAN.test(en), `${zh} → ${en}`);
});
test('data/i18n/en.json matches the current data/*.json: every leaf replaces a Chinese string that still hashes the same', () => {
// A failure here means data/*.json was rebuilt after the overlay: run `node tools/build-i18n.mjs` (docs/I18N.md).
for (const [file, fov] of Object.entries(OVERLAY.files)) {
const data = readJson(`data/${file}.json`);
let leaves = 0;
for (const [id, rov] of Object.entries(fov)) {
if (typeof rov._s === 'string') { assert.equal(typeof data[id], 'string', `${file}.${id}`); leaves++; continue; }
walkOverlay(rov, (p, enText) => {
const zhText = getPath(data[id], p);
assert.equal(typeof zhText, 'string', `${file}.${id}.${p.join('.')} is not a string in data/${file}.json`);
assert.ok(HAN.test(zhText), `${file}.${id}.${p.join('.')}: not a Chinese text`);
assert.ok(enText.trim(), `${file}.${id}.${p.join('.')}: empty translation`);
leaves++;
});
}
const r = applyFileOverlay(data, fov);
assert.equal(r.stale, 0, `${file}: ${r.stale} translations are stale (run node tools/build-i18n.mjs)`);
assert.equal(r.applied, leaves, file);
}
});
test('official EN skill texts keep the rich-text markup and the remake\'s numbers (ui/richText.js renders them)', () => {
const chess = readJson('data/chess.json');
const cases = [['chess_char_3_04_a', 1, '155%'], ['chess_char_3_04_b', 1, '170%'], ['chess_char_1_01_a', 1, '+80%']];
for (const [id, idx, number] of cases) {
const zhRec = chess[id];
const en = applyRecordOverlay(zhRec, OVERLAY.files.chess[id]).value;
const zhSkill = zhRec.skills[idx];
const enSkill = en.skills[idx];
assert.ok(!HAN.test(enSkill.descRaw), `${id}: ${enSkill.descRaw}`);
const vup = (s) => parseRichText(s).filter((x) => x.cls && x.cls.includes('ba.vup')).map((x) => x.text);
assert.ok(vup(zhSkill.descRaw).includes(number) && vup(enSkill.descRaw).includes(number), `${id}: ${number} highlighted in both languages`);
assert.equal(richTextPlain(enSkill.descRaw), enSkill.desc, 'desc is the plain form of descRaw');
assert.equal((enSkill.descRaw.match(/<[@$][^<>]*>/g) || []).length, (enSkill.descRaw.match(/<\/>/g) || []).length, 'tags closed');
}
// every translated skill: it parses, no placeholder is left, plain = stripped raw (a few official EN texts leave a
// tag unclosed, e.g. skchr_excu2_1's <@ba.rem>; the parser takes that as running to the end, as the game does)
for (const [id, rov] of Object.entries(OVERLAY.files.chess)) {
const rec = applyRecordOverlay(chess[id], rov).value;
for (const s of rec.skills || []) {
if (HAN.test(s.descRaw)) continue;
assert.ok(parseRichText(s.descRaw).length > 0, `${id} ${s.skillId}`);
assert.ok(!/\{-?[A-Za-z_@][^{}:]*(?::[^{}]+)?\}/.test(s.descRaw), `${id} ${s.skillId}: placeholder left: ${s.descRaw}`);
assert.equal(stripRich(s.descRaw), s.desc, `${id} ${s.skillId}`);
}
}
});
/** A fetch over the repo's data/ (data/i18n/en.json included). */
function diskFetch(calls) {
return async (url) => {
calls.push(url);
const rel = url.replace(/^\/data\//, 'data/');
const abs = path.join(ROOT, rel);
if (!existsSync(abs)) return { ok: false, status: 404, json: async () => null };
const text = readFileSync(abs, 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(text) };
};
}
test('data.js setLocale: getters return English records after the overlay loads, Chinese again after zh', async () => {
const calls = [];
const d = createDataStore({ fetch: diskFetch(calls), retryDelays: [] });
await d.loadAll('chess', 'config', 'bonds');
const seen = [];
d.subscribe((n) => seen.push(n));
assert.equal(d.lookup('chess', 'chess_char_3_04_a').name, '琳琅诗怀雅');
assert.equal(await d.setLocale('en'), 'en');
assert.equal(calls.filter((u) => u === '/data/i18n/en.json').length, 1);
assert.deepEqual(seen.sort(), ['bonds', 'chess', 'config'], 'every loaded file notified');
const swire = d.lookup('chess', 'chess_char_3_04_a');
assert.equal(swire.name, 'Swire the Elegant Wit');
assert.equal(swire.subProfessionName, 'Merchant');
assert.equal(swire.stats.atk, 672, 'numbers untouched');
assert.equal(d.list('bonds').find((b) => b.bondId === 'yanShip').name, 'Yan');
assert.equal(d.get('config').modes.mode_multi_hard.name, 'Dire Simulation');
assert.equal(d.getRaw('config').modes.mode_multi_hard.name, '绝境模拟', 'getRaw keeps the Chinese file');
assert.equal(d.localeName('琳琅诗怀雅'), 'Swire the Elegant Wit');
assert.equal(d.localeName('不存在的名字'), '不存在的名字');
await d.load('items'); // loaded after the switch: localized too
assert.equal(d.lookup('items', 'chess_item_1_01_e_a').name, 'Victorian Hammer');
assert.equal(await d.setLocale('zh'), 'zh');
assert.equal(d.lookup('chess', 'chess_char_3_04_a').name, '琳琅诗怀雅');
assert.equal(d.localeName('琳琅诗怀雅'), '琳琅诗怀雅');
await d.setLocale('en');
assert.equal(calls.filter((u) => u === '/data/i18n/en.json').length, 1, 'the overlay downloads once');
});
test('data.js setLocale: without an overlay the texts stay Chinese (no throw, no switch)', async () => {
const d = createDataStore({ fetch: async () => ({ ok: false, status: 404, json: async () => null }), retryDelays: [] });
const w = console.warn;
console.warn = () => {};
try {
assert.equal(await d.setLocale('en'), 'zh');
assert.equal(d.locale(), 'zh');
} finally { console.warn = w; }
});
+717
View File
@@ -0,0 +1,717 @@
#!/usr/bin/env node
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data.
//
// The game texts of data/*.json (operators, skills, talents, modules, traits, enemies, bonds, items, effects, bands,
// 特质, 机变 cards, modes, stages, tokens …) are the official zh_CN texts (tools/build-data.mjs). This tool writes their
// official English counterparts as an overlay keyed by the remake's own ids and field paths (format: shared/i18nData.js),
// which public/js/data.js applies when the player switches to English. tools/build-data.mjs is not touched.
//
// How a text is matched (no id table to keep up to date — the official tables are walked side by side):
// 1. the zh_CN and the EN tables are walked in parallel (same keys; arrays of keyed records by their key), giving
// pairs Chinese source → English source with the ids on their path as "scope";
// 2. a data text equal to a Chinese source takes its English source (exact);
// 3. a data text whose blackboard placeholders were resolved by build-data ({atk:0%} → 80%) is matched against the
// Chinese templates of the same shape: the numbers are read back into a blackboard, the Chinese template must
// reproduce the text exactly, and the English template is filled with the same values (template);
// 4. a few remake-made composites (stage names) are assembled from translated parts (composite);
// 5. what the official EN data lacks falls back to tools/i18n/fallback-remake.json (the remake's own strings),
// tools/i18n/fallback-pr70.json (PR #70's translations by @YuriRestia, reused with credit) and the UI table
// public/i18n/en.json (a data text that is also a UI msgid); anything else stays Chinese.
// Ties between several English sources prefer the one whose path shares the record's ids, then the most frequent.
// Rich-text tags and placeholders of the official EN texts are the same as in zh_CN, so the client's rich-text
// formatting (ui/richText.js) works unchanged; `desc` / `text` (plain) are the stripped `descRaw` / `textRaw`.
//
// Usage: node tools/build-i18n.mjs [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>]
// [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]
// --source EN tables: 'assets' (default) = ArknightsAssets/ArknightsGamedata en (current, has the 盟约 seasons
// act1autochess / act2autochess), 'yostar' = Kengxxiao/ArknightsGameData_YoStar en_US (archived in
// 2025-11: no 盟约 season, only the 2025 test event act1vautochess)
// --refresh re-download the EN tables; --offline never download (fail when a table is missing)
// --cache zh_CN cache (default <repo>/.cache/gamedata, shared with build-data; missing tables are downloaded)
// --cache-en EN cache (default <repo>/.cache/gamedata-en for 'assets', .cache/gamedata-en-yostar for 'yostar')
// --data the data/*.json to localize (default <repo>/data); --out default <repo>/data/i18n/en.json
// --report default <repo>/.cache/build-i18n-report.json (coverage per kind, untranslated samples)
// --check build in memory and exit 1 when the output differs from --out (nothing written)
// Determinism: the output depends only on the inputs (stable key order, no timestamps).
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildRecordOverlay, applyFileOverlay, OVERLAY_VERSION } from '../shared/i18nData.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const USAGE = 'usage: node tools/build-i18n.mjs [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]';
export const EN_SOURCES = Object.freeze({
assets: {
label: 'ArknightsAssets/ArknightsGamedata (en)',
home: 'https://github.com/ArknightsAssets/ArknightsGamedata',
url: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsGamedata/master/en/gamedata/',
cache: '.cache/gamedata-en',
},
yostar: {
label: 'Kengxxiao/ArknightsGameData_YoStar (en_US)',
home: 'https://github.com/Kengxxiao/ArknightsGameData_YoStar',
url: 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData_YoStar/main/en_US/gamedata/',
cache: '.cache/gamedata-en-yostar',
},
});
const ZH_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
/** Official tables walked side by side (zh_CN ↔ EN), with the sub-trees that hold texts the remake uses. */
const TABLES = [
{ rel: 'excel/activity_table.json', pick: (j) => ({ act2: j?.activity?.AUTOCHESS_SEASON?.act2autochess, act1: j?.activity?.AUTOCHESS_SEASON?.act1autochess, ac: j?.autoChessData, info: { act2autochess: j?.basicInfo?.act2autochess, act1autochess: j?.basicInfo?.act1autochess } }) },
{ rel: 'excel/character_table.json' },
{ rel: 'excel/skill_table.json' },
{ rel: 'excel/uniequip_table.json' },
{ rel: 'excel/battle_equip_table.json' },
{ rel: 'excel/enemy_handbook_table.json', pick: (j) => j?.enemyData },
{ rel: 'excel/item_table.json', pick: (j) => j?.items },
{ rel: 'excel/token_table.json' },
{ rel: 'levels/enemydata/enemy_database.json', pick: (j) => (Array.isArray(j?.enemies) ? kvMap(j.enemies) : j) },
];
/** A Unity-style list of { Key, Value } entries as a plain map (the two dumps serialize some tables either way). */
function kvMap(list) {
const out = {};
for (const e of list) if (e && (typeof e.Key === 'string' || typeof e.key === 'string')) out[e.Key ?? e.key] = e.Value ?? e.value;
return out;
}
const isKvList = (v) => Array.isArray(v) && v.length > 0 && v.every((e) => e && typeof e === 'object' && typeof e.Key === 'string' && 'Value' in e && Object.keys(e).length === 2);
/** Scope segment of the act2autochess sub-tree (TABLES pick key): the season data/*.json is built from. */
const SEASON_SCOPE = 'act2';
/** data/*.json files localized, in output order. */
export const DATA_FILES = Object.freeze(['chess', 'backups', 'tokens', 'bonds', 'garrisons', 'items', 'bands', 'effects',
'choices', 'enemies', 'factions', 'stages', 'bosses', 'config', 'emotes']);
/**
* Research / developer notes of the data files (English with Chinese terms; no screen shows them): never translated,
* counted apart. A leaf is a note when one of these keys is on its path, or its own key is in NOTE_LEAF_KEYS.
*/
const NOTE_KEYS = new Set(['spec', 'implFormula', 'assumed', 'notes', 'formula', 'helperOrder', '_why', '_doc', 'algorithm', 'titleRule']);
const NOTE_LEAF_KEYS = new Set(['cards', 'rule', 'note_dev']);
/** Fields whose translation also feeds the `names` map (server ticker / toast args are data names). */
const NAME_KEYS = new Set(['name', 'effectName']);
/** File priority when two records give the same Chinese name different English names. */
const NAME_PRIORITY = ['chess', 'backups', 'tokens', 'items', 'bonds', 'bands', 'effects', 'garrisons', 'enemies', 'bosses', 'stages', 'choices', 'config', 'factions', 'emotes'];
const CJK = /[\u3400-\u9fff\uf900-\ufaff]/;
/** Official rich-text markup (a styled / term tag or its close). */
const RICH = /<[@$#][^<>]*>|<\/>/;
const hasCjk = (s) => typeof s === 'string' && CJK.test(s);
// ===== text helpers (kept identical to tools/build-data.mjs: the zh round trip in the report proves it) ============
export function unescapeNewlines(s) { return typeof s === 'string' ? s.replace(/\\n/g, '\n') : s; }
export function stripRich(s) {
if (typeof s !== 'string') return s ?? null;
return unescapeNewlines(s)
.replace(/<[@$#][^<>]*>/g, '')
.replace(/<\/>/g, '')
.replace(/<\/?color[^<>]*>/gi, '')
.replace(/<\/?[bi]>/gi, '');
}
function cleanNum(v) {
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
if (Number.isInteger(v) || Math.abs(v) >= 1e6) return v;
return Math.round(v * 1e6) / 1e6;
}
export function formatValue(v, fmt) {
if (typeof v !== 'number' || !Number.isFinite(v)) return String(v);
if (!fmt) return String(cleanNum(v));
const pct = fmt.endsWith('%');
const core = pct ? fmt.slice(0, -1) : fmt;
const decimals = core.includes('.') ? core.split('.')[1].length : 0;
const x = pct ? v * 100 : v;
const rounded = Math.sign(x) * Math.round(Math.abs(x) * 10 ** decimals + 1e-9) / 10 ** decimals;
return rounded.toFixed(decimals) + (pct ? '%' : '');
}
const PH = /\{(-?)([^{}:]+)(?::([^{}]+))?\}/g;
/** Resolve {key}, {-key}, {key:0%} against a blackboard (case-insensitive); unknown keys stay verbatim. */
export function resolvePlaceholders(text, bb, bbStr = {}) {
if (typeof text !== 'string') return text ?? null;
const lower = new Map();
for (const [k, v] of Object.entries(bb || {})) lower.set(k.toLowerCase(), v);
const lowerStr = new Map();
for (const [k, v] of Object.entries(bbStr || {})) lowerStr.set(k.toLowerCase(), v);
return text.replace(PH, (m, neg, key, fmt) => {
const k = key.trim().toLowerCase();
if (lowerStr.has(k) && (!lower.has(k) || !fmt)) return lowerStr.get(k);
if (lower.has(k)) return formatValue(neg ? -lower.get(k) : lower.get(k), fmt);
return m;
});
}
const hasPlaceholder = (s) => typeof s === 'string' && /\{-?[A-Za-z_@][^{}:]*(?::[^{}]+)?\}/.test(s);
// ===== CLI & IO ======================================================================================================
function parseArgs(argv) {
const opts = {
refresh: false, offline: false, quiet: false, check: false, source: 'assets',
cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'),
out: join(ROOT, 'data', 'i18n', 'en.json'), report: join(ROOT, '.cache', 'build-i18n-report.json'),
};
const flags = { '--refresh': 'refresh', '--offline': 'offline', '--quiet': 'quiet', '--check': 'check' };
const vals = { '--source': 'source', '--cache': 'cache', '--cache-en': 'cacheEn', '--data': 'data', '--out': 'out', '--report': 'report' };
for (let i = 0; i < argv.length; i++) {
const eq = argv[i].indexOf('=');
const [name, inline] = eq > 0 ? [argv[i].slice(0, eq), argv[i].slice(eq + 1)] : [argv[i], null];
if (name === '--help' || name === '-h') { console.log(USAGE); process.exit(0); }
if (flags[name] && inline === null) { opts[flags[name]] = true; continue; }
if (vals[name]) {
const v = inline ?? argv[++i];
if (!v || v.startsWith('--')) throw new Error(`${name} needs a value\n${USAGE}`);
opts[vals[name]] = name === '--source' ? v : resolve(v);
continue;
}
throw new Error(`unknown option ${argv[i]}\n${USAGE}`);
}
if (opts.refresh && opts.offline) throw new Error(`--refresh and --offline are mutually exclusive\n${USAGE}`);
if (!EN_SOURCES[opts.source]) throw new Error(`--source must be one of ${Object.keys(EN_SOURCES).join(', ')}`);
if (!opts.cacheEn) opts.cacheEn = join(ROOT, EN_SOURCES[opts.source].cache);
return opts;
}
async function download(url, abs, log) {
let lastErr;
for (let attempt = 1; attempt <= 4; attempt++) {
try {
log(` download ${url}${attempt > 1 ? ` (attempt ${attempt})` : ''}`);
const res = await fetch(url, { signal: AbortSignal.timeout(240_000) });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
const text = await res.text();
JSON.parse(text); // never cache a truncated file
await mkdir(dirname(abs), { recursive: true });
const tmp = `${abs}.tmp-${process.pid}`;
await writeFile(tmp, text);
await rename(tmp, abs);
return;
} catch (e) {
lastErr = e;
if (attempt < 4) await new Promise((r) => setTimeout(r, 600 * attempt));
}
}
throw new Error(`cannot download ${url}: ${lastErr && lastErr.message}`);
}
async function loadTable(dir, base, rel, { refresh, offline, log }) {
const abs = join(dir, rel);
if (refresh || !existsSync(abs)) {
if (offline) throw new Error(`missing cached table ${abs} (offline mode)`);
await download(base + rel, abs, log);
}
return JSON.parse(await readFile(abs, 'utf8'));
}
// ===== the parallel walk: Chinese source → English source pairs ======================================================
const KEY_FIELDS = ['Key', 'key', 'id', 'charId', 'skillId', 'uniEquipId', 'enemyId', 'itemId', 'effectId', 'bondId', 'bandId', 'stageId', 'modeId', 'bossId', 'talentIndex'];
/** The field an array of records is keyed by (every element an object with a distinct string/number value), or null. */
function arrayKeyField(arr) {
if (!Array.isArray(arr) || arr.length < 2 || !arr.every((x) => x && typeof x === 'object' && !Array.isArray(x))) return null;
for (const f of KEY_FIELDS) {
const vals = arr.map((x) => x[f]);
if (vals.every((v) => typeof v === 'string' || typeof v === 'number') && new Set(vals).size === vals.length) return f;
}
return null;
}
/** Path segments that identify game objects (they contain '_' or a digit): the scope of a pair. */
const isScopeSeg = (s) => typeof s === 'string' && /[_\d]/.test(s) && s.length <= 64;
/** A dictionary of records (≥ 6 keys, every value an object): its keys are ids even without '_' ('longrange'). */
const isDict = (o) => {
const vals = Object.values(o);
return vals.length >= 6 && vals.every((v) => v && typeof v === 'object');
};
/**
* Pair index: Chinese source text (newlines unescaped) → English variants with counts and scopes; plus a template
* index (shape key → [{ zh, en, scope }]) for texts with placeholders.
*/
class PairIndex {
constructor() {
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
this.exact = new Map();
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
this.plain = new Map();
/** @type {Map<string, Map<string, { zh: string, en: string, n: number, scopes: Set<string> }>>} */
this.templates = new Map();
this.pairs = 0;
}
static add(map, zh, en, scope) {
let m = map.get(zh);
if (!m) { m = new Map(); map.set(zh, m); }
let e = m.get(en);
if (!e) { e = { n: 0, scopes: new Set() }; m.set(en, e); }
e.n++;
for (const s of scope) if (e.scopes.size < 48) e.scopes.add(s);
}
add(zhRaw, enRaw, scope) {
const zh = unescapeNewlines(zhRaw);
const en = unescapeNewlines(enRaw);
if (!en.trim() || hasCjk(en)) return;
this.pairs++;
PairIndex.add(this.exact, zh, en, scope);
// build-data trims some texts (a tip ends with '\\n' in the table, not in data/config.json)
if (zh.trim() !== zh) PairIndex.add(this.exact, zh.trim(), en.trim(), scope);
const zp = stripRich(zh);
if (zp !== zh) PairIndex.add(this.plain, zp, stripRich(en), scope);
if (hasPlaceholder(zh)) {
const key = shapeKey(zh);
let m = this.templates.get(key);
if (!m) { m = new Map(); this.templates.set(key, m); }
const id = `${zh}\u0000${en}`;
let e = m.get(id);
if (!e) { e = { zh, en, n: 0, scopes: new Set() }; m.set(id, e); }
e.n++;
for (const s of scope) if (e.scopes.size < 48) e.scopes.add(s);
}
}
/** Walk a zh_CN tree and its EN counterpart together. */
walk(zh, en, scope = []) {
if (isKvList(zh)) zh = kvMap(zh);
if (isKvList(en)) en = kvMap(en);
if (typeof zh === 'string') {
if (typeof en === 'string' && hasCjk(zh)) this.add(zh, en, scope);
return;
}
if (Array.isArray(zh)) {
if (!Array.isArray(en)) return;
const kf = arrayKeyField(zh);
if (kf && arrayKeyField(en) === kf) {
const byKey = new Map(en.map((x) => [x[kf], x]));
for (const x of zh) {
const y = byKey.get(x[kf]);
if (y !== undefined) this.walk(x, y, isScopeSeg(String(x[kf])) ? [...scope, String(x[kf])] : scope);
}
return;
}
if (zh.length !== en.length) return; // unkeyed arrays of different length cannot be aligned safely
for (let i = 0; i < zh.length; i++) this.walk(zh[i], en[i], scope);
return;
}
if (zh && typeof zh === 'object') {
if (!en || typeof en !== 'object' || Array.isArray(en)) return;
const dict = isDict(zh);
for (const k of Object.keys(zh)) {
if (!Object.prototype.hasOwnProperty.call(en, k)) continue;
this.walk(zh[k], en[k], dict || isScopeSeg(k) ? [...scope, k] : scope);
}
}
}
}
/** Shape of a text for template lookup: markup stripped, placeholders and numbers folded into one marker. */
export function shapeKey(s) {
return stripRich(s)
.replace(/\{-?[^{}:]+(?::[^{}]+)?\}/g, '\u0001')
.replace(/[+\-]?\d+(?:\.\d+)?%?/g, '\u0001')
.replace(/[+\-]?\u0001%?/g, '\u0001')
.replace(/\u0001+/g, '\u0001');
}
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Read a resolved text back into the blackboard values of a template: { bb, bbStr } that make
* resolvePlaceholders(template) reproduce `text` exactly, or null.
* @param {string} template
* @param {string} text
*/
export function inverseFormat(template, text) {
const parts = [];
let last = 0;
let re = '^';
for (const m of template.matchAll(PH)) {
re += escapeRe(template.slice(last, m.index)) + '([\\s\\S]*?)';
parts.push({ neg: m[1] === '-', key: m[2].trim(), fmt: m[3] || null });
last = m.index + m[0].length;
}
if (!parts.length) return null;
re += `${escapeRe(template.slice(last))}$`;
let mm;
try { mm = new RegExp(re).exec(text); } catch { return null; }
if (!mm) return null;
const bb = {};
const bbStr = {};
for (let i = 0; i < parts.length; i++) {
const { neg, key, fmt } = parts[i];
const cap = mm[i + 1];
let v;
if (fmt) {
const pct = fmt.endsWith('%');
const num = pct ? cap.match(/^(-?\d+(?:\.\d+)?)%$/) : cap.match(/^(-?\d+(?:\.\d+)?)$/);
if (!num) return null;
v = cleanNum(Number(num[1]) / (pct ? 100 : 1));
} else if (/^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(cap)) {
v = Number(cap);
} else {
if (neg) return null;
bbStr[key] = cap;
continue;
}
if (neg) v = -v;
v = cleanNum(v);
if (Object.prototype.hasOwnProperty.call(bb, key) && bb[key] !== v) return null;
bb[key] = v;
}
if (resolvePlaceholders(template, bb, bbStr) !== text) return null;
return { bb, bbStr };
}
// ===== translation of one data text ==================================================================================
const scoreScopes = (scopes, ctx) => {
let s = 0;
for (const id of ctx.near) if (scopes.has(id)) s += 4;
for (const id of ctx.record) if (scopes.has(id)) s += 1;
return s;
};
/** Best English variant of a set: scope match first, then frequency, then text order (deterministic). */
function bestVariant(variants, ctx) {
let best = null;
for (const [en, e] of variants) {
const cand = { en, score: scoreScopes(e.scopes, ctx), n: e.n };
if (!best || cand.score > best.score || (cand.score === best.score && (cand.n > best.n || (cand.n === best.n && cand.en < best.en)))) best = cand;
}
return best;
}
/**
* Translator over the pair index and the fallback dictionary.
*/
class Translator {
/**
* @param {PairIndex} index
* @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference
*/
constructor(index, fallbacks) {
this.index = index;
this.fallbacks = fallbacks;
}
/**
* @param {string} zh the data text
* @param {{ near: Set<string>, record: Set<string>, bb?: object|null, bbStr?: object|null }} ctx
* @returns {{ en: string, how: string } | null}
*/
translate(zh, ctx) {
const ex = this.index.exact.get(zh);
if (ex) return { en: bestVariant(ex, ctx).en, how: 'exact' };
// a plain text (build-data strips the markup of `desc`, `text`, card / device descriptions) against stripped sources
if (!RICH.test(zh)) {
const pl = this.index.plain.get(zh);
if (pl) return { en: bestVariant(pl, ctx).en, how: 'exact' };
}
const tpl = this.template(zh, ctx);
if (tpl) return tpl;
const comp = this.composite(zh, ctx);
if (comp) return comp;
for (const { name, map } of this.fallbacks) {
const fb = map.get(zh);
if (fb) return { en: fb, how: name };
}
return null;
}
template(zh, ctx) {
const cands = this.index.templates.get(shapeKey(zh));
if (!cands) return null;
let best = null;
const plain = !RICH.test(zh);
for (const c of cands.values()) {
const zhT = plain ? stripRich(c.zh) : c.zh;
const inv = inverseFormat(zhT, zh);
if (!inv) continue;
const enT = plain ? stripRich(c.en) : c.en;
const bb = { ...(ctx.bb || {}), ...inv.bb };
const bbStr = { ...(ctx.bbStr || {}), ...inv.bbStr };
const en = resolvePlaceholders(enT, bb, bbStr);
if (hasPlaceholder(en) && !hasPlaceholder(zh)) continue; // the English template needs a value the text lacks
const cand = { en, score: scoreScopes(c.scopes, ctx), n: c.n };
if (!best || cand.score > best.score || (cand.score === best.score && (cand.n > best.n || (cand.n === best.n && cand.en < best.en)))) best = cand;
}
return best ? { en: best.en, how: 'template' } : null;
}
/** Remake-made composites: stage names "战场#05(下半) 源石流发生装置". */
composite(zh, ctx) {
const m = zh.match(/^战场#(\d+)(?:\((上半|下半)\))?(?:\s+(.+))?$/);
if (!m) return null;
let en = `Battlefield #${m[1]}`;
if (m[2]) en += m[2] === '上半' ? ' (First Half)' : ' (Second Half)';
if (m[3]) {
const parts = m[3].split('/').map((p) => this.translate(p.trim(), ctx));
if (parts.some((p) => !p)) return null;
en += ` ${parts.map((p) => p.en).join(' / ')}`;
}
return { en, how: 'composite' };
}
}
// ===== walking the data files ========================================================================================
const ID_VALUE = /^[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+$/;
/** Id-like string values of an object (an `…Id` / `id` / `key` field, or a value shaped like an id), `depth` levels down. */
function idsOf(obj, depth, out = new Set()) {
if (!obj || typeof obj !== 'object' || depth < 0) return out;
for (const [k, v] of Array.isArray(obj) ? obj.entries() : Object.entries(obj)) {
if (typeof v === 'string') {
if (v.length <= 64 && (ID_VALUE.test(v) || (typeof k === 'string' && /(?:^id|Id|^key)$/.test(k) && /^[A-Za-z][\w-]*$/.test(v)))) out.add(v);
} else if (v && typeof v === 'object') idsOf(v, depth - 1, out);
}
return out;
}
/** Kind of a leaf for the coverage report. */
function kindOf(file, path) {
if (file === 'chess' || file === 'backups') {
const keys = path.filter((k) => typeof k === 'string');
if (keys.some((k) => k === 'talentChanges' || k === 'talents' || k === 'talentsBase')) return 'talents';
if (keys.some((k) => k === 'modules' || k === 'module' || k === 'moduleNames')) return 'modules';
if (keys.some((k) => k === 'skills' || k === 'skill')) return 'skills';
if (keys.some((k) => k === 'trait' || k === 'traitBase')) return 'traits';
return 'operators';
}
return file;
}
const isNotePath = (path) => path.some((k) => typeof k === 'string' && NOTE_KEYS.has(k))
|| (typeof path[path.length - 1] === 'string' && NOTE_LEAF_KEYS.has(path[path.length - 1]));
/**
* Translate every Chinese text of one record. Raw/plain pairs (descRaw/desc, textRaw/text, moduleDescRaw/moduleDesc,
* effectDescRaw/effectDesc): the raw text is translated, the plain one is its stripped translation when it is the
* stripped Chinese raw text (as build-data writes it).
*/
function translateRecord(file, id, record, tr, stats, samples) {
const leaves = [];
const recordIds = idsOf(record, 3);
recordIds.add(id);
// data/*.json is the 下半 season (act2autochess): its wording wins a tie with the 上半 one (the `act2` sub-tree)
recordIds.add(SEASON_SCOPE);
const visit = (node, path, nearIds) => {
if (!node || typeof node !== 'object') return;
const arr = Array.isArray(node);
const near = arr ? nearIds : new Set([...nearIds, ...idsOf(node, 0)]);
const done = new Map();
const keys = arr ? node.map((_, i) => i) : Object.keys(node);
const ordered = arr ? keys : [...keys.filter((k) => k.endsWith('Raw')), ...keys.filter((k) => !k.endsWith('Raw'))];
for (const k of ordered) {
const v = node[k];
const p = [...path, k];
if (typeof v !== 'string') { if (v && typeof v === 'object') visit(v, p, near); continue; }
if (!hasCjk(v)) continue;
const kind = kindOf(file, p);
if (isNotePath(p)) { stats.note(kind); continue; }
let res = null;
const rawKey = `${k}Raw`;
if (!arr && !k.endsWith('Raw') && typeof node[rawKey] === 'string' && done.has(rawKey) && stripRich(node[rawKey]) === v) {
const r = done.get(rawKey);
res = { en: stripRich(r.en), how: r.how };
}
if (!res) {
const bb = arr ? null : node.bb && typeof node.bb === 'object' ? { duration: node.duration, ...node.bb } : (Number.isFinite(node.duration) ? { duration: node.duration } : null);
res = tr.translate(v, { near, record: recordIds, bb, bbStr: arr ? null : node.bbStr || null });
}
if (res) {
done.set(k, res);
leaves.push({ path: p, zh: v, en: res.en, kind, key: arr ? path[path.length - 1] : k });
stats.hit(kind, res.how);
} else {
stats.miss(kind, v);
const list = samples[kind] || (samples[kind] = []);
if (list.length < 25) list.push(`${file}.${id}.${p.join('.')}: ${v.slice(0, 80).replace(/\n/g, '⏎')}`);
}
}
};
visit(record, [], new Set([id]));
return leaves;
}
/** A record that is a plain string (config `seasonName`). */
function translateScalar(file, id, text, tr, stats, samples) {
if (!hasCjk(text) || NOTE_KEYS.has(id)) return [];
const kind = kindOf(file, [id]);
const res = tr.translate(text, { near: new Set([id]), record: new Set([id, SEASON_SCOPE]), bb: null, bbStr: null });
if (!res) {
stats.miss(kind, text);
(samples[kind] || (samples[kind] = [])).push(`${file}.${id}: ${text.slice(0, 80)}`);
return [];
}
stats.hit(kind, res.how);
return [{ path: [], zh: text, en: res.en, kind, key: id }];
}
class Stats {
constructor() { this.kinds = {}; this.missing = new Map(); }
row(kind) { return this.kinds[kind] || (this.kinds[kind] = { texts: 0, exact: 0, template: 0, composite: 0, remake: 0, pr70: 0, ui: 0, missing: 0, notes: 0 }); }
hit(kind, how) { const r = this.row(kind); r.texts++; r[how]++; }
miss(kind, text) {
const r = this.row(kind);
r.texts++;
r.missing++;
const m = this.missing.get(text) || { kind, n: 0 };
m.n++;
this.missing.set(text, m);
}
note(kind) { this.row(kind).notes++; }
}
// ===== main ==========================================================================================================
const dictMap = (d) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !hasCjk(v)));
/**
* Build the overlay. Exported for tests (they pass in-memory tables).
* @param {{ zh: object[], en: object[], data: Record<string, any>, fallback?: { remake?: object, pr70?: object, ui?: object }, source?: object }} input
* zh / en: the picked sub-trees of TABLES, in order
*/
export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
const index = new PairIndex();
for (let i = 0; i < zh.length; i++) index.walk(zh[i], en[i], []);
const tr = new Translator(index, [
{ name: 'remake', map: dictMap(fallback.remake) },
{ name: 'pr70', map: dictMap(fallback.pr70) },
{ name: 'ui', map: dictMap(fallback.ui) },
]);
const stats = new Stats();
const samples = {};
const files = {};
const nameVotes = new Map(); // zh → [{ en, prio }]
for (const file of DATA_FILES) {
const json = data[file];
if (!json || typeof json !== 'object' || Array.isArray(json)) continue;
const out = {};
for (const [id, record] of Object.entries(json)) {
if (!record || (typeof record !== 'object' && typeof record !== 'string')) continue;
const leaves = typeof record === 'string' ? translateScalar(file, id, record, tr, stats, samples) : translateRecord(file, id, record, tr, stats, samples);
const ov = buildRecordOverlay(leaves.map(({ path, zh: z, en: e }) => ({ path, zh: z, en: e })));
if (ov) out[id] = ov;
for (const l of leaves) {
// record-level names (and the stand-ins' operator names): what server messages name
const top = l.path.length === 1
|| (file === 'backups' && ((id === 'units' && l.path.length === 2) || (id === 'diy' && l.path[0] === 'operators' && l.path.length === 3)));
if (!top || !NAME_KEYS.has(l.key) || hasCjk(l.en)) continue;
const prio = NAME_PRIORITY.indexOf(file);
const list = nameVotes.get(l.zh) || [];
list.push({ en: l.en, prio: prio < 0 ? 99 : prio });
nameVotes.set(l.zh, list);
}
}
if (Object.keys(out).length) files[file] = out;
}
const names = {};
const nameConflicts = [];
for (const zhName of [...nameVotes.keys()].sort()) {
const votes = nameVotes.get(zhName).sort((a, b) => a.prio - b.prio || (a.en < b.en ? -1 : a.en > b.en ? 1 : 0));
names[zhName] = votes[0].en;
const distinct = [...new Set(votes.map((v) => v.en))];
if (distinct.length > 1) nameConflicts.push(`${zhName} → ${distinct.join(' | ')}`);
}
const coverage = {};
let total = 0, translated = 0, official = 0;
for (const [kind, r] of Object.entries(stats.kinds).sort()) {
const ok = r.exact + r.template + r.composite + r.remake + r.pr70 + r.ui;
coverage[kind] = { ...r, translated: ok, pct: r.texts ? Math.round((ok / r.texts) * 1000) / 10 : 100 };
total += r.texts; translated += ok; official += r.exact + r.template + r.composite;
}
const overlay = {
version: OVERLAY_VERSION,
lang: 'en',
meta: {
generator: 'tools/build-i18n.mjs',
source: source || null,
fallback: 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json',
coverage: Object.fromEntries(Object.entries(coverage).map(([k, v]) => [k, { texts: v.texts, translated: v.translated, official: v.exact + v.template + v.composite, pct: v.pct }])),
totals: { texts: total, translated, official, pct: total ? Math.round((translated / total) * 1000) / 10 : 100 },
},
names,
files,
};
const missing = [...stats.missing].sort((a, b) => (a[1].kind < b[1].kind ? -1 : a[1].kind > b[1].kind ? 1 : a[0] < b[0] ? -1 : 1))
.map(([text, m]) => ({ kind: m.kind, n: m.n, text }));
return { overlay, report: { coverage, totals: overlay.meta.totals, pairs: index.pairs, nameConflicts, untranslated: samples, missing } };
}
async function main() {
let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(`build-i18n: ${e.message}`); process.exit(2); }
const log = (...a) => { if (!opts.quiet) console.log(...a); };
const src = EN_SOURCES[opts.source];
log(`build-i18n: EN tables from ${src.label}`);
const zh = [];
const en = [];
for (const t of TABLES) {
const zj = await loadTable(opts.cache, ZH_URL, t.rel, { refresh: false, offline: opts.offline, log });
const ej = await loadTable(opts.cacheEn, src.url, t.rel, { refresh: opts.refresh, offline: opts.offline, log });
zh.push(t.pick ? t.pick(zj) : zj);
en.push(t.pick ? t.pick(ej) : ej);
}
const seasonEn = en[0]?.act2 ? 'act2autochess' : en[0]?.act1 ? 'act1autochess' : 'none (no 盟约 season in this EN build)';
let version = null;
const versionFile = join(opts.cacheEn, 'excel', 'data_version.txt');
if (!opts.offline && (opts.refresh || !existsSync(versionFile))) {
try {
const res = await fetch(`${src.url}excel/data_version.txt`, { signal: AbortSignal.timeout(30_000) });
if (res.ok) { await mkdir(dirname(versionFile), { recursive: true }); await writeFile(versionFile, await res.text()); }
} catch { /* optional */ }
}
try { version = (await readFile(versionFile, 'utf8')).trim(); } catch { /* optional */ }
const data = {};
for (const f of DATA_FILES) {
const abs = join(opts.data, `${f}.json`);
if (existsSync(abs)) data[f] = JSON.parse(await readFile(abs, 'utf8'));
}
const readDict = async (abs) => (existsSync(abs) ? JSON.parse(await readFile(abs, 'utf8')) : {});
const fallback = {
remake: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-remake.json')),
pr70: await readDict(join(ROOT, 'tools', 'i18n', 'fallback-pr70.json')),
ui: await readDict(join(ROOT, 'public', 'i18n', 'en.json')),
};
const { overlay, report } = buildOverlay({ zh, en, data, fallback, source: { id: opts.source, label: src.label, home: src.home, season: seasonEn } });
// self-check: applying the overlay keeps every record's shape (strings replace strings only)
let applied = 0;
for (const [file, fov] of Object.entries(overlay.files)) {
const r = applyFileOverlay(data[file], fov);
applied += r.applied;
if (r.stale) throw new Error(`overlay of ${file} does not apply cleanly (${r.stale} stale leaves)`);
}
const text = `${JSON.stringify(overlay)}\n`;
const rep = { ...report, source: overlay.meta.source, enDataVersion: version, applied, bytes: Buffer.byteLength(text) };
await mkdir(dirname(opts.report), { recursive: true });
await writeFile(opts.report, `${JSON.stringify(rep, null, 1)}\n`);
log(` season in the EN build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`);
log(` ${report.pairs} zh→en source pairs; coverage per kind (translated / texts):`);
for (const [kind, c] of Object.entries(report.coverage)) {
const fb = [c.remake && `remake ${c.remake}`, c.pr70 && `PR #70 ${c.pr70}`, c.ui && `UI ${c.ui}`].filter(Boolean).join(' · ');
log(` ${kind.padEnd(10)} ${String(c.translated).padStart(5)} / ${String(c.texts).padEnd(5)} ${String(c.pct).padStart(5)} % official ${c.exact + c.template + c.composite} (exact ${c.exact} · template ${c.template} · composite ${c.composite})${fb ? ` · ${fb}` : ''}${c.notes ? ` · notes skipped ${c.notes}` : ''}`);
}
log(` total ${report.totals.translated} / ${report.totals.texts} (${report.totals.pct} %), official ${report.totals.official}; ${Object.keys(overlay.names).length} names (${report.nameConflicts.length} with several English forms)`);
if (opts.check) {
const cur = existsSync(opts.out) ? await readFile(opts.out, 'utf8') : '';
if (cur !== text) { console.error(`build-i18n --check: ${relative(ROOT, opts.out)} is out of date (run node tools/build-i18n.mjs)`); process.exit(1); }
log(` ${relative(ROOT, opts.out)} is up to date`);
return;
}
await mkdir(dirname(opts.out), { recursive: true });
const tmp = `${opts.out}.tmp-${process.pid}`;
await writeFile(tmp, text);
await rename(tmp, opts.out);
log(` wrote ${relative(ROOT, opts.out)} (${(rep.bytes / 1024).toFixed(0)} KB); report ${relative(ROOT, opts.report)}`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((e) => { console.error(`build-i18n: ${e.stack || e.message}`); process.exit(1); });
}
+76
View File
@@ -0,0 +1,76 @@
{
"_about": "Fallback English for data/*.json texts the official EN tables lack (tools/build-i18n.mjs step 5). Taken from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (the owner's decision of 2026-10-05; NOTICE.md). Entries PR #70 took from ak-spa-database.pages.dev (no licence) are not included. Regenerate the list of untranslated texts with node tools/build-i18n.mjs (report: .cache/build-i18n-report.json `missing`).",
"“新车和新生活”": "\"New Car, New Life\"",
"“独属自己的一隅”": "\"A Corner of One's Own\"",
"“老朋友”": "\"Old Friend\"",
"【炎】6名成员激活时召唤的友方单位;开战时攻击力/生命值增加【炎】干员攻击力/生命值总和的30%(见 bonds.json yanShip)": "A friendly unit summoned when 6 members of [Yan] are active; at battle start, ATK/HP increased by 30% of the total ATK/HP of [Yan] Operators (see bonds.json yanShip)",
"伤心": "Sad",
"再见!": "Bye!",
"剑舞": "Sword Dance",
"博士士 1": "Doctor-tor 1",
"博士士 2": "Doctor-tor 2",
"博士士 3": "Doctor-tor 3",
"博士士 4": "Doctor-tor 4",
"博士士 5": "Doctor-tor 5",
"博士士 6": "Doctor-tor 6",
"合作愉快": "Nice teamwork",
"合作愉快!": "Nice teamwork!",
"在<@ba.kw>生息演算</>中,首次开启技能后,生命上限、攻击力、防御力提升至150%": "In <@ba.kw>Reclamation Algorithm</>, after the skill is activated for the first time, Max HP, ATK, and DEF increase to 150%",
"在<@ba.kw>集成战略</>中,自身和<替身>造成的物理和法术伤害提升40%,受到的物理和法术伤害减少40%": "In <@ba.kw>Integrated Strategies</>, Physical and Arts damage dealt by self and the <Substitute> +40%, Physical and Arts damage taken -40%",
"在生息演算中,首次开启技能后,生命上限、攻击力、防御力提升至150%": "In Reclamation Algorithm, after the skill is activated for the first time, Max HP, ATK, and DEF increase to 150%",
"在集成战略中,自身和<替身>造成的物理和法术伤害提升40%,受到的物理和法术伤害减少40%": "In Integrated Strategies, Physical and Arts damage dealt by self and the <Substitute> +40%, Physical and Arts damage taken -40%",
"害怕": "Scared",
"对不起": "Sorry",
"对不起!": "Sorry!",
"对敌方领袖造成伤害最高(仅胜利时)": "Highest damage dealt to the Enemy Leader (victory only)",
"开心": "Happy",
"归溟幽灵鲨特限证章": "Skadi the Corrupting Heart Limited Badge",
"很快就好!": "Almost done!",
"快死了": "Dying",
"思考": "Thinking",
"战场#04 活性源石": "Battlefield #04 Active Originium",
"战场#05(上半) 涨潮/特制水上平台": "Battlefield #05 (First Half) High Tide/Special Water Platform",
"战场#07(下半) 排气格栅": "Battlefield #07 (Second Half) Exhaust Grille",
"攻击力+8%,攻击被<$ba.tremble>战栗</>的目标时,无视目标33%的防御力,地面敌人首次进入自身攻击范围时,使其<$ba.tremble>战栗</>6秒": "ATK +8%; when attacking <$ba.tremble>Trembling</> targets, ignores 33% of the target's DEF; when a ground enemy first enters Attack Range, inflicts <$ba.tremble>Tremble</> on it for 6 seconds",
"攻击力+8%,攻击被战栗的目标时,无视目标33%的防御力,地面敌人首次进入自身攻击范围时,使其战栗6秒": "ATK +8%; when attacking Trembling targets, ignores 33% of the target's DEF; when a ground enemy first enters Attack Range, inflicts Tremble on it for 6 seconds",
"攻击命中生命值在80%以上的敌人时,3秒内异客对其造成的伤害提升20%;在<@ba.kw>生息演算</>中,每10秒选择场上5个目标进行雷击(密集的目标更易被选中),对范围内所有单位造成异客攻击力500%的法术伤害,并获得该范围内的视野12秒,使命中的友方单位获得2点技力": "When hitting an enemy with more than 80% HP, Passenger deals 20% more damage to it for 3 seconds; in <@ba.kw>Reclamation Algorithm</>, every 10 seconds strikes 5 targets on the field with lightning (dense targets are more likely to be chosen), dealing Arts damage equal to 500% of Passenger's ATK to all units in the area, granting vision of that area for 12 seconds, and giving hit friendly units 2 SP",
"攻击命中生命值在80%以上的敌人时,3秒内异客对其造成的伤害提升20%;在生息演算中,每10秒选择场上5个目标进行雷击(密集的目标更易被选中),对范围内所有单位造成异客攻击力500%的法术伤害,并获得该范围内的视野12秒,使命中的友方单位获得2点技力": "When hitting an enemy with more than 80% HP, Passenger deals 20% more damage to it for 3 seconds; in Reclamation Algorithm, every 10 seconds strikes 5 targets on the field with lightning (dense targets are more likely to be chosen), dealing Arts damage equal to 500% of Passenger's ATK to all units in the area, granting vision of that area for 12 seconds, and giving hit friendly units 2 SP",
"攻击时有25%的几率使目标<$ba.root>束缚</>1.5秒,首次命中敌人时提升至100%且<$ba.root>束缚</>时间提升至3秒": "Attacks have a 25% chance to <$ba.root>Bind</> the target for 1.5 seconds; the first hit on each enemy has a 100% chance and the <$ba.root>Bind</> duration is increased to 3 seconds",
"攻击时有25%的几率使目标束缚1.5秒,首次命中敌人时提升至100%且束缚时间提升至3秒": "Attacks have a 25% chance to Bind the target for 1.5 seconds; the first hit on each enemy has a 100% chance and the Bind duration is increased to 3 seconds",
"攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至110%": "When there is an injured allied Operator within Attack Range, damage dealt increases to 110%",
"攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至<@ba.kw>110%</>": "When there is an injured allied Operator within Attack Range, damage dealt increases to <@ba.kw>110%</>",
"敬礼!": "Salute!",
"新合同": "New Contract",
"晋升精锐次数最多": "Most Elite promotions",
"欢呼!": "Cheers!",
"欲雪时": "When Snow Is Coming",
"沟通小助手": "Little Communication Helper",
"没问题!": "No problem!",
"消耗资金最多": "Most Funds spent",
"激活的盟约层数总和最高": "Highest total activated Alliance Stacks",
"甄选干员": "Selected Operator",
"百炼嘉维尔特限证章": "Gavial the Invincible Limited Badge",
"目标生命值损失最少": "Least LP lost",
"策略【不稳定要素】(昆图斯)": "Strategy [Unstable Element] (Quintus)",
"米米子 1": "Mimiko 1",
"米米子 2": "Mimiko 2",
"米米子 3": "Mimiko 3",
"米米子 4": "Mimiko 4",
"米米子 5": "Mimiko 5",
"米米子 6": "Mimiko 6",
"维维美 1": "Wiwime 1",
"维维美 2": "Wiwime 2",
"维维美 3": "Wiwime 3",
"维维美 4": "Wiwime 4",
"维维美 5": "Wiwime 5",
"维维美 6": "Wiwime 6",
"表情套组:博士士": "Emote Set: Doctor-tor",
"表情套组:米米子": "Emote Set: Mimiko",
"表情套组:维维美": "Emote Set: Wiwime",
"装备配发数量最多": "Most Equipment equipped",
"谢谢": "Thanks",
"谢谢!": "Thanks!",
"造成伤害时有30%概率对敌人施加灼痕效果:攻击力-20%、38%的<$ba.magicfragile>【法术脆弱】</>,不可叠加持续8秒": "When dealing damage, 30% chance to inflict Scorch on the enemy: ATK -20% and 38% <$ba.magicfragile>[Arts Fragility]</>, non-stackable, lasts 8 seconds",
"造成伤害时有30%概率对敌人施加灼痕效果:攻击力-20%、38%的【法术脆弱】,不可叠加持续8秒": "When dealing damage, 30% chance to inflict Scorch on the enemy: ATK -20% and 38% [Arts Fragility], non-stackable, lasts 8 seconds",
"酷!": "Cool!"
}
+11
View File
@@ -0,0 +1,11 @@
{
"_about": "Fallback English for the remake's own data texts (names and notes tools/build-data.mjs writes; no official counterpart). Official EN terms where they exist: 'Active Originium', 'High Tide', 'Mutated Cells', 'Rockrock', 'Stronghold Protocol: Alliance' (the EN activity name). '排气格栅' follows PR #70's wording (no official EN occurrence found).",
"卫戍协议:盟约": "Stronghold Protocol: Alliance",
"活性源石": "Active Originium",
"涨潮": "High Tide",
"排气格栅": "Exhaust Grille",
"联防阵地(1名玩家)": "Unite Field (1 player)",
"联防阵地(2名玩家)": "Unite Field (2 players)",
"维多利亚盟约每25层 / 洛洛的定制品": "Victoria Alliance (every 25 stacks) / Rockrock's custom items",
"生效时原干员销毁,突变细胞与其他装备退回整备区,可再次配发;随后获得一名高一阶的随机初始干员(最高6阶),进入整备区,需要重新部署": "When it takes effect, the Operator is destroyed and the Mutated Cells and other equipment return to the Bench, ready to be equipped again; then a random starting Operator one Tier higher (up to Tier 6) joins the Bench and has to be deployed again"
}