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

+85 -40
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data.
// tools/build-i18n.mjs — LOCALIZED GAME DATA: data/i18n/en.json from the official EN client data (and, with --lang, the
// game texts of a language pack from another official client: ja, ko, zh-TW — docs/I18N.md "Adding a language").
//
// 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
@@ -21,17 +22,27 @@
// 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)
// Usage: node tools/build-i18n.mjs [--lang <code>] [--refresh | --offline] [--source assets|yostar] [--cache <dir>]
// [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]
// --lang the target language (default en): one with an official client in LANG_SOURCES — en, ja (the JP
// client), ko (KR), zh-TW (TW); all three carry both 盟约 seasons (verified 2026-10-07, data 51.x). The
// output goes to data/i18n/<code>.json, the game texts of the language pack <code> (it needs the pack's
// UI file public/i18n/<code>.json to show in the menu). A language with no official client gets its game
// texts another way (docs/I18N.md): the pack's UI file fills the data texts equal to one of its msgids,
// the rest falls back per text (the pack's chain, then Chinese).
// --source tables of the language: 'assets' (default) = ArknightsAssets/ArknightsGamedata (en / jp / kr / tw;
// current, has the 盟约 seasons act1autochess / act2autochess); for en also 'yostar' =
// Kengxxiao/ArknightsGameData_YoStar en_US (archived in 2025-11: no 盟约 season, only the 2025 test event
// act1vautochess)
// --refresh re-download the target 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)
// --cache-en target cache (default <repo>/.cache/gamedata-en for en 'assets', .cache/gamedata-en-yostar for 'yostar',
// .cache/gamedata-<code> for the others)
// --data the data/*.json to localize (default <repo>/data); --out default <repo>/data/i18n/<code>.json
// --report default <repo>/.cache/build-i18n-report.json (en; build-i18n-report.<code>.json for the others): coverage
// per kind, untranslated samples
// --check build in memory and exit 1 when the output differs from --out (nothing written)
// In the code below "en" names the target side of the walk, whatever the language (the matching is the same).
// Determinism: the output depends only on the inputs (stable key order, no timestamps).
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
@@ -41,7 +52,16 @@ 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]';
const USAGE = 'usage: node tools/build-i18n.mjs [--lang en|ja|ko|zh-TW] [--refresh | --offline] [--source assets|yostar] [--cache <dir>] [--cache-en <dir>] [--data <dir>] [--out <file>] [--report <file>] [--check] [--quiet]';
const ASSETS_HOME = 'https://github.com/ArknightsAssets/ArknightsGamedata';
/** The official client tables of a language other than English (ArknightsAssets/ArknightsGamedata <server>/gamedata). */
const assetsSource = (server, code) => Object.freeze({
label: `ArknightsAssets/ArknightsGamedata (${server})`,
home: ASSETS_HOME,
url: `https://raw.githubusercontent.com/ArknightsAssets/ArknightsGamedata/master/${server}/gamedata/`,
cache: `.cache/gamedata-${code}`,
});
export const EN_SOURCES = Object.freeze({
assets: {
@@ -59,6 +79,21 @@ export const EN_SOURCES = Object.freeze({
});
const ZH_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
/**
* The official clients by target language, each with its table sources. `untranslated`: how a target text that is not
* a translation shows — 'cjk' (it still has Chinese characters: en, ko) or 'same' (it is the Chinese text itself: ja and
* zh-TW write with Han characters). `composite`: the remake-made stage names are assembled (English wording only).
*/
export const LANG_SOURCES = Object.freeze({
en: Object.freeze({ sources: EN_SOURCES, untranslated: 'cjk', composite: true }),
ja: Object.freeze({ sources: Object.freeze({ assets: assetsSource('jp', 'ja') }), untranslated: 'same', composite: false }),
ko: Object.freeze({ sources: Object.freeze({ assets: assetsSource('kr', 'ko') }), untranslated: 'cjk', composite: false }),
'zh-TW': Object.freeze({ sources: Object.freeze({ assets: assetsSource('tw', 'zh-TW') }), untranslated: 'same', composite: false }),
});
/** The test "this target text is no translation of that Chinese one" of a language (LANG_SOURCES `untranslated`). */
const untranslatedTest = (lang) => (LANG_SOURCES[lang]?.untranslated === 'same' ? (zh, t) => t === zh : (zh, t) => hasCjk(t));
/** 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 } }) },
@@ -151,12 +186,11 @@ const hasPlaceholder = (s) => typeof s === 'string' && /\{-?[A-Za-z_@][^{}:]*(?:
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'),
refresh: false, offline: false, quiet: false, check: false, source: 'assets', lang: 'en',
cache: join(ROOT, '.cache', 'gamedata'), cacheEn: null, data: join(ROOT, 'data'), out: null, report: null,
};
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' };
const vals = { '--source': 'source', '--lang': 'lang', '--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];
@@ -165,14 +199,18 @@ function parseArgs(argv) {
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);
opts[vals[name]] = name === '--source' || name === '--lang' ? 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);
const lang = LANG_SOURCES[opts.lang];
if (!lang) throw new Error(`--lang must be one of ${Object.keys(LANG_SOURCES).join(', ')} (a language with an official client; docs/I18N.md says how a pack without one gets its game texts)`);
if (!lang.sources[opts.source]) throw new Error(`--source for ${opts.lang} must be one of ${Object.keys(lang.sources).join(', ')}`);
if (!opts.cacheEn) opts.cacheEn = join(ROOT, lang.sources[opts.source].cache);
if (!opts.out) opts.out = join(ROOT, 'data', 'i18n', `${opts.lang}.json`);
if (!opts.report) opts.report = join(ROOT, '.cache', opts.lang === 'en' ? 'build-i18n-report.json' : `build-i18n-report.${opts.lang}.json`);
return opts;
}
@@ -234,7 +272,9 @@ const isDict = (o) => {
* index (shape key → [{ zh, en, scope }]) for texts with placeholders.
*/
class PairIndex {
constructor() {
/** @param {(zh: string, target: string) => boolean} [untranslated] a target text that is no translation (skipped) */
constructor(untranslated = (zh, t) => hasCjk(t)) {
this.untranslated = untranslated;
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
this.exact = new Map();
/** @type {Map<string, Map<string, { n: number, scopes: Set<string> }>>} */
@@ -256,7 +296,7 @@ class PairIndex {
add(zhRaw, enRaw, scope) {
const zh = unescapeNewlines(zhRaw);
const en = unescapeNewlines(enRaw);
if (!en.trim() || hasCjk(en)) return;
if (!en.trim() || this.untranslated(zh, 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)
@@ -394,9 +434,10 @@ class Translator {
* @param {PairIndex} index
* @param {{ name: string, map: Map<string, string> }[]} fallbacks in order of preference
*/
constructor(index, fallbacks) {
constructor(index, fallbacks, { composites = true } = {}) {
this.index = index;
this.fallbacks = fallbacks;
this.composites = composites;
}
/**
@@ -414,7 +455,7 @@ class Translator {
}
const tpl = this.template(zh, ctx);
if (tpl) return tpl;
const comp = this.composite(zh, ctx);
const comp = this.composites ? this.composite(zh, ctx) : null;
if (comp) return comp;
for (const { name, map } of this.fallbacks) {
const fb = map.get(zh);
@@ -570,21 +611,22 @@ class Stats {
// ===== main ==========================================================================================================
const dictMap = (d) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !hasCjk(v)));
const dictMap = (d, untranslated = (zh, t) => hasCjk(t)) => new Map(Object.entries(d || {}).filter(([k, v]) => !k.startsWith('_') && typeof v === 'string' && v && !untranslated(k, 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();
export function buildOverlay({ zh, en, data, fallback = {}, source = null, lang = 'en' }) {
const untranslated = untranslatedTest(lang);
const index = new PairIndex(untranslated);
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) },
]);
{ name: 'remake', map: dictMap(fallback.remake, untranslated) },
{ name: 'pr70', map: dictMap(fallback.pr70, untranslated) },
{ name: 'ui', map: dictMap(fallback.ui, untranslated) },
], { composites: LANG_SOURCES[lang]?.composite ?? false });
const stats = new Stats();
const samples = {};
const files = {};
@@ -602,7 +644,7 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
// 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;
if (!top || !NAME_KEYS.has(l.key) || untranslated(l.zh, 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 });
@@ -628,11 +670,13 @@ export function buildOverlay({ zh, en, data, fallback = {}, source = null }) {
}
const overlay = {
version: OVERLAY_VERSION,
lang: 'en',
lang,
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',
fallback: lang === 'en'
? 'tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json'
: `public/i18n/${lang}.json (the pack's UI strings)`,
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 },
},
@@ -648,8 +692,8 @@ 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 src = LANG_SOURCES[opts.lang].sources[opts.source];
log(`build-i18n: ${opts.lang} tables from ${src.label}`);
const zh = [];
const en = [];
for (const t of TABLES) {
@@ -674,12 +718,13 @@ async function main() {
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 = {
// the remake's own strings and PR #70's are English; another language falls back to its pack's UI strings only
const fallback = opts.lang === 'en' ? {
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 } });
} : { ui: await readDict(join(ROOT, 'public', 'i18n', `${opts.lang}.json`)) };
const { overlay, report } = buildOverlay({ zh, en, data, fallback, lang: opts.lang, 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;
@@ -692,13 +737,13 @@ async function main() {
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):`);
log(` season in the ${opts.lang} build: ${seasonEn}${version ? ` · ${version.replace(/\s+/g, ' ')}` : ''}`);
log(` ${report.pairs} zh→${opts.lang} 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)`);
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 ${opts.lang} 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); }