feedback5 W5S: i18n phase 2, shared and server texts — shared/constants.js msgid tables (difficulties, phases, error codes, emote sets and labels) marked N_(); what is not player text is marked and skipped by the audit: error details and logger lines (tools/i18n.mjs reads detail: values, log.* calls and i18n-ignore-file), the developer invariant reports, the bilingual HTTP error pages, the AI seats' player names, the dev STUB match, data keys / fallbacks; the 虫动 emote set follows the official EN name (Slug Style); node tools/i18n.mjs extract now reports 0

This commit is contained in:
sganggs committed 2026-10-06 18:21:43 +08:00
1 parent 7e262dc809
commit 7697b4ff40
19 files changed
+102 -70

No files matched your search

+1 -1
View File
@@ -745,7 +745,7 @@
"谢谢": "Thanks", "谢谢": "Thanks",
"思考": "Thinking", "思考": "Thinking",
"合作愉快": "Nice teamwork", "合作愉快": "Nice teamwork",
"表情套组:虫动": "Emote Set: Slug Moves", "表情套组:虫动": "Emote Set: Slug Style",
"合作愉快!": "Nice teamwork!", "合作愉快!": "Nice teamwork!",
"谢谢!": "Thanks!", "谢谢!": "Thanks!",
"对不起!": "Sorry!", "对不起!": "Sorry!",
+1
View File
@@ -1,4 +1,5 @@
// server/http/common.js — what every HTTP answer shares: // 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); // * 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 // * splitUrl (raw path + query, also from absolute-form URLs), the bilingual error page (sendError) and JSON replies
+1
View File
@@ -1,4 +1,5 @@
// server/http/media.js — the extension-less audio route /media/… → public/assets/audio (see serveMedia). // 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 fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
+1
View File
@@ -1,4 +1,5 @@
// server/http/routes.js — the node:http request listener. Every response gets the security headers (common.js), then: // 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; // * a URL longer than 4096 characters → 414; one that does not parse → 400;
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`; // * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
+1
View File
@@ -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): // 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) // / → public/ (index.html for directories)
// /data/ → data/ (generated game data) // /data/ → data/ (generated game data)
+1 -1
View File
@@ -119,7 +119,7 @@ export const LOBBY_DEFAULTS = Object.freeze({
export const SOLO_RECONNECT_FALLBACK_SEC = 86_400; export const SOLO_RECONNECT_FALLBACK_SEC = 86_400;
/** Display names for AI teammates (the tutorial NPCs first, then a few familiar faces). */ /** 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 OK = Object.freeze({ ok: true });
const fail = (code, detail) => (detail ? { error: code, detail } : { error: code }); const fail = (code, detail) => (detail ? { error: code, detail } : { error: code });
+1 -1
View File
@@ -225,7 +225,7 @@ export class StubMatch {
})), })),
fields: [], fields: [],
stub: true, stub: true,
message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。', message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。', // i18n-ignore: development placeholder
}; };
} }
+1
View File
@@ -1,4 +1,5 @@
// server/match/audit.js — rule auditor for sweeps and tests (tools/matchrun.mjs --check, test/match/fullmatch.test.js). // 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 // 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: // is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js:
+1 -1
View File
@@ -91,7 +91,7 @@ const ITEM_HANDLERS = {
const n = int(paramsOf(ctx, ev.item).count, 1); const n = int(paramsOf(ctx, ev.item).count, 1);
const rec = ctx.gd.item(ev.item.id); const rec = ctx.gd.item(ev.item.id);
ctx.addEffect({ 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 }, iconKind: 'item', iconId: rec ? rec.iconId || rec.id : ev.item.id, battle: false, params: { count: n },
}); });
}, },
+3 -3
View File
@@ -63,7 +63,7 @@
import { weightedPick } from './waves.js'; 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>每场</>作战": * 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); const r = Number(c && c.rounds);
return Math.max(1, Math.min(99, Number.isInteger(r) ? r : 1)); 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 * 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 * "接下来<@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) { export function bountyText(text, c) {
if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text; if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text;
const n = MULTI_ROUND_BOUNTY_BATTLES; const n = MULTI_ROUND_BOUNTY_BATTLES;
const battles = `${N_ZH[n] || n}场作战`; const battles = `${N_ZH[n] || n}场作战`; // i18n-ignore
return text return text
.replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}</>`) .replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}</>`)
.replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`); .replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`);
+1
View File
@@ -1,4 +1,5 @@
// server/match/invariants.js — engine invariants (DESIGN §11) as a non-throwing check. Used by the test harness // 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. // (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): // collectViolations(m) → string[] (empty when every invariant holds):
+1 -1
View File
@@ -166,7 +166,7 @@ export class MatchPhases {
if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY); if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY);
if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN); 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 (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); this._applyBand(ps, bandId);
return OK; return OK;
} }
+1 -1
View File
@@ -112,7 +112,7 @@ export class MatchSpDraft {
const rounds = bountyBattles(card); const rounds = bountyBattles(card);
const b = { const b = {
id: `bounty:${this.nextUid()}`, 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, roundsLeft: rounds,
}; };
ps.bounties.push(b); ps.bounties.push(b);
+2 -2
View File
@@ -42,8 +42,8 @@ export class PlayerViews {
const left = b.roundsLeft >= 90 ? null : b.roundsLeft; const left = b.roundsLeft >= 90 ? null : b.roundsLeft;
const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null; const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null;
out.push({ 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', 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} 场作战`, counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`, // i18n-ignore: older clients (ui/effectsList.js builds it from counter)
}); });
} }
return out; return out;
+53 -51
View File
@@ -1,5 +1,7 @@
// Shared enums & constants (server + browser). Pure ESM, no Node APIs. // Shared enums & constants (server + browser). Pure ESM, no Node APIs.
import { N_ } from './i18n.js';
export const PROTOCOL_VERSION = 1; export const PROTOCOL_VERSION = 1;
/** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version" /** 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. */ * (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 NAME_MAX_LEN = 12;
export const DIFFICULTIES = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS']; 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' }; 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 // 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 = { export const PHASE_NAMES = {
LOBBY: '等待中', INFO_CHECK: '确认本局信息', BAND_DRAFT: '选择策略', BATTLE_CHECK: '协议启动', LOBBY: N_('等待中'), INFO_CHECK: N_('确认本局信息'), BAND_DRAFT: N_('选择策略'), BATTLE_CHECK: N_('协议启动'),
ROUND_START: '回合开始', SP_DRAFT: '机变阶段', PREP: '休整期', COMBAT: '作战中', UNITE: '联防阶段', ROUND_START: N_('回合开始'), SP_DRAFT: N_('机变阶段'), PREP: N_('休整期'), COMBAT: N_('作战中'), UNITE: N_('联防阶段'),
SETTLE: '结算', FINAL_ASSAULT: '最终攻势', HIDDEN_CORE: '隐秘核心', RESULT: '模拟结束', SETTLE: N_('结算'), FINAL_ASSAULT: N_('最终攻势'), HIDDEN_CORE: N_('隐秘核心'), RESULT: N_('模拟结束'),
}; };
// Board geometry on the 19x21 stage grid (row 0 = bottom). See DESIGN §3. // 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 = { export const ERR_TEXT = {
BAD_MSG: '无效的请求', RATE: '操作过于频繁', NOT_IN_ROOM: '你不在房间中', ROOM_NOT_FOUND: '未找到该同盟密钥对应的房间', BAD_MSG: N_('无效的请求'), RATE: N_('操作过于频繁'), NOT_IN_ROOM: N_('你不在房间中'), ROOM_NOT_FOUND: N_('未找到该同盟密钥对应的房间'),
ROOM_FULL: '房间已满', ROOM_STARTED: '模拟已开始', NOT_HOST: '只有房主可以操作', NOT_READY: '仍有玩家未就绪', ROOM_FULL: N_('房间已满'), ROOM_STARTED: N_('模拟已开始'), NOT_HOST: N_('只有房主可以操作'), NOT_READY: N_('仍有玩家未就绪'),
WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限', WRONG_PHASE: N_('当前阶段无法进行该操作'), NO_FUNDS: N_('资金不足'), HAND_FULL: N_('整备区已满'), BOARD_FULL: N_('已达到部署上限'),
BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级', BAD_TILE: N_('无法部署在该位置'), BAD_TARGET: N_('无效的目标'), SOLD_OUT: N_('已售出'), MAX_LEVEL: N_('调度中心已达最高等级'),
NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰', NOT_YOUR_TURN: N_('尚未轮到你'), ALREADY: N_('已完成该操作'), TEMP_NOT_EMPTY: N_('临时整备区不为空'), ELIMINATED: N_('你已被淘汰'),
SPECTATOR: '观战中无法进行该操作', INTERNAL: '服务器内部错误', SPECTATOR: N_('观战中无法进行该操作'), INTERNAL: N_('服务器内部错误'),
}; };
// ---- Emotes (交流, research 09 §4) ----------------------------------------------------------------------------- // ---- 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). // 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 }); const emo = (id, sortId, picId, label) => Object.freeze({ id, sortId, picId, label });
export const EMOTE_THEMES = Object.freeze([ export const EMOTE_THEMES = Object.freeze([
{ themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: '表情套组:卫戍协议', emotes: [ { themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: N_('表情套组:卫戍协议'), emotes: [
emo('autochess_battle_happy', 1001, 'pic_happy_battle', '开心'), emo('autochess_battle_happy', 1001, 'pic_happy_battle', N_('开心')),
emo('autochess_battle_scared', 1002, 'pic_scared_battle', '害怕'), emo('autochess_battle_scared', 1002, 'pic_scared_battle', N_('害怕')),
emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', '对不起'), emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', N_('对不起')),
emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', '谢谢'), emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', N_('谢谢')),
emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', '思考'), emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', N_('思考')),
emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', '合作愉快'), emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', N_('合作愉快')),
] }, ] },
{ themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: '表情套组:虫动', emotes: [ { themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: N_('表情套组:虫动'), emotes: [
emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', '合作愉快!'), emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', N_('合作愉快!')),
emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', '谢谢!'), emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', N_('谢谢!')),
emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', '对不起!'), emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', N_('对不起!')),
emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', '再见!'), emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', N_('再见!')),
emo('slug_autochess_battle_distrust', 2005, 'pic_distrust_battle', '???'), 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: [ { themeId: 'emoticon_autochess_basic_2', dir: 'basic_2', sortId: 100001, isBasic: true, name: N_('表情套组:卫戍协议'), emotes: [
emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', '没问题!'), emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', N_('没问题!')),
emo('autochess_battle_respect', 1008, 'pic_respect_battle', '敬礼!'), emo('autochess_battle_respect', 1008, 'pic_respect_battle', N_('敬礼!')),
emo('autochess_battle_call', 1009, 'pic_call_battle', '欢呼!'), emo('autochess_battle_call', 1009, 'pic_call_battle', N_('欢呼!')),
emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', '酷!'), emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', N_('酷!')),
emo('autochess_battle_sad', 1011, 'pic_sad_battle', '伤心'), emo('autochess_battle_sad', 1011, 'pic_sad_battle', N_('伤心')),
emo('autochess_battle_dying', 1012, 'pic_dying_battle', '快死了'), emo('autochess_battle_dying', 1012, 'pic_dying_battle', N_('快死了')),
] }, ] },
{ themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: '表情套组:博士士', emotes: [ { themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: N_('表情套组:博士士'), emotes: [
emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', '博士士 1'), emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', N_('博士士 1')),
emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', '博士士 2'), emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', N_('博士士 2')),
emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', '博士士 3'), emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', N_('博士士 3')),
emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', '博士士 4'), emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', N_('博士士 4')),
emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', '博士士 5'), emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', N_('博士士 5')),
emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', '博士士 6'), emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', N_('博士士 6')),
] }, ] },
{ themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: '表情套组:米米子', emotes: [ { themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: N_('表情套组:米米子'), emotes: [
emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', '米米子 1'), emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', N_('米米子 1')),
emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', '米米子 2'), emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', N_('米米子 2')),
emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', '米米子 3'), emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', N_('米米子 3')),
emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', '米米子 4'), emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', N_('米米子 4')),
emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', '米米子 5'), emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', N_('米米子 5')),
emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', '米米子 6'), emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', N_('米米子 6')),
] }, ] },
{ themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: '表情套组:维维美', emotes: [ { themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: N_('表情套组:维维美'), emotes: [
emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', '维维美 1'), emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', N_('维维美 1')),
emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', '维维美 2'), emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', N_('维维美 2')),
emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', '维维美 3'), emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', N_('维维美 3')),
emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', '维维美 4'), emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', N_('维维美 4')),
emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', '维维美 5'), emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', N_('维维美 5')),
emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', '维维美 6'), emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', N_('维维美 6')),
] }, ] },
].map((t) => Object.freeze({ ...t, emotes: Object.freeze(t.emotes) }))); ].map((t) => Object.freeze({ ...t, emotes: Object.freeze(t.emotes) })));
/** Every emote with its theme: `{ id, sortId, picId, label, themeId, dir }`, in wheel order. */ /** Every emote with its theme: `{ id, sortId, picId, label, themeId, dir }`, in wheel order. */
+3 -3
View File
@@ -155,12 +155,12 @@ export function diyPool(tier, { data, kitted = null } = { data: null }) {
*/ */
export function checkDiyPick(slotId, pick, data) { export function checkDiyPick(slotId, pick, data) {
const slot = diySlot(slotId, 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` }; if (!isObj(pick) || typeof pick.charId !== 'string' || !ID.test(pick.charId)) return { error: `${slot.baseId}: bad pick` };
const { charId } = pick; const { charId } = pick;
const diy = diyOf(data); const diy = diyOf(data);
const proto = isPrototypePick(data, slot.tier, charId); 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 want = pick.skillIndex ?? null;
const wantMod = pick.uniEquipId === undefined || pick.uniEquipId === 'none' ? null : pick.uniEquipId; 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}` }; 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); const lk = lockedSelection(data, slot.tier, charId);
if (!lk) return { error: `${charId}: no locked selection at tier ${slot.tier}` }; 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 // 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'}` }; if (pick.uniEquipId != null && wantMod !== lk.uniEquipId) return { error: `${charId}: a prototype carries module ${lk.uniEquipId ?? 'none'}` };
({ skillIndex, uniEquipId } = lk); ({ skillIndex, uniEquipId } = lk);
} else { } else {
+1 -1
View File
@@ -10,7 +10,7 @@
// §战斗部署), and is not read. The battle keeps position MELEE: on a 高台 the unit attacks and blocks nothing. // §战斗部署), 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. */ /** 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). * The record's trait text without a module ('' when it has none).
+8
View File
@@ -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'); 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.msgids.map((m) => [m.msgid, m.via]), [['替补', 't']]);
assert.deepEqual(aliased.literals.filter((l) => !l.reason), []); 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), []);
}); });
+20 -4
View File
@@ -143,6 +143,17 @@ const calleeName = (call) => {
return null; return null;
}; };
const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console'; 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`). */ /** 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()) { 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--) { for (let i = ancestors.length - 1; i >= 0; i--) {
const a = ancestors[i]; const a = ancestors[i];
if (a.type === 'CallExpression') { if (a.type === 'CallExpression') {
if (isConsoleCall(a)) return 'console'; if (isConsoleCall(a) || isLogCall(a)) return 'console';
const name = calleeName(a); const name = calleeName(a);
if (a.callee.type === 'Identifier' && SKIP_CALLS.has(aliases.get(name) ?? name)) return 'in-call'; 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 (a.type === 'ImportDeclaration' || a.type === 'ExportAllDeclaration' || (a.type === 'ExportNamedDeclaration' && a.source)) return 'import';
} }
if (!parent) return null; 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 === 'Property' && parent.key === node && !parent.computed) return 'key';
if (parent.type === 'MemberExpression' && parent.property === node) return 'key'; if (parent.type === 'MemberExpression' && parent.property === node) return 'key';
if (parent.type === 'BinaryExpression' && ['===', '!==', '==', '!=', 'in', 'instanceof'].includes(parent.operator)) return 'compare'; if (parent.type === 'BinaryExpression' && ['===', '!==', '==', '!=', 'in', 'instanceof'].includes(parent.operator)) return 'compare';
@@ -333,6 +346,8 @@ export async function scanSource(src, file = '<src>') {
const fnName = (callee) => (callee.type === 'Identifier' ? aliases.get(callee.name) ?? callee.name : null); 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 …) // 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)); 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) => { walk(ast, (node, ancestors) => {
if (node.type === 'CallExpression' && fnName(node.callee) === 'tc') { if (node.type === 'CallExpression' && fnName(node.callee) === 'tc') {
const [c, a] = node.arguments; const [c, a] = node.arguments;
@@ -347,7 +362,8 @@ export async function scanSource(src, file = '<src>') {
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') { if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
for (const seg of htmlSegments(node.quasi)) { for (const seg of htmlSegments(node.quasi)) {
const { msgid, params } = templateMsgid(seg.quasis, seg.exprs.map((e) => src.slice(e.start, e.end))); 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 ${…}) // expressions inside are walked normally (nested html``, strings in ${…})
return true; return true;
@@ -365,7 +381,7 @@ export async function scanSource(src, file = '<src>') {
return true; return true;
} }
const line = lineOf(node.start); 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 }); literals.push({ kind: 'str', start: node.start, end: node.end, line, msgid: node.value, params: [], module: !inFunction(ancestors), reason });
return true; return true;
} }
@@ -374,7 +390,7 @@ export async function scanSource(src, file = '<src>') {
if (parent && parent.type === 'TaggedTemplateExpression') return true; if (parent && parent.type === 'TaggedTemplateExpression') return true;
const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw); const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw);
if (!cooked.some((s) => HAN.test(s))) return true; 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))); 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 }); 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; return true;