Files
ark-stronghold-protocol/docs/I18N.md
T

15 KiB

I18N.md — languages (中文 / English)

Chinese is the default; English is a switch (the owner's decision of 2026-10-05): an English browser still opens in Chinese until the player picks English on the title screen (top right) or in 设置 / Settings. The choice is kept in localStorage (sp.pref.lang); ?lang=en / ?lang=zh in the address bar sets it once and is then removed. A switch re-renders the page in place (no reload).

Three kinds of text, three mechanisms — no i18n framework:

Text Source of the English Mechanism
UI strings (buttons, labels, hints, toasts) public/i18n/en.json — Chinese msgid → English t() in the code (shared/i18n.js)
Game texts (operators, skills, talents, modules, traits, enemies, bonds, items, effects, strategies, 特质, 机变 cards, modes, stages, tokens) data/i18n/en.json — the official EN client texts by record id and field public/js/data.js returns localized records
Server messages (m.toast, m.ticker, error codes) the same public/i18n/en.json the server sends msgid + params, the client translates

1. UI strings: t()

gettext style — the Chinese source string is the message id, so an untranslated string shows Chinese, never a key:

import { t, tc, N_ } from '../../../shared/i18n.js';
t('整备区已满');                         // 'Bench is full' in English
t('还剩 {n} 秒', { n });                 // params: {name}; arrays fill {0}, {1} …
t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN });
tc('toggle', '关闭');                    // context: the key 'toggle::关闭' ("Off"), else '关闭' ("Close")
const TABLE = { a: N_('独立模拟') };     // module level: N_ only marks it; call t(TABLE.a) where it is shown
html`<span>${tParts('第 {r} 回合 · 最终攻势', { r: html`<b class="num">${r}</b>` })}</span>`; // markup inside a sentence
  • Placeholders are the same in the msgid and the translation: {name}, positional {0}, and one plural form for English, {n|one|other} ('{n} {n|round|rounds}'). A value that is an array renders as a list (、 / , ). A missing value leaves the placeholder as it is (official templates such as {0}博士… pass through untouched).
  • Markup inside a sentence (a number in <b>, a <kbd>): tParts(msgid, params) returns the translation split at its placeholders, with a param that is markup (a vnode, or an array of vnodes) kept as it is — the translator gets the whole sentence and its word order, the Chinese pieces are the text around the markup exactly as before. Do not split such a sentence into fragments (第 … 回合).
  • Game-data names inside a message are marked dn(name) ({ dn: '…' }) and translated through the localized data (tName(name) does the same for a bare name). Player names are never translated — pass them as plain strings.
  • Data texts are not msgids: getChess(id).name, getMode(id).desc, data.get('config').tips … already come in the current language (§2). Do not wrap them in t().
  • Re-rendering: main.js App subscribes with useLang() (public/js/ui/lang.js), so the whole tree re-renders on a switch. A component that caches text outside rendering (a useMemo returning text, a module-level string) must depend on the language (useLang()) or move the t() call into rendering.
  • Font samples, data keys and other Chinese that is not UI text can carry // i18n-ignore on the line.

Translating

Edit public/i18n/en.json ("msgid": "English", two-space JSON; _about is metadata). Keep every {placeholder}; test/i18n.test.js checks that, that no Chinese is left in a value, and that every msgid the code uses is present. A translation may use more params than its msgid ('{n} {count|unit|units}' with count passed beside a vnode n). Use the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts):

中文 English 中文 English
盟约 Alliance 调度中心 Dispatch Center
整备区 / 临时整备区 Bench / temporary Bench 资金 Funds
休整期 Rest Phase 机变阶段 Improv Phase
联防 / 联防阶段 Unite / Unite Phase 最终攻势 Final Assault
隐秘核心 Hidden Core 目标生命值 LP
联合模拟 / 同盟模拟 Team Simulation 独立模拟 Solo Simulation
标准 / 险境 / 绝境 / 终极模拟 Standard / Perilous / Dire / Ultimate Simulation 入门协议 Entry Protocol
策略 Strategy 特质 Attribute
精锐(干员) Elite (Operator) 层数 stacks
同盟密钥 Alliance Key 创建者 Host

