From 7697b4ff40a0104dab05d63a35705fbf103fe779 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 18:21:43 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20W5S:=20i18n=20phase=202,=20shared?= =?UTF-8?q?=20and=20server=20texts=20=E2=80=94=20shared/constants.js=20msg?= =?UTF-8?q?id=20tables=20(difficulties,=20phases,=20error=20codes,=20emote?= =?UTF-8?q?=20sets=20and=20labels)=20marked=20N=5F();=20what=20is=20not=20?= =?UTF-8?q?player=20text=20is=20marked=20and=20skipped=20by=20the=20audit:?= =?UTF-8?q?=20error=20details=20and=20logger=20lines=20(tools/i18n.mjs=20r?= =?UTF-8?q?eads=20detail:=20values,=20log.*=20calls=20and=20i18n-ignore-fi?= =?UTF-8?q?le),=20the=20developer=20invariant=20reports,=20the=20bilingual?= =?UTF-8?q?=20HTTP=20error=20pages,=20the=20AI=20seats'=20player=20names,?= =?UTF-8?q?=20the=20dev=20STUB=20match,=20data=20keys=20/=20fallbacks;=20t?= =?UTF-8?q?he=20=E8=99=AB=E5=8A=A8=20emote=20set=20follows=20the=20officia?= =?UTF-8?q?l=20EN=20name=20(Slug=20Style);=20node=20tools/i18n.mjs=20extra?= =?UTF-8?q?ct=20now=20reports=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/i18n/en.json | 2 +- server/http/common.js | 1 + server/http/media.js | 1 + server/http/routes.js | 1 + server/http/static.js | 1 + server/lobby.js | 2 +- server/match/StubMatch.js | 2 +- server/match/audit.js | 1 + server/match/builtinMeta.js | 2 +- server/match/choices.js | 6 +- server/match/invariants.js | 1 + server/match/match/phases.js | 2 +- server/match/match/spDraft.js | 2 +- server/match/player/views.js | 4 +- shared/constants.js | 104 +++++++++++++++++----------------- shared/diy.js | 6 +- shared/highGround.js | 2 +- test/i18n.test.js | 8 +++ tools/i18n.mjs | 24 ++++++-- 19 files changed, 102 insertions(+), 70 deletions(-) diff --git a/public/i18n/en.json b/public/i18n/en.json index a11afad0..a3308fab 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -745,7 +745,7 @@ "谢谢": "Thanks", "思考": "Thinking", "合作愉快": "Nice teamwork", - "表情套组:虫动": "Emote Set: Slug Moves", + "表情套组:虫动": "Emote Set: Slug Style", "合作愉快!": "Nice teamwork!", "谢谢!": "Thanks!", "对不起!": "Sorry!", diff --git a/server/http/common.js b/server/http/common.js index b3c8fcce..9d9300ae 100644 --- a/server/http/common.js +++ b/server/http/common.js @@ -1,4 +1,5 @@ // server/http/common.js — what every HTTP answer shares: +// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md) // // * the security headers, set on every response before any route runs (routes.js); // * splitUrl (raw path + query, also from absolute-form URLs), the bilingual error page (sendError) and JSON replies diff --git a/server/http/media.js b/server/http/media.js index 25f6adf4..2308ac03 100644 --- a/server/http/media.js +++ b/server/http/media.js @@ -1,4 +1,5 @@ // server/http/media.js — the extension-less audio route /media/… → public/assets/audio (see serveMedia). +// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md) import fsp from 'node:fs/promises'; import path from 'node:path'; diff --git a/server/http/routes.js b/server/http/routes.js index 383c1d21..ab4a12ff 100644 --- a/server/http/routes.js +++ b/server/http/routes.js @@ -1,4 +1,5 @@ // server/http/routes.js — the node:http request listener. Every response gets the security headers (common.js), then: +// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md) // // * a URL longer than 4096 characters → 414; one that does not parse → 400; // * any method but GET / HEAD → 405 with `Allow: GET, HEAD`; diff --git a/server/http/static.js b/server/http/static.js index 265fdc6a..01b4929c 100644 --- a/server/http/static.js +++ b/server/http/static.js @@ -1,4 +1,5 @@ // server/http/static.js — the static file server (routes.js hands it every GET / HEAD that is not an API route): +// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md) // // / → public/ (index.html for directories) // /data/ → data/ (generated game data) diff --git a/server/lobby.js b/server/lobby.js index b527bb2c..0e0530f2 100644 --- a/server/lobby.js +++ b/server/lobby.js @@ -119,7 +119,7 @@ export const LOBBY_DEFAULTS = Object.freeze({ export const SOLO_RECONNECT_FALLBACK_SEC = 86_400; /** Display names for AI teammates (the tutorial NPCs first, then a few familiar faces). */ -export const BOT_NAMES = Object.freeze(['AI·华法琳', 'AI·阿米娅', 'AI·惊蛰', 'AI·杜宾', 'AI·凯尔希', 'AI·可露希尔']); +export const BOT_NAMES = Object.freeze(['AI·华法琳', 'AI·阿米娅', 'AI·惊蛰', 'AI·杜宾', 'AI·凯尔希', 'AI·可露希尔']); // i18n-ignore: player names (docs/I18N.md) const OK = Object.freeze({ ok: true }); const fail = (code, detail) => (detail ? { error: code, detail } : { error: code }); diff --git a/server/match/StubMatch.js b/server/match/StubMatch.js index b61f2cdb..3ec4eabb 100644 --- a/server/match/StubMatch.js +++ b/server/match/StubMatch.js @@ -225,7 +225,7 @@ export class StubMatch { })), fields: [], stub: true, - message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。', + message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。', // i18n-ignore: development placeholder }; } diff --git a/server/match/audit.js b/server/match/audit.js index 16eb7bff..ce191e1f 100644 --- a/server/match/audit.js +++ b/server/match/audit.js @@ -1,4 +1,5 @@ // server/match/audit.js — rule auditor for sweeps and tests (tools/matchrun.mjs --check, test/match/fullmatch.test.js). +// (i18n-ignore-file: developer reports in English with the game's terms, never shown to players — docs/I18N.md) // // attachAudit(m) wraps a live Match's phase transitions and a few prep handlers (instance-level wrappers; the engine // is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js: diff --git a/server/match/builtinMeta.js b/server/match/builtinMeta.js index 3cc38185..9cf37fe4 100644 --- a/server/match/builtinMeta.js +++ b/server/match/builtinMeta.js @@ -91,7 +91,7 @@ const ITEM_HANDLERS = { const n = int(paramsOf(ctx, ev.item).count, 1); const rec = ctx.gd.item(ev.item.id); ctx.addEffect({ - id: `doll:${ev.item.uid}`, key: 'effect:builtin_round_coin', name: rec ? rec.name : '精打细算玩偶', desc: rec ? rec.desc : '', + id: `doll:${ev.item.uid}`, key: 'effect:builtin_round_coin', name: rec ? rec.name : '精打细算玩偶', desc: rec ? rec.desc : '', // i18n-ignore: the item's data name iconKind: 'item', iconId: rec ? rec.iconId || rec.id : ev.item.id, battle: false, params: { count: n }, }); }, diff --git a/server/match/choices.js b/server/match/choices.js index 04cee051..bb3ef5e0 100644 --- a/server/match/choices.js +++ b/server/match/choices.js @@ -63,7 +63,7 @@ import { weightedPick } from './waves.js'; -export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' }; +export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' }; // i18n-ignore: = choices.json families (the client shows the localized record) /** * Battles a multi-round bounty card lasts (data `rounds` 99, official text "之后 / 后续的<@ba.vdown>每场作战": @@ -82,7 +82,7 @@ export function bountyBattles(c) { const r = Number(c && c.rounds); return Math.max(1, Math.min(99, Number.isInteger(r) ? r : 1)); } -const N_ZH = ['', '一', '两', '三', '四', '五']; +const N_ZH = ['', '一', '两', '三', '四', '五']; // i18n-ignore: rewrites the official Chinese bounty text /** * A bounty text as the card lasts: a multi-round card's "之后的 / 后续每场作战" (rich `<@ba.vdown>每场` or plain) reads * "接下来<@ba.vup>两场作战" like the official two-battle cards while MULTI_ROUND_BOUNTY_BATTLES is set; any other @@ -91,7 +91,7 @@ const N_ZH = ['', '一', '两', '三', '四', '五']; export function bountyText(text, c) { if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text; const n = MULTI_ROUND_BOUNTY_BATTLES; - const battles = `${N_ZH[n] || n}场作战`; + const battles = `${N_ZH[n] || n}场作战`; // i18n-ignore return text .replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}`) .replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`); diff --git a/server/match/invariants.js b/server/match/invariants.js index fc2296f5..df2c67bc 100644 --- a/server/match/invariants.js +++ b/server/match/invariants.js @@ -1,4 +1,5 @@ // server/match/invariants.js — engine invariants (DESIGN §11) as a non-throwing check. Used by the test harness +// (i18n-ignore-file: developer reports in English with the game's terms, never shown to players — docs/I18N.md) // (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps. // // collectViolations(m) → string[] (empty when every invariant holds): diff --git a/server/match/match/phases.js b/server/match/match/phases.js index 25df77df..34c67800 100644 --- a/server/match/match/phases.js +++ b/server/match/match/phases.js @@ -166,7 +166,7 @@ export class MatchPhases { if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY); if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN); if (typeof bandId !== 'string' || !this.gd.bandAllowed(bandId)) return fail(ERR.BAD_TARGET); - if (this.bandTaken(bandId, ps.playerId)) return fail(ERR.BAD_TARGET, '队友已选'); + if (this.bandTaken(bandId, ps.playerId)) return fail(ERR.BAD_TARGET, '队友已选'); // i18n-ignore: developer detail (players see ERR_TEXT) this._applyBand(ps, bandId); return OK; } diff --git a/server/match/match/spDraft.js b/server/match/match/spDraft.js index 5bac0e6f..65ca9625 100644 --- a/server/match/match/spDraft.js +++ b/server/match/match/spDraft.js @@ -112,7 +112,7 @@ export class MatchSpDraft { const rounds = bountyBattles(card); const b = { id: `bounty:${this.nextUid()}`, - card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) }, + card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) }, // i18n-ignore: a data-less card's fallback name roundsLeft: rounds, }; ps.bounties.push(b); diff --git a/server/match/player/views.js b/server/match/player/views.js index 764bf39a..4be97bed 100644 --- a/server/match/player/views.js +++ b/server/match/player/views.js @@ -42,8 +42,8 @@ export class PlayerViews { const left = b.roundsLeft >= 90 ? null : b.roundsLeft; const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null; out.push({ - id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty', - counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`, + id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty', // i18n-ignore: a data-less card's fallback name + counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`, // i18n-ignore: older clients (ui/effectsList.js builds it from counter) }); } return out; diff --git a/shared/constants.js b/shared/constants.js index deb1abaf..42665500 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -1,5 +1,7 @@ // Shared enums & constants (server + browser). Pure ESM, no Node APIs. +import { N_ } from './i18n.js'; + export const PROTOCOL_VERSION = 1; /** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version" * (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */ @@ -17,7 +19,7 @@ export const ROOM_CODE_LEN = 4; export const NAME_MAX_LEN = 12; export const DIFFICULTIES = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS']; -export const DIFFICULTY_NAMES = { FUNNY: '标准模拟', NORMAL: '险境模拟', HARD: '绝境模拟', ABYSS: '终极模拟' }; +export const DIFFICULTY_NAMES = { FUNNY: N_('标准模拟'), NORMAL: N_('险境模拟'), HARD: N_('绝境模拟'), ABYSS: N_('终极模拟') }; export const DIFFICULTY_COLORS = { FUNNY: '#f6a329', NORMAL: '#e85a1a', HARD: '#e73118', ABYSS: '#ff0024' }; // modeId in data/config.json = `mode_${type}_${difficulty.toLowerCase()}` with type single|multi @@ -41,9 +43,9 @@ export const PHASE = Object.freeze({ }); export const PHASE_NAMES = { - LOBBY: '等待中', INFO_CHECK: '确认本局信息', BAND_DRAFT: '选择策略', BATTLE_CHECK: '协议启动', - ROUND_START: '回合开始', SP_DRAFT: '机变阶段', PREP: '休整期', COMBAT: '作战中', UNITE: '联防阶段', - SETTLE: '结算', FINAL_ASSAULT: '最终攻势', HIDDEN_CORE: '隐秘核心', RESULT: '模拟结束', + LOBBY: N_('等待中'), INFO_CHECK: N_('确认本局信息'), BAND_DRAFT: N_('选择策略'), BATTLE_CHECK: N_('协议启动'), + ROUND_START: N_('回合开始'), SP_DRAFT: N_('机变阶段'), PREP: N_('休整期'), COMBAT: N_('作战中'), UNITE: N_('联防阶段'), + SETTLE: N_('结算'), FINAL_ASSAULT: N_('最终攻势'), HIDDEN_CORE: N_('隐秘核心'), RESULT: N_('模拟结束'), }; // Board geometry on the 19x21 stage grid (row 0 = bottom). See DESIGN §3. @@ -145,12 +147,12 @@ export const ERR = Object.freeze({ }); export const ERR_TEXT = { - BAD_MSG: '无效的请求', RATE: '操作过于频繁', NOT_IN_ROOM: '你不在房间中', ROOM_NOT_FOUND: '未找到该同盟密钥对应的房间', - ROOM_FULL: '房间已满', ROOM_STARTED: '模拟已开始', NOT_HOST: '只有房主可以操作', NOT_READY: '仍有玩家未就绪', - WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限', - BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级', - NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰', - SPECTATOR: '观战中无法进行该操作', INTERNAL: '服务器内部错误', + BAD_MSG: N_('无效的请求'), RATE: N_('操作过于频繁'), NOT_IN_ROOM: N_('你不在房间中'), ROOM_NOT_FOUND: N_('未找到该同盟密钥对应的房间'), + ROOM_FULL: N_('房间已满'), ROOM_STARTED: N_('模拟已开始'), NOT_HOST: N_('只有房主可以操作'), NOT_READY: N_('仍有玩家未就绪'), + WRONG_PHASE: N_('当前阶段无法进行该操作'), NO_FUNDS: N_('资金不足'), HAND_FULL: N_('整备区已满'), BOARD_FULL: N_('已达到部署上限'), + BAD_TILE: N_('无法部署在该位置'), BAD_TARGET: N_('无效的目标'), SOLD_OUT: N_('已售出'), MAX_LEVEL: N_('调度中心已达最高等级'), + NOT_YOUR_TURN: N_('尚未轮到你'), ALREADY: N_('已完成该操作'), TEMP_NOT_EMPTY: N_('临时整备区不为空'), ELIMINATED: N_('你已被淘汰'), + SPECTATOR: N_('观战中无法进行该操作'), INTERNAL: N_('服务器内部错误'), }; // ---- Emotes (交流, research 09 §4) ----------------------------------------------------------------------------- @@ -166,53 +168,53 @@ export const ERR_TEXT = { // derived from the id (autochess_battle_fooldoctor_03…06 → pic_fooldoctor_04/05/06/08_battle). const emo = (id, sortId, picId, label) => Object.freeze({ id, sortId, picId, label }); export const EMOTE_THEMES = Object.freeze([ - { themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: '表情套组:卫戍协议', emotes: [ - emo('autochess_battle_happy', 1001, 'pic_happy_battle', '开心'), - emo('autochess_battle_scared', 1002, 'pic_scared_battle', '害怕'), - emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', '对不起'), - emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', '谢谢'), - emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', '思考'), - emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', '合作愉快'), + { themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: N_('表情套组:卫戍协议'), emotes: [ + emo('autochess_battle_happy', 1001, 'pic_happy_battle', N_('开心')), + emo('autochess_battle_scared', 1002, 'pic_scared_battle', N_('害怕')), + emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', N_('对不起')), + emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', N_('谢谢')), + emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', N_('思考')), + emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', N_('合作愉快')), ] }, - { themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: '表情套组:虫动', emotes: [ - emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', '合作愉快!'), - emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', '谢谢!'), - emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', '对不起!'), - emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', '再见!'), + { themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: N_('表情套组:虫动'), emotes: [ + emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', N_('合作愉快!')), + emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', N_('谢谢!')), + emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', N_('对不起!')), + emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', N_('再见!')), emo('slug_autochess_battle_distrust', 2005, 'pic_distrust_battle', '???'), - emo('slug_autochess_battle_very_soon', 2006, 'pic_very_soon_battle', '很快就好!'), + emo('slug_autochess_battle_very_soon', 2006, 'pic_very_soon_battle', N_('很快就好!')), ] }, - { themeId: 'emoticon_autochess_basic_2', dir: 'basic_2', sortId: 100001, isBasic: true, name: '表情套组:卫戍协议', emotes: [ - emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', '没问题!'), - emo('autochess_battle_respect', 1008, 'pic_respect_battle', '敬礼!'), - emo('autochess_battle_call', 1009, 'pic_call_battle', '欢呼!'), - emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', '酷!'), - emo('autochess_battle_sad', 1011, 'pic_sad_battle', '伤心'), - emo('autochess_battle_dying', 1012, 'pic_dying_battle', '快死了'), + { themeId: 'emoticon_autochess_basic_2', dir: 'basic_2', sortId: 100001, isBasic: true, name: N_('表情套组:卫戍协议'), emotes: [ + emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', N_('没问题!')), + emo('autochess_battle_respect', 1008, 'pic_respect_battle', N_('敬礼!')), + emo('autochess_battle_call', 1009, 'pic_call_battle', N_('欢呼!')), + emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', N_('酷!')), + emo('autochess_battle_sad', 1011, 'pic_sad_battle', N_('伤心')), + emo('autochess_battle_dying', 1012, 'pic_dying_battle', N_('快死了')), ] }, - { themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: '表情套组:博士士', emotes: [ - emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', '博士士 1'), - emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', '博士士 2'), - emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', '博士士 3'), - emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', '博士士 4'), - emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', '博士士 5'), - emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', '博士士 6'), + { themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: N_('表情套组:博士士'), emotes: [ + emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', N_('博士士 1')), + emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', N_('博士士 2')), + emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', N_('博士士 3')), + emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', N_('博士士 4')), + emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', N_('博士士 5')), + emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', N_('博士士 6')), ] }, - { themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: '表情套组:米米子', emotes: [ - emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', '米米子 1'), - emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', '米米子 2'), - emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', '米米子 3'), - emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', '米米子 4'), - emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', '米米子 5'), - emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', '米米子 6'), + { themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: N_('表情套组:米米子'), emotes: [ + emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', N_('米米子 1')), + emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', N_('米米子 2')), + emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', N_('米米子 3')), + emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', N_('米米子 4')), + emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', N_('米米子 5')), + emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', N_('米米子 6')), ] }, - { themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: '表情套组:维维美', emotes: [ - emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', '维维美 1'), - emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', '维维美 2'), - emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', '维维美 3'), - emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', '维维美 4'), - emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', '维维美 5'), - emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', '维维美 6'), + { themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: N_('表情套组:维维美'), emotes: [ + emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', N_('维维美 1')), + emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', N_('维维美 2')), + emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', N_('维维美 3')), + emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', N_('维维美 4')), + emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', N_('维维美 5')), + emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', N_('维维美 6')), ] }, ].map((t) => Object.freeze({ ...t, emotes: Object.freeze(t.emotes) }))); /** Every emote with its theme: `{ id, sortId, picId, label, themeId, dir }`, in wheel order. */ diff --git a/shared/diy.js b/shared/diy.js index fcb8d4ba..64b4ad02 100644 --- a/shared/diy.js +++ b/shared/diy.js @@ -155,12 +155,12 @@ export function diyPool(tier, { data, kitted = null } = { data: null }) { */ export function checkDiyPick(slotId, pick, data) { const slot = diySlot(slotId, data); - if (!slot) return { error: `not a 自选 slot: ${slotId}` }; + if (!slot) return { error: `not a 自选 slot: ${slotId}` }; // i18n-ignore: developer detail if (!isObj(pick) || typeof pick.charId !== 'string' || !ID.test(pick.charId)) return { error: `${slot.baseId}: bad pick` }; const { charId } = pick; const diy = diyOf(data); const proto = isPrototypePick(data, slot.tier, charId); - if (!proto && !(diy.ownedPool ?? []).includes(charId)) return { error: `${charId} is not a tier-${slot.tier} 自选 pick` }; + if (!proto && !(diy.ownedPool ?? []).includes(charId)) return { error: `${charId} is not a tier-${slot.tier} 自选 pick` }; // i18n-ignore: developer detail const want = pick.skillIndex ?? null; const wantMod = pick.uniEquipId === undefined || pick.uniEquipId === 'none' ? null : pick.uniEquipId; if (want !== null && !(Number.isInteger(want) && want >= 0 && want <= 9)) return { error: `${charId}: bad skill index ${want}` }; @@ -172,7 +172,7 @@ export function checkDiyPick(slotId, pick, data) { const lk = lockedSelection(data, slot.tier, charId); if (!lk) return { error: `${charId}: no locked selection at tier ${slot.tier}` }; // omitted / null = its locked selection; another skill, another module or an explicit 'none' is refused - if (want !== null && want !== lk.skillIndex) return { error: `${charId}: a prototype carries skill ${lk.skillIndex} (与系统补位时一致)` }; + if (want !== null && want !== lk.skillIndex) return { error: `${charId}: a prototype carries skill ${lk.skillIndex} (与系统补位时一致)` }; // i18n-ignore: developer detail if (pick.uniEquipId != null && wantMod !== lk.uniEquipId) return { error: `${charId}: a prototype carries module ${lk.uniEquipId ?? 'none'}` }; ({ skillIndex, uniEquipId } = lk); } else { diff --git a/shared/highGround.js b/shared/highGround.js index ff025a14..cdb3b274 100644 --- a/shared/highGround.js +++ b/shared/highGround.js @@ -10,7 +10,7 @@ // §战斗部署), and is not read. The battle keeps position MELEE: on a 高台 the unit attacks and blocks nothing. /** The trait line that lets a MELEE chess stand on a ranged (高台) tile. */ -export const PLACE_ON_RANGED = '可以放置于远程位'; +export const PLACE_ON_RANGED = '可以放置于远程位'; // i18n-ignore: matched against the data's trait text /** * The record's trait text without a module ('' when it has none). diff --git a/test/i18n.test.js b/test/i18n.test.js index 71e8db0d..e250c22d 100644 --- a/test/i18n.test.js +++ b/test/i18n.test.js @@ -184,4 +184,12 @@ test('tools/i18n.mjs: msgids of template literals name their params; the codemod const aliased = await scanSource("import { t as tr } from '../../../shared/i18n.js';\nconst t = 3;\nexport const f = () => tr('替补') + t;", 'x.js'); assert.deepEqual(aliased.msgids.map((m) => [m.msgid, m.via]), [['替补', 't']]); assert.deepEqual(aliased.literals.filter((l) => !l.reason), []); + // developer text is not UI text: an error result's detail, a logger call, a file marked i18n-ignore-file + const dev = await scanSource([ + "export const a = (x) => ({ error: 'BAD_TARGET', detail: `不是自选格子 ${x ? '甲' : '乙'}` });", + "export function b(ev, log) { ev.detail = '已经是精锐'; log.warn(`[match] 自选 被忽略`); this.m.log?.info?.('信息'); return '界面文字'; }", + ].join('\n'), 'x.js'); + assert.deepEqual(dev.literals.filter((l) => !l.reason).map((l) => l.msgid), ['界面文字']); + const whole = await scanSource("// (i18n-ignore-file: developer reports)\nexport const r = ['开发者报告', `第${1}条`];", 'x.js'); + assert.deepEqual(whole.literals.filter((l) => !l.reason), []); }); diff --git a/tools/i18n.mjs b/tools/i18n.mjs index d53787df..3e122c08 100644 --- a/tools/i18n.mjs +++ b/tools/i18n.mjs @@ -143,6 +143,17 @@ const calleeName = (call) => { return null; }; const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console'; +/** A logger call (`log.warn(…)`, `this.m.log?.warn?.(…)`): developer text, like console.*. */ +const isLogCall = (call) => { + const c = call?.callee; + if (!c || c.type !== 'MemberExpression') return false; + const o = c.object; + const LOG = /^(log|logger)$/; + return (o.type === 'Identifier' && LOG.test(o.name)) || (o.type === 'MemberExpression' && !o.computed && o.property.type === 'Identifier' && LOG.test(o.property.name)); +}; +/** The developer detail of an error result (`{ error: 'BAD_TARGET', detail: '…' }`, `ev.detail = '…'`): never shown to players. */ +const isDetail = (node, parent) => (parent.type === 'Property' && parent.value === node && !parent.computed && (parent.key.name ?? parent.key.value) === 'detail') + || (parent.type === 'AssignmentExpression' && parent.right === node && parent.left.type === 'MemberExpression' && !parent.left.computed && parent.left.property.name === 'detail'); /** Why a literal must not be wrapped (null = wrap it). `aliases`: local name → shared/i18n.js export (`t as tr`). */ function skipReason(node, ancestors, aliases = new Map()) { @@ -150,7 +161,7 @@ function skipReason(node, ancestors, aliases = new Map()) { for (let i = ancestors.length - 1; i >= 0; i--) { const a = ancestors[i]; if (a.type === 'CallExpression') { - if (isConsoleCall(a)) return 'console'; + if (isConsoleCall(a) || isLogCall(a)) return 'console'; const name = calleeName(a); if (a.callee.type === 'Identifier' && SKIP_CALLS.has(aliases.get(name) ?? name)) return 'in-call'; } @@ -159,6 +170,8 @@ function skipReason(node, ancestors, aliases = new Map()) { if (a.type === 'ImportDeclaration' || a.type === 'ExportAllDeclaration' || (a.type === 'ExportNamedDeclaration' && a.source)) return 'import'; } if (!parent) return null; + // (inside a detail too: `detail: \`… ${x ? '甲' : '乙'}\``) + if (isDetail(node, parent) || ancestors.some((a, i) => i > 0 && isDetail(a, ancestors[i - 1]))) return 'detail'; if (parent.type === 'Property' && parent.key === node && !parent.computed) return 'key'; if (parent.type === 'MemberExpression' && parent.property === node) return 'key'; if (parent.type === 'BinaryExpression' && ['===', '!==', '==', '!=', 'in', 'instanceof'].includes(parent.operator)) return 'compare'; @@ -333,6 +346,8 @@ export async function scanSource(src, file = '') { const fnName = (callee) => (callee.type === 'Identifier' ? aliases.get(callee.name) ?? callee.name : null); // a line marked `i18n-ignore` (in a comment) holds no UI text (font samples, data keys …) const ignored = new Set(src.split('\n').map((l, i) => (/i18n-ignore/.test(l) ? i + 1 : 0)).filter(Boolean)); + // a file marked `i18n-ignore-file` (developer reports, bilingual error pages) holds no UI text to translate + const fileIgnored = /\/\/[^\n]*i18n-ignore-file/.test(src); walk(ast, (node, ancestors) => { if (node.type === 'CallExpression' && fnName(node.callee) === 'tc') { const [c, a] = node.arguments; @@ -347,7 +362,8 @@ export async function scanSource(src, file = '') { if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') { for (const seg of htmlSegments(node.quasi)) { const { msgid, params } = templateMsgid(seg.quasis, seg.exprs.map((e) => src.slice(e.start, e.end))); - literals.push({ kind: seg.kind, attr: seg.attr, start: seg.start, end: seg.end, line: lineOf(seg.start), msgid, params, exprs: seg.exprs.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason: null }); + const line = lineOf(seg.start); + literals.push({ kind: seg.kind, attr: seg.attr, start: seg.start, end: seg.end, line, msgid, params, exprs: seg.exprs.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason: fileIgnored || ignored.has(line) ? 'ignored' : null }); } // expressions inside are walked normally (nested html``, strings in ${…}) return true; @@ -365,7 +381,7 @@ export async function scanSource(src, file = '') { return true; } const line = lineOf(node.start); - const reason = ignored.has(line) ? 'ignored' : skipReason(node, ancestors, aliases); + const reason = fileIgnored || ignored.has(line) ? 'ignored' : skipReason(node, ancestors, aliases); literals.push({ kind: 'str', start: node.start, end: node.end, line, msgid: node.value, params: [], module: !inFunction(ancestors), reason }); return true; } @@ -374,7 +390,7 @@ export async function scanSource(src, file = '') { if (parent && parent.type === 'TaggedTemplateExpression') return true; const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw); if (!cooked.some((s) => HAN.test(s))) return true; - const reason = ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors, aliases); + const reason = fileIgnored || ignored.has(lineOf(node.start)) ? 'ignored' : skipReason(node, ancestors, aliases); const { msgid, params } = templateMsgid(cooked, node.expressions.map((e) => src.slice(e.start, e.end))); literals.push({ kind: 'tpl', start: node.start, end: node.end, line: lineOf(node.start), msgid, params, exprs: node.expressions.map((e) => [e.start, e.end]), module: !inFunction(ancestors), reason }); return true;