Tools: node tools/i18n.mjs

  • extract [paths] [--list] — per file: the msgids in t() / tc() / tParts() / N_() / msg(), and the Chinese literals still shown untranslated; the total says how many of those already have a translation (ready for the codemod).
  • check [paths] [--strict] [--stale] — msgids the code uses that public/i18n/en.json lacks (--strict exits 1; --stale lists entries no code uses yet).
  • codemod <files> [--write] — wraps a file's Chinese literals: html`` text and attribute values → ${t('…')}, strings → t('…'), template literals → t('…{name}…', { name }) (an expression's param name: identifier → itself, a.b → b, x.length → n, else its position), nested strings in params included; adds the import. Module-level literals get N_() and are listed for a manual t() where they are shown; compared literals, object keys, string-method arguments, console.* and Error messages are left alone; a text run with a child that is not a plain value is split into its static pieces. Review the diff: join fragments into whole sentences where it reads better (the settings hotkey line is an example), check module-level tables.
  • seed --from <map.json> [--all] [--write] — fills en.json from a flat { msgid: English } map for the msgids the code uses (--all: also the literals not wrapped yet, under the msgid the codemod will give them).

Server code is scanned too: the first argument of msg() and a Chinese literal handed to m.toast / ctx.toast / tickerText are msgids (server/sim only for those — its own t() helpers are not translation calls). Not player text, so neither wrapped nor counted: console.* / logger calls (log.warn(…), this.m.log?.info?.(…)), Error messages, the detail of an error result ({ error: 'BAD_TARGET', detail: '…' }, ev.detail = '…' — players see ERR_TEXT), a line marked // i18n-ignore and a file marked i18n-ignore-file in a comment (the developer invariant reports server/match/audit.js / invariants.js, the bilingual HTTP error pages server/http/*). An aliased import (import { t as tr }, where t is a local variable) is read as the function it names.

test/i18n.test.js scans what extract / check scan (public/js, shared, server) and fails on a Chinese literal left unwrapped or a msgid missing from public/i18n/en.json — extract 0 and check --strict are part of the tests.

2. Game texts: data/i18n/en.json

node tools/build-i18n.mjs                 # downloads the EN tables once into .cache/gamedata-en/, writes data/i18n/en.json
node tools/build-i18n.mjs --offline       # never download (the zh_CN tables come from .cache/gamedata, as for build-data)
node tools/build-i18n.mjs --check         # exit 1 when data/i18n/en.json is out of date (nothing written)
node tools/build-i18n.mjs --source yostar # the other EN source (see below)

Rebuild it after every node tools/build-data.mjs that changes a text: test/i18n-data.test.js fails while a translation's Chinese source no longer matches data/*.json, and at run time such a stale leaf is not applied (the player sees the current Chinese text, never an outdated number).

  • Sources. The English comes from the official EN client tables. Default --source assets: ArknightsAssets/ArknightsGamedata en/gamedata — current, and its activity_table has both 盟约 seasons (act1autochess, act2autochess, "Stronghold Protocol: Alliance") with the same ids as zh_CN. --source yostar: Kengxxiao/ArknightsGameData_YoStar en_US — archived in 2025-11, it predates 盟约 (only the 2025 test event act1vautochess) and many operators, so most mode texts and new operators stay Chinese with it.
  • Matching (no id table to maintain): the zh_CN and EN tables are walked side by side; a data text equal to a Chinese source takes its English; a text whose blackboard placeholders build-data resolved ({atk:0%} → 80%) is matched against the Chinese templates of the same shape, its numbers are read back and the English template is filled with them (the remake's numbers, not the EN server's); stage names are assembled from their parts. Ties prefer the source on the record's own ids, then the 下半 season (act2autochess). Rich-text tags and placeholders are the same as in Chinese, so ui/richText.js renders the English unchanged (a few official EN texts leave a tag unclosed; the parser runs it to the end of the text, as the game does).
  • Fallbacks, in order: tools/i18n/fallback-remake.json (the remake's own strings: stage-name parts, item notes, the 联防 map names, the season name), tools/i18n/fallback-pr70.json (PR #70's translations of texts the EN client lacks: modules of CN-only operators, the 甄选 slots, 结算称号 criteria, emote labels), then public/i18n/en.json; anything else stays Chinese. Research notes (bonds.*.spec, implFormula, assumed …) are never translated.
  • Format (shared/i18nData.js): { version, lang, meta: { source, coverage, totals }, names, files }. files[file][id] mirrors the record — string leaves replace the Chinese at the same path, arrays are index-aligned (null = keep) — with _h, a 2-character hash of each replaced Chinese string; names maps every record-level Chinese name to English (server messages and ticker lines name operators / items / bonds in Chinese).
  • Coverage (build of 2026-10-05 with the 自选 data, 22 183 texts): 97.6 % translated, 97.2 % from the official EN data. What stays Chinese is CN-only content the EN client does not have yet: some of the 71 owned 6★ of the 自选 pool (names, skills, talents) and some newer modules (before the 自选 data: 14 931 texts, 99.9 %). Per kind in .cache/build-i18n-report.json (also missing: every untranslated text) and in meta.coverage.
  • Client: data.setLocale(lang) downloads the overlay once (~1.5 MB, ~190 KB gzipped) and from then on get, lookup and list (and every getter built on them) return localized copies; data.getRaw(name) keeps the Chinese file; data.localeName(zh) translates a name.

3. Server messages

The server sends a toast or a custom ticker line as a plain string (its own msgid) or as msg(msgid, params) (shared/i18n.js). Frames keep text — the Chinese rendering, what older clients show — and add msgid / params; data names are dn() params. A config.broadcasts line (tickerFor) also carries its args, and the client fills the localized broadcast template (ui/lang.js tickerText; {0} is the player's name and is never translated). Error frames already carry their ERR code: ui/toasts.js describeError translates ERR_TEXT[code]. The protocol is backward compatible both ways (new fields are optional).

this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));

Game texts the server copies into m.public / m.private in Chinese — a 机变 card's name and description, the draft's name, an effect entry of the effects column, a reward offer's label, a result title — are shown from the localized data on the client: names through tName() (every record name is in the overlay's names), texts through ui/lang.js sentText(sent, raw, local), which takes the localized record's text when the sent text is that record's own Chinese text (data.lookupRaw / data.getRaw), else the sent text (a line the server reworded — the two-battle text of a multi-round 悬赏, server/match/choices.js bountyText — stays Chinese). In Chinese both return what the server sent.

4. Coverage of the UI strings (phase 2, 0.2.0)

Phase 1 converted the title screen, the lobby, the room, the settings panel (with the language switch), the app shell (main.js: toasts, close reasons, the crash screen), ui/toasts.js, DifficultyTag and the server messages. Phase 2 (2026-10-06) converted the rest: of the 1 128 Chinese literals extract listed, 1 056 go through t() / tParts() / N_() — the briefing, the band draft, the game screen and its overlays, 干员调配 / 干员持有, the result (275), the HUD, shop bar, detail card, bond strip, 机变 overlay, enemy drawer, team panel, guide, emotes, components and the client models (579), the phase banners, refusal reasons and formats of ui/gameLogic (106), the canvas / fallback-field / facing labels (16) and the msgid tables of shared/constants.js (80) — and 72 are marked as no player text (above). Now node tools/i18n.mjs extract reports 0, check 1 002 msgids, all in public/i18n/en.json (1 015 entries). The Chinese output did not change: a render of every converted file in Chinese (each t() call put back as the string it renders, html`` parsed with htm, text runs compared) equals the code before the sweep; the few rewrites it cannot follow (a variable folded into whole sentences, a list param in place of .join('、'), fmtNum's English branch) were checked by hand. Numbers: fmtNum writes 万 / 亿 in Chinese and K / M / B in English.

What stays Chinese in English, and why:

  • player names, including the AI seats' (AI·华法琳 …, server/lobby.js BOT_NAMES) — a name is never translated;
  • a server text the client cannot match to a data record (the reworded multi-round 悬赏 text above);
  • game texts the official EN client does not have yet (§2: some 自选 operators, newer modules);
  • the 玩法说明 pages, which are the official Chinese pictures (their titles are translated).

Not player text, never translated: developer details and logs (detail, log.*, the invariant reports), the data keys (shared/highGround.js reads the trait text 「可以放置于远程位」), the development STUB match, and the HTTP error pages, which are bilingual on purpose.

Credits

The English UI strings were seeded from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (the owner's decision of 2026-10-05); see NOTICE.md and THIRD-PARTY-NOTICES.md. PR #70's texts taken from ak-spa-database.pages.dev (no licence) are not used. The English game texts are the official EN client data (© Hypergryph / Yostar), via the community mirrors named above.