diff --git a/public/css/screens/title.css b/public/css/screens/title.css
index 63357d95..1805b46c 100644
--- a/public/css/screens/title.css
+++ b/public/css/screens/title.css
@@ -192,6 +192,8 @@
animation: rise-in 600ms 140ms var(--ease-out) backwards;
}
.title-cn__colon { color: #f4f6f5; }
+/* English title (ui/lang.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */
+.title-cn--latin { font-family: var(--font-display); font-size: .6rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
.title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); }
.title-tag {
margin-top: .12rem;
@@ -241,6 +243,10 @@
.title-invite .icon { width: .2rem; height: .2rem; }
.title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); }
+/* language switch (中文 | English) in the top-right corner */
+.title-lang { display: inline-flex; margin-bottom: .1rem; }
+.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
+html[data-lang="en"] .title-login .btn--xl { letter-spacing: .3em; }
.title-conn .ping { height: .26rem; font-size: .15rem; }
.title-foot {
diff --git a/public/i18n/en.json b/public/i18n/en.json
new file mode 100644
index 00000000..411a899b
--- /dev/null
+++ b/public/i18n/en.json
@@ -0,0 +1,849 @@
+{
+ "_about": "English UI strings: Chinese msgid → English (shared/i18n.js t(); docs/I18N.md). Placeholders {name} and {n|one|other} as in the msgid; a 'context::msgid' key is a tc() variant. Seeded from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (NOTICE.md). Game texts are not here: data/i18n/en.json (tools/build-i18n.mjs).",
+ "你已在其他同盟中,请先离开当前同盟": "You are already in another Alliance. Leave it first.",
+ "你已不在该同盟中": "You are no longer in this Alliance",
+ "创建者已离开,同盟已解散": "The host left. The Alliance has been disbanded.",
+ "由于长时间断开连接,你已离开同盟": "You left the Alliance after being disconnected for too long",
+ "同盟已解散": "The Alliance has been disbanded",
+ "你已被移出同盟": "You were removed from the Alliance",
+ "模拟已结束": "Simulation Over",
+ "同盟已过期": "The Alliance has expired",
+ "服务器维护中,同盟已关闭": "Server maintenance in progress. The Alliance has been closed.",
+ "该身份已在其他页面登录,本页已断开": "This identity signed in on another page. This page has been disconnected.",
+ "同盟已关闭:{reason}": "Alliance closed: {reason}",
+ "同盟已关闭": "Alliance closed",
+ "界面发生错误": "An interface error occurred",
+ "重新加载界面": "Reload Interface",
+ "启动失败,请刷新页面重试": "Failed to start. Please refresh the page and try again.",
+ "时长较短的模拟训练": "A shorter simulation exercise",
+ "可以快速完成作战": "Battles can be completed quickly",
+ "常规奖励": "Standard rewards",
+ "敌方攻击强度较高的模拟训练": "A simulation exercise with high enemy strength",
+ "可使用盟约数增加": "More Alliances available",
+ "大幅增加奖励": "Greatly increased rewards",
+ "敌方攻击强度极高的模拟训练": "A simulation exercise with very high enemy strength",
+ "作战环境困难": "Harsh combat environment",
+ "出现更加危险的敌人": "More dangerous enemies appear",
+ "敌方攻击强度到达极限的模拟训练": "A simulation exercise with enemy strength pushed to the limit",
+ "作战环境无比困难": "Extremely harsh combat environment",
+ "出现极度危险的敌人": "Extremely dangerous enemies appear",
+ "敌方攻击强度较低的模拟训练": "A simulation exercise with low enemy strength",
+ "作战环境较为温和": "Mild combat environment",
+ "战场固定为 {name}": "Fixed Battlefield: {name}",
+ "战场固定": "Fixed Battlefield",
+ "战场随机(共{n}张)": "Random Battlefield ({n} total)",
+ "战场随机(共{stages}张)": "Random Battlefield ({stages} total)",
+ "独立模拟": "Solo Simulation",
+ "独自调配资金与干员,以自己的节奏完成整场模拟。": "Manage your own Funds and Operators and play the whole simulation at your own pace.",
+ "1 名博士": "1 Doctor",
+ "休整期与机变阶段不限时": "No time limit in the Rest Phase and Improv Phase",
+ "同盟模拟": "Team Simulation",
+ "联防阶段 · 最终攻势合并生命值": "Unite Phase · Shared LP from the Final Assault",
+ "联合模拟在选择策略时可以进行一次跳过": "In Team Simulation, you can skip once when choosing a Strategy",
+ "调度中心即使冻结,依然可以主动刷新": "Even when frozen, the Dispatch Center can still be refreshed manually",
+ "两件同名装备可以合成一件更强力的装备": "Two identical pieces of Equipment can be combined into a stronger one",
+ "只有达成完美作战的队友可以进行联防": "Only Teammates who achieve a Perfect Battle can Unite",
+ "作战提示": "Combat Tips",
+ "上一条": "Previous tip",
+ "下一条": "Next tip",
+ "已选定": "Selected",
+ "回合": "Round",
+ "+ 隐秘核心": "+ Hidden Core",
+ "尚未连接到服务器,请稍候": "Not connected to the server yet, please wait",
+ "同盟密钥为 {ROOM_CODE_LEN} 位字母或数字": "The Alliance Key is {ROOM_CODE_LEN} letters or digits",
+ "返回标题": "Back to Title",
+ "返回": "Back",
+ "选择模拟协议": "Select Simulation Protocol",
+ "玩法说明": "How to Play",
+ "干员调配": "Operator Loadout",
+ "博士": "Doctor",
+ "模拟方式": "Mode",
+ "加入同盟": "Join Alliance",
+ "输入同盟密钥 / 粘贴邀请链接": "Enter Alliance Key / paste invite link",
+ "观战": "Spectate",
+ "最近的同盟": "Recent Alliances",
+ "向同伴索取 {ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接": "Ask a teammate for the {ROOM_CODE_LEN}-character Alliance Key, or open the invite link directly",
+ "模拟难度": "Difficulty",
+ "正在连接服务器…": "Connecting to server...",
+ "开始独立模拟": "Start Solo Simulation",
+ "创建同盟": "Create Alliance",
+ "创建后即可开始模拟": "You can start the simulation right after creating it",
+ "创建后可邀请好友或添加 AI 队友": "After creating, invite friends or add AI Teammates",
+ "等待博士加入": "Waiting for a Doctor",
+ "添加 AI 队友": "Add AI Teammate",
+ "空位": "Empty",
+ "创建者": "Host",
+ "AI 队友": "AI Teammate",
+ "你": "You",
+ "已就绪": "Ready",
+ "连接中断": "Disconnected",
+ "待命中": "Standing by",
+ "准备中": "Preparing",
+ "移除该 AI 队友": "Remove this AI Teammate",
+ "移除 AI 队友": "Remove AI Teammate",
+ "已复制同盟密钥 {code}": "Alliance Key {code} copied",
+ "已复制邀请链接": "Invite link copied",
+ "复制失败,请手动复制": "Copy failed, please copy manually",
+ "同盟密钥": "Alliance Key",
+ "同盟密钥 {code}": "Alliance Key {code}",
+ "复制密钥": "Copy Key",
+ "复制链接": "Copy Link",
+ "由创建者选择": "Chosen by the Host",
+ "连接中断,请稍候重试": "Connection lost, please try again shortly",
+ "离开同盟": "Leave Alliance",
+ "你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "You are the Host of this Alliance. If you leave, host duties will be handed over or the Alliance will disband. Leave anyway?",
+ "离开": "Leave",
+ "连接中断,正在重连…": "Connection lost, reconnecting...",
+ "*模拟协议已就绪,准许进入模拟": "*Protocol ready to deploy",
+ "*同盟人数达标,准许进入模拟": "*Alliance ready to deploy",
+ "等待所有博士准备就绪": "Waiting for all Doctors to be ready",
+ "已就绪 · 等待创建者开始模拟": "Ready · waiting for the Host",
+ "准备就绪后,创建者即可开始模拟": "Get ready so the Host can start",
+ "当前延迟": "Current Ping",
+ "仅限 1 名博士": "1 Doctor only",
+ ",满足条件时进入隐秘核心": "; the Hidden Core is entered when conditions are met",
+ "独立模拟中休整期与机变阶段不限时": "In Solo Simulation, the Rest Phase and Improv Phase have no time limit",
+ "仍有博士未准备就绪": "Some Doctors are not ready yet",
+ "开始模拟": "Start Simulation",
+ "准备就绪": "Ready",
+ "准备连接": "Ready to connect",
+ "正在连接服务器": "Connecting to server",
+ "已连接服务器": "Connected to server",
+ "正在验证身份": "Verifying identity",
+ "连接中断,正在重连": "Connection lost, reconnecting",
+ "连接已关闭": "Connection closed",
+ "请输入博士代号": "Please enter your Doctor callsign",
+ "卫戍协议": "Stronghold Protocol",
+ "盟约": "Alliance",
+ "调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。": "Allocate Funds and Operators, coordinate defenses with your allies, hold off wave after wave, and defeat the Enemy Leader.",
+ "收到同盟邀请": "Alliance invite received",
+ "· 输入代号后将自动加入": "· You'll join automatically after entering your callsign",
+ "博士代号": "Doctor Callsign",
+ "输入你的代号(最多 {NAME_MAX_LEN} 字)": "Callsign (max {NAME_MAX_LEN} chars)",
+ "开始": "Start",
+ "非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "Unofficial fan remake · Game assets © Hypergryph / Yostar",
+ "高": "High",
+ "中": "Medium",
+ "低": "Low",
+ "刷新": "Refresh",
+ "冻结": "Freeze",
+ "升级": "Upgrade",
+ "设置": "Settings",
+ "完成": "Done",
+ "背景音乐": "Music",
+ "音效": "Sound Effects",
+ "静音": "Mute",
+ "显示伤害数字": "Damage Numbers",
+ "画面质量": "Graphics Quality",
+ "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "Touch controls: tap a unit to select it (Retreat / Sell) · long-press a unit or card for details · after dragging to deploy, swipe to choose a direction",
+ "快捷键:": "Hotkeys: ",
+ "发生未知错误": "An unknown error occurred",
+ "标准模拟": "Standard Simulation",
+ "险境模拟": "Perilous Simulation",
+ "绝境模拟": "Dire Simulation",
+ "终极模拟": "Ultimate Simulation",
+ "无效的请求": "Invalid request",
+ "操作过于频繁": "Too many actions, slow down",
+ "你不在房间中": "You are not in a room",
+ "未找到该同盟密钥对应的房间": "No room found for this Alliance Key",
+ "房间已满": "Room is full",
+ "模拟已开始": "Simulation already started",
+ "只有房主可以操作": "Only the host can do that",
+ "仍有玩家未就绪": "Some players are not ready",
+ "当前阶段无法进行该操作": "Cannot do that in this phase",
+ "资金不足": "Insufficient Funds",
+ "整备区已满": "Bench is full",
+ "已达到部署上限": "Deployment limit reached",
+ "无法部署在该位置": "Cannot deploy here",
+ "无效的目标": "Invalid target",
+ "已售出": "Sold Out",
+ "调度中心已达最高等级": "Dispatch Center is at max level",
+ "尚未轮到你": "It is not your turn yet",
+ "已完成该操作": "Already done",
+ "临时整备区不为空": "Temporary Bench is not empty",
+ "你已被淘汰": "You have been eliminated",
+ "服务器内部错误": "Internal server error",
+ "请求超时,请重试": "Request timed out. Please try again.",
+ "未连接到服务器": "Not connected to the server",
+ "连接已断开,请重试": "Connection lost. Please try again.",
+ "该身份已在其他页面登录": "This identity is signed in on another page",
+ "客户端版本与服务器不一致,请刷新页面": "Client version does not match the server. Please refresh the page.",
+ "队友": "Teammate",
+ "该队友已被淘汰,无法查看": "This Teammate has been eliminated and cannot be viewed",
+ "该队友当前没有战场": "This Teammate has no Battlefield right now",
+ "队友与你在同一战场,使用 ‹ › 切换视角": "Your Teammate is on your Battlefield. Use ‹ › to switch views.",
+ "无法查看另一组队友的战场": "Cannot view the other pair's Battlefield",
+ "作战中无法查看队友,作战结束后可前往查看": "Cannot view Teammates during Battle. You can view them once your Battle ends.",
+ "无人在家": "Nobody Home",
+ "你自己": "You",
+ "全景": "Overview",
+ "未知错误": "Unknown error",
+ "战场": "Battlefield",
+ "本局禁用": "Disabled this match",
+ "{skip};本局不限时": "{skip}; no time limit this match",
+ "每位博士有 {0} 秒": "each Doctor has {0} s",
+ "每位博士限时决策": "each Doctor has a time limit",
+ "当前选中的": "the currently highlighted ",
+ "已选择「{0}」{1}": "Selected \"{0}\"{1}",
+ ",等待其他博士": ", waiting for other Doctors",
+ "轮到你决策": "Your turn to decide",
+ "{turnName} 决策中": "{turnName} is deciding",
+ "等待轮到你": "Waiting for your turn",
+ "华法琳": "Warfarin",
+ "选择策略": "Choose Strategy",
+ "查看禁用盟约与干员": "View disabled Alliances & Operators",
+ "策略": "Strategy",
+ "决策顺序": "Pick Order",
+ "已选择": "Selected",
+ "决策中": "Deciding",
+ "等待中": "Waiting",
+ "队友已选": "Taken by a Teammate",
+ "初始生命值": "Initial LP",
+ "选择一个策略查看详情": "Select a Strategy to view details",
+ "队友已选,请选择其他策略": "Taken by a Teammate, please choose another Strategy",
+ "{turnName} 正在决策…": "{turnName} is deciding...",
+ "跳过本轮,稍后再选": "Skip this turn and pick later",
+ "跳过次数已用完": "No skips left",
+ "(已用)": " (used)",
+ "确认选择": "Confirm",
+ "战场随机(共{poolN}张)": "Random Battlefield ({poolN} total)",
+ "确认本局信息": "Confirm Match Info",
+ "ENEMY LEADER // 敌方领袖": "ENEMY LEADER // Enemy Leader",
+ "第": "Round",
+ "回合 · 最终攻势": "· Final Assault",
+ "特训敌人": "Tactical Training Enemies",
+ "未知领袖": "Unknown Leader",
+ "本局没有特训敌人": "No Tactical Training Enemies this match",
+ "本局模拟已结束": "This simulation has ended",
+ "同盟已返回等待室": "The Alliance has returned to the waiting room",
+ "返回同盟": "Back to Alliance",
+ "暂停中": "Paused",
+ "作战已暂停,计时与敌人行动均已停止": "Battle paused; the timer and enemy actions have stopped",
+ "放弃模拟": "Abandon Simulation",
+ "继续作战": "Resume Battle",
+ "正在载入模拟数据…": "Loading simulation data...",
+ "正在进入模拟…": "Entering simulation...",
+ "当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)",
+ "已配发的装备无法销毁": "Equipped Equipment cannot be destroyed",
+ "销毁道具": "Destroy Item",
+ "道具无法出售。确定要销毁「{0}」吗?": "Items cannot be sold. Destroy \"{0}\"?",
+ "道具": "Item",
+ "销毁": "Destroy",
+ "正在查看": "Viewing",
+ "的阵地(只读)": "'s Field (read-only)",
+ "返回自己": "Back to Mine",
+ "你已被淘汰 · 可继续观战队友": "You have been eliminated · You can keep spectating your Teammates",
+ "已调整": "Customized",
+ "不装备模组": "No Module",
+ "未知技能": "Unknown Skill",
+ "默认": "Default",
+ "已装备": "Equipped",
+ "初始": "Initial",
+ "消耗": "Cost",
+ "持续": "Duration",
+ "充能": "Charges",
+ "不装备模组:精锐干员以基础属性、特性与天赋作战。": "No Module: the Elite Operator fights with its base stats, Trait and Talents.",
+ "特性": "Trait",
+ "属性": "Stats",
+ "无属性加成": "No stat bonus",
+ "天赋": "Talent",
+ "局内数值": "In-Match Stats",
+ "数值版本": "Stat Variant",
+ "普通": "Normal",
+ "精锐": "Elite",
+ "不含技能发动、装备、盟约等局内加成": "excludes in-match bonuses such as skill activation, Equipment and Alliances",
+ "数值含所选模组;": "Includes the chosen Module; ",
+ "普通干员没有模组,所选模组在「精锐」中生效;": "Normal Operators have no Module; the chosen Module applies to the [Elite]; ",
+ "没有符合条件的干员": "No Operators match the filters",
+ "恢复默认": "Reset to Default",
+ "技能": "Skill",
+ "技能等级": "Skill Level",
+ "选择技能": "Choose Skill",
+ "默认配置": "Default Loadout",
+ "模组": "Module",
+ "仅精锐干员装备 · 模组等级": "Elite Operators only · Module Level",
+ "选择模组": "Choose Module",
+ "不装备": "None",
+ "本局的调配已锁定,修改将在下一局生效": "Loadout is locked for this match; changes will apply next match",
+ "阶级": "Tier",
+ "全部": "All",
+ "搜索干员 / 职业 / 盟约": "Search Operator / Class / Alliance",
+ "职业": "Class",
+ "按盟约筛选": "Filter by Alliance",
+ "全部盟约": "All Alliances",
+ "仅看已调整": "Customized Only",
+ "{t}阶": "Tier {t}",
+ "保存中…": "Saving…",
+ "同步中…": "Syncing…",
+ "已同步": "Synced",
+ "本局已锁定 · 下一局生效": "Locked for this match · applies next match",
+ "同步失败": "Sync failed",
+ "全部恢复默认": "Reset All to Default",
+ "已复制到剪贴板": "Copied to clipboard",
+ "复制失败,请在文本框中手动全选复制": "Copy failed, please select all in the text box and copy manually",
+ "文件过大,请选择「导出」下载的调配文件": "File too large. Please choose a loadout file downloaded via [Export]",
+ "读取文件失败": "Failed to read the file",
+ "干员数据仍在载入,请稍候再导入": "Operator data is still loading, please wait before importing",
+ "导入失败:这份数据在当前版本没有可用的调配,未做任何改动": "Import failed: this data has no loadout usable in the current version. Nothing was changed",
+ "确认本局信息阶段结束前可调整本局配置": "You can adjust this match's loadout until the match briefing ends",
+ "开始模拟前可调整干员携带的技能与模组,干员等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry; Operator levels cannot be changed",
+ "返回 (Esc)": "Back (Esc)",
+ "导出当前调配(可复制或下载)": "Export the current loadout (copy or download)",
+ "导出": "Export",
+ "导入调配(粘贴或选择文件)": "Import a loadout (paste or choose a file)",
+ "导入": "Import",
+ "调配截止": "Loadout Deadline",
+ "本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效": "Loadout is locked for this match (it cannot be changed after the briefing); changes will apply next match",
+ "正在载入干员数据(打开页面后仅载入一次)…": "Loading Operator data (only once per page load)…",
+ "干员列表": "Operator List",
+ "导出干员调配": "Export Operator Loadout",
+ "导入干员调配": "Import Operator Loadout",
+ "关闭": "Close",
+ "复制": "Copy",
+ "下载文件": "Download File",
+ "取消": "Cancel",
+ "选择文件": "Choose File",
+ "把导出的内容粘贴到下方,或点「选择文件」。": "Paste exported data below, or tap [Choose File]. ",
+ "覆盖": "overwrite",
+ "在此粘贴导出的调配内容…": "Paste the exported loadout here…",
+ "调整干员携带的技能与模组": "Change the Skills and Modules your Operators carry",
+ "造成伤害": "Damage Dealt",
+ "击倒敌人": "Enemies Defeated",
+ "领袖伤害": "Leader Damage",
+ "盟约层数": "Alliance Stacks",
+ "晋升次数": "Promotions",
+ "配发装备": "Equipment Equipped",
+ "消耗资金": "Funds Spent",
+ "完美作战": "Perfect Battles",
+ "刷新次数": "Refreshes",
+ "未击倒": "Leaks",
+ "损失生命": "LP Lost",
+ "阵容已撤离": "Lineup withdrawn",
+ "获得奖杯": "Trophies earned",
+ "卫戍认证": "Stronghold Certification",
+ "评语": "Title",
+ "最终攻势起全队共享目标生命值": "From the Final Assault on, the whole team shares LP",
+ "同盟剩余生命": "Alliance LP Left",
+ "剩余生命": "LP Left",
+ "通过回合": "Rounds Cleared",
+ "同盟成员": "Alliance Members",
+ "模拟完成": "Simulation Complete",
+ "模拟失败": "Simulation Failed",
+ "成功卫戍 · 敌方领袖已被击败": "Stronghold held · Enemy Leader defeated",
+ "防线已被突破": "The defense line has been breached",
+ "敌方领袖": "Enemy Leader",
+ "隐秘核心": "Hidden Core",
+ "本局耗时": "Match duration:",
+ "分钟": "min",
+ "返回大厅": "Back to Lobby",
+ "暂无结算数据": "No results data",
+ "服务器会话已重置,上一局模拟已结束": "Server session was reset. The previous simulation has ended.",
+ "服务器会话已重置,已返回大厅": "Server session was reset. Returned to the lobby.",
+ "正在查看 {owner} 的盟约": "Viewing {owner}'s Alliances",
+ "{owner} 尚未激活盟约": "{owner} has no active Alliances yet",
+ "部署干员以激活盟约": "Deploy Operators to activate Alliances",
+ "{owner} 的盟约": "{owner}'s Alliances",
+ "我的盟约": "My Alliances",
+ "(含调和 +{harmony})": " (incl. Harmony +{harmony})",
+ "层数叠加已禁用": "Stack accumulation disabled",
+ "调和": "Harmony",
+ "{0} 在场": "{0} on field",
+ "{harmonyName}已激活": "{harmonyName} active",
+ "层数": "Stacks",
+ "盟约效果": "Alliance Effect",
+ "成员": "Members",
+ "{owner} 的盟约:{name}": "{owner}'s Alliance: {name}",
+ "盟约:{name}": "Alliance: {name}",
+ "CORE BOND // 核心盟约": "CORE BOND // Core Alliance",
+ "ADD-ON BOND // 附加盟约": "ADD-ON BOND // Add-on Alliance",
+ "的盟约": "'s Alliance",
+ "在场": "On field",
+ "(含整备区)": "(incl. Bench)",
+ "(含{harmonyName} +{harmony})": "(incl. {harmonyName} +{harmony})",
+ "已激活{0}": "Active{0}",
+ " · {tier} 阶": " · Tier {tier}",
+ "未激活": "Inactive",
+ "本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)": "This Alliance never activates in this mode (its Operators may still appear through their other Alliances)",
+ "名及以下": " or fewer",
+ "当前效果": "Current Effect",
+ "({layers} 层)": "({layers} Stacks)",
+ "(变形同构体:视为本盟约成员)": " (Isomorph: counts as a member of this Alliance)",
+ "(本局禁用)": " (Disabled this match)",
+ "(在场)": " (On field)",
+ "(整备区)": " (Bench)",
+ "同构": "Iso",
+ "机变": "Improv",
+ "机变阶段": "Improv Phase",
+ "CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase",
+ "选择一项": "Choose one",
+ "倒计时结束后仍未选定将自动分配": "If no choice is made before the countdown ends, one will be assigned automatically",
+ "选择一项(无时间限制)": "Choose one (no time limit)",
+ " · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm",
+ "已完成选择": "Choice made",
+ "当前轮到你决策": "Your turn to decide",
+ "确认选择「{armedName}」(再次点击卡牌亦可)": "Confirm [{armedName}] (or tap the card again)",
+ "{name},已选中,再次点击确认": "{name}, selected, tap again to confirm",
+ "{name},{takerName}已选择": "{name}, selected by {takerName}",
+ "全队获得": "Whole team",
+ "赏金 {coin}": "Bounty {coin}",
+ "{name} 已选择": "Selected by {name}",
+ "再次点击": "Tap again",
+ "选择中": "Selecting",
+ "队友作战进度": "Teammate battle progress",
+ "作战结束": "Battle over",
+ "作战结束,等待队友完成作战": "Battle over. Waiting for teammates to finish",
+ "你已被淘汰,正在观战": "You have been eliminated. Spectating",
+ "上一个战场": "Previous battlefield",
+ "下一个战场": "Next battlefield",
+ "你已被淘汰,可点击队友头像前往查看": "You have been eliminated. Tap a teammate's avatar to watch",
+ "返回战场": "Return to Battlefield",
+ "左侧战场": "Left battlefield",
+ "右侧战场": "Right battlefield",
+ "禁用": "Disabled",
+ "无倒计时": "No countdown",
+ "剩余{remain}秒": "{remain}s remaining",
+ "确认": "Confirm",
+ "加载中": "Loading",
+ "当前延迟 {ms}ms": "Current latency: {ms}ms",
+ "未连接": "Not connected",
+ "正在同步同盟状态…": "Syncing Alliance state…",
+ "服务器已更新,本局结束后刷新": "The server was updated. The page will refresh after this match",
+ "刷新页面": "Refresh Page",
+ "与服务器的连接已中断,正在重连": "Connection to the server was lost. Reconnecting",
+ "立即重连": "Reconnect Now",
+ "在此页面继续": "Continue on This Page",
+ "重新连接": "Reconnect",
+ "重试": "Retry",
+ "第 {attempt} 次 · {secs}s": "Attempt {attempt} · {secs}s",
+ "先锋": "Vanguard",
+ "近卫": "Guard",
+ "重装": "Defender",
+ "狙击": "Sniper",
+ "术师": "Caster",
+ "医疗": "Medic",
+ "辅助": "Supporter",
+ "特种": "Specialist",
+ "召唤物": "Summon",
+ "自动回复": "Auto Recovery",
+ "攻击回复": "Offensive Recovery",
+ "受击回复": "Defensive Recovery",
+ "被动": "Passive",
+ "自动触发": "Auto Trigger",
+ "精英": "Elite",
+ "领袖": "Leader",
+ "物理": "Physical",
+ "法术": "Arts",
+ "治疗": "Healing",
+ "真实": "True",
+ "无": "None",
+ "攻击范围": "Attack Range",
+ "全场": "Whole field",
+ "基础 {0}": "Base {0}",
+ "当前作战中的实时数值(绿色为增益,红色为减益)": "Live values in the current battle (green: buffed, red: debuffed)",
+ "下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)": "Values at the start of the next battle: Equipment, Alliance stacks, Attributes, Strategy and Improv effects included (skills and temporary in-battle effects excluded)",
+ "实时": "Live",
+ "开战时": "At battle start",
+ "搭配以下装备时,携带者视为对应盟约的成员:": "When equipped with one of the following, the wearer counts as a member of that Alliance:",
+ "变形同构体对应关系": "Damazti Isomorph pairings",
+ "已搭配": "Paired",
+ "生效中": "Active",
+ "暂未生效:需与上表中的一件装备一同携带": "Not active yet: carry it together with one of the items above",
+ "所属盟约": "Alliances",
+ "(变形同构体)": " (Damazti Isomorph)",
+ ",已激活 {tier} 阶": ", active at tier {tier}",
+ ",未激活": ", inactive",
+ "特质": "Attribute",
+ "生命上限": "Max HP",
+ "攻击": "ATK",
+ "防御": "DEF",
+ "法术抗性": "RES",
+ "攻击间隔": "Attack Interval",
+ "阻挡数": "Block",
+ "部署费用": "DP Cost",
+ "再部署": "Redeploy Time",
+ "近战位": "Melee",
+ "远程位": "Ranged",
+ "技能 {skSlot}": "Skill {skSlot}",
+ "干员调配中选择的技能": "Skill chosen in Operator Loadout",
+ "已调配": "Customized",
+ "技力": "SP",
+ "· 消耗": "· Cost",
+ "干员调配中选择的模组": "Module chosen in Operator Loadout",
+ "装备": "Equipment",
+ "拖拽装备至该干员以配发(最多 2 件)": "Drag Equipment onto this Operator to equip it (up to 2)",
+ "出售": "Sell",
+ "可晋升:{hint}": "Can promote: {hint}",
+ "效果": "Effect",
+ "进阶": "Elite",
+ "奇术": "Arts",
+ "将其拖拽至战场上的格子使用": "Drag it onto a tile of the battlefield to use it",
+ ";2 件相同装备自动合成进阶装备": "; 2 identical pieces merge into Elite Equipment",
+ "效果获得": "gained from effects",
+ "晕眩": "Stun",
+ "沉默": "Silence",
+ "沉睡": "Sleep",
+ "浮空": "Levitate",
+ "移动速度": "Move Speed",
+ "目标价值": "LP Damage",
+ "空中": "Aerial",
+ "地面": "Ground",
+ "近战": "Melee",
+ "能力": "Abilities",
+ "说明": "Description",
+ "作战开始时在摆放的位置部署": "Deploys on its placed tile when the battle starts",
+ "作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)": "Deploys once on its placed tile when the battle starts, then reappears each time its Operator activates a skill (does not appear if not placed)",
+ "所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)": "Appears on its placed tile only when its Operator activates a skill (does not appear if not placed)",
+ "出售精锐干员": "Sell Elite Operator",
+ "确定要出售精锐干员「{0}」吗?出售后获得 {1} 资金。": "Sell the Elite Operator \"{0}\"? You will get {1} Funds.",
+ "详情": "Details",
+ "退出全屏": "Exit Fullscreen",
+ "全屏": "Fullscreen",
+ "团队增益": "Team Buff",
+ "生效中的效果": "Active effects",
+ "表情": "Emote",
+ "交流": "Chat",
+ "上一组表情": "Previous emotes",
+ "下一组表情": "Next emotes",
+ "表情主题": "Emote themes",
+ "悬赏": "Bounty",
+ "护卫": "Escort",
+ "回合 · 即将迎击": "·",
+ "名敌人": "incoming enemies",
+ "暂无敌方情报": "No enemy intel yet",
+ "本局禁用干员情况": "Banned Operators this match",
+ "我的策略": "My Strategy",
+ "战场:": "Battlefield:",
+ "该盟约中被禁用的干员数": "Banned Operators in this Alliance",
+ "本局没有禁用盟约": "No Alliances banned this match",
+ "本局信息": "Match Info",
+ "敌方情报": "Enemy Intel",
+ "该干员": "This Operator",
+ "将被销毁": "Will be destroyed",
+ "替换装备": "Replace Equipment",
+ "」的装备栏已满(2/2)。选择一件装备进行替换,": "]'s Equipment slots are full (2/2). Choose a piece of Equipment to replace.",
+ "被替换的装备将被销毁": "The replaced Equipment will be destroyed",
+ "新装备": "New Equipment",
+ "选择被替换的装备": "Choose Equipment to replace",
+ "将销毁「": "Destroy [",
+ "」并配发「": "] and equip [",
+ "点击选择要替换的装备": "Tap to choose the Equipment to replace",
+ "上": "Up",
+ "右": "Right",
+ "下": "Down",
+ "左": "Left",
+ "选择{0}朝向": "Choose facing{0}",
+ "「{name}」的": " for [{name}]",
+ "点击取消": "Tap to cancel",
+ "拖回中心区域取消": "Drag back to center to cancel",
+ "朝向:{0}": "Facing: {0}",
+ "干员": "Operator",
+ "整备区": "Bench",
+ "召唤": "Summon",
+ "目标生命值": "LP",
+ "结算时扣除 {p}": "-{p} at settlement",
+ "已淘汰": "Eliminated",
+ "已离开": "Left",
+ "{name}:本局禁用(该盟约不会激活)": "{name}: Disabled this match (this Alliance will not activate)",
+ "本局禁用{0}盟约,此策略效果可能无法发挥": "{0} Alliance disabled this match; this Strategy's effect may not work",
+ "行动中": "Acting",
+ "作战中": "In Combat",
+ "联防中": "Uniting",
+ "未装备模组": "No Module",
+ "协议启动": "Protocol Start",
+ "模拟即将开始": "The simulation is about to begin",
+ "第 {r} 回合": "Round {r}",
+ "资金已到账": "Funds received",
+ "依次选择机变": "Choose Improv in turn",
+ "休整期": "Rest Phase",
+ "部署干员,准备迎敌": "Deploy Operators and prepare for the enemy",
+ "作战开始": "Battle Start",
+ "各自行动阶段": "Individual action phase",
+ "联防阶段": "Unite Phase",
+ "联防:{0}": "Unite: {0}",
+ "完美作战的博士迎战突破防线的敌人": "Doctors with a flawless battle take on the enemies that broke through",
+ "最终攻势": "Final Assault",
+ "击败敌方领袖": "Defeat the Enemy Leader",
+ "被源石侵蚀的假想敌": "An OpFor eroded by Originium",
+ "回合开始": "Round Start",
+ "回合结算": "Round Settlement",
+ "休息一下": "Take a Break",
+ "找不到该单位": "Unit not found",
+ "无法放置在该位置": "Cannot place here",
+ "位置未变化": "Position unchanged",
+ "该道具需要放置在战场上使用": "This Item must be placed on the Battlefield to use",
+ "交换后的单位无法部署在原位置": "The swapped unit cannot be deployed at the original position",
+ "请将装备拖拽至干员身上": "Drag Equipment onto an Operator",
+ "近战单位只能部署在地面": "Melee units can only be deployed on ground tiles",
+ "只能部署在召唤者攻击范围内": "Can only be deployed within the summoner's Attack Range",
+ "该位置已有单位": "This tile is already occupied",
+ "召唤者尚未部署": "The summoner is not deployed yet",
+ "装备只能配发给干员": "Equipment can only be equipped on Operators",
+ "临时整备区无法放入单位": "Units cannot be placed on the Temporary Bench",
+ "之后还有 {queued} 项": "{queued} more to come",
+ "晋升奖励": "Promotion Reward",
+ "免费选择 1 名": "Choose 1 Operator for free",
+ "晋升奖励待选择": "Promotion Reward pending",
+ "装备补给": "Equipment Supply",
+ "特殊招募": "Special Recruit",
+ "免费选择 1 件": "Choose 1 Item for free",
+ "{title}待选择": "{title} pending",
+ "尚未就绪": "Not ready yet",
+ "临时整备区不为空,请先处理溢出的资源": "The Temporary Bench is not empty; deal with the overflow first",
+ "当前无法选择": "Cannot choose right now",
+ "已准备就绪,取消准备后才能操作": "You are Ready; cancel Ready to make changes",
+ "该队友已被淘汰,无法查看其阵地": "This Teammate has been eliminated; their Field cannot be viewed",
+ "自己": "Self",
+ "联防(自己)": "Unite (Self)",
+ "联防阵地": "Unite Field",
+ "领袖战场": "Leader Battlefield",
+ "基础规则": "Basic Rules",
+ "卫戍协议已运行": "Stronghold Protocol Online",
+ "攻防战": "Attack and Defense",
+ "休整期 · 区域": "Rest Phase · Areas",
+ "休整期 · 资金与调度": "Rest Phase · Funds and Dispatch",
+ "限时战斗": "Timed Battle",
+ "作战期": "Battle Phase",
+ "协同战斗": "Co-op Battle",
+ "调度手册": "Dispatch Handbook",
+ "干员晋级": "Operator Promotion",
+ "加成情况": "Bonuses",
+ "卫戍能力": "Stronghold Abilities",
+ "助战及自选编队": "Support and Custom Squads",
+ "进阶图鉴": "Advanced Handbook",
+ "敌人类型": "Enemy Types",
+ "盟约激活与叠加": "Alliance Activation and Stacking",
+ "追加盟约": "Add-on Alliances",
+ "策略与轮选": "Strategies and Draft",
+ "TIPS // 模拟要点": "TIPS // SIMULATION TIPS",
+ "暂无说明内容": "No guide content available",
+ "关闭 (Esc)": "Close (Esc)",
+ "章节": "Chapters",
+ "上一页": "Previous page",
+ "下一页": "Next page",
+ "页码": "Pages",
+ "联防": "Unite",
+ "{name} 漏过的敌人": "Enemies that got past {name}",
+ "你漏过的敌人": "Enemies that got past you",
+ "队友正在迎战": "teammates are engaging",
+ "{who}已全部被击倒": "{who}: all defeated",
+ "剩余敌人 {v}": "Enemies remaining: {v}",
+ "剩余不足 {cap} 个后,队友每击倒一个少扣 1 点": "once fewer than {cap} remain, each one your teammates defeat costs 1 less",
+ "队友每击倒一个就少扣 1 点": "each one your teammates defeat costs 1 less",
+ "目标生命值 {lp}:联防中,你漏过的敌人还剩 {left} 个,{each};按现在结算扣除 {pending} 点(每回合至多 {cap} 点)": "LP {lp}: Unite in progress, {left} enemies that got past you remain; {each}. Settling now would cost {pending} LP (max {cap} per round)",
+ "目标生命值 {lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 {pending} 点)": "LP {lp}: Unite in progress, teammates are engaging the enemies that got past you; the loss at settlement depends on the enemies left after Unite (max {pending} LP)",
+ "{cap} 个以上": "{cap}+",
+ "{pending} 个": "{pending}",
+ "放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁": "Move them to the Bench or Battlefield, equip or use them before you can Ready; units still on the Temporary Bench when the Rest Phase ends will be destroyed",
+ "临时整备区还有 {count} 个单位:{TEMP_RULE}": "{count} unit(s) still on the Temporary Bench: {TEMP_RULE}",
+ "取消准备": "Cancel Ready",
+ "临时整备区": "Temporary Bench:",
+ "个单位待处理": "unit(s) pending",
+ "本局信息(策略 / 禁用盟约 / 干员)": "Match Info (Strategies / Banned Alliances / Operators)",
+ "查看即将迎击的敌方单位": "View the enemies you are about to face",
+ "休整期可以查看即将迎击的敌方单位": "Upcoming enemies can be viewed during the Rest Phase",
+ "秒后全队生命值开始流失": "s",
+ "超时 · 生命值": "Overtime · LP",
+ "/秒": "/s",
+ "已流失": "Lost",
+ "暂停": "Pause",
+ "继续作战(Space)": "Resume Battle (Space)",
+ "暂停作战(Space)": "Pause Battle (Space)",
+ "离开 / 暂离": "Leave / Step Away",
+ "部署费用(再部署消耗)": "DP Cost (spent on redeploy)",
+ "攻击力": "ATK",
+ "防御力": "DEF",
+ "攻击速度": "ASPD",
+ "再部署时间": "Redeploy Time",
+ "秒": "s",
+ "每秒回复": "HP Regen/s",
+ "内容过长,无法导入": "Content too long to import",
+ "没有可导入的内容": "Nothing to import",
+ "无法识别的内容": "Unrecognized content",
+ "无法识别的格式": "Unrecognized format",
+ "这不是干员调配的数据": "This is not Operator Loadout data",
+ "里面没有有效的调配条目": "It contains no valid loadout entries",
+ "弹药": "Ammo",
+ "{0}秒": "{0}s",
+ "本局的干员调配已锁定,修改将在下一局生效": "Operator loadout is locked for this match; changes will apply next match",
+ "独立模拟的休整期没有时间限制,你可以随时继续。": "The Rest Phase in Solo Simulation has no time limit. You can continue at any time.",
+ "放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。": "Abandoning the simulation ends this match immediately and returns you to the lobby. Progress will not be kept and no results will be tallied.",
+ "离开模拟": "Leave Simulation",
+ "暂离(AI 托管)": "Step Away (AI Autopilot)",
+ "暂离": "Step Away",
+ ":由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。": ": AI takes over your seat (auto deploy, ready-up and choices). You can return at any time.",
+ ":离开后无法返回本局,你的干员将回到共享卡池。": ": You cannot return to this match after leaving, and your Operators go back to the shared pool.",
+ "AI 托管中": "AI Autopilot active",
+ "AUTOPILOT // AI 托管": "AUTOPILOT // AI AUTOPILOT",
+ "AI 正在代为操作你的席位": "AI is playing your seat for you",
+ "返回模拟": "Return to Simulation",
+ "该盟约中无法出现的干员数": "Operators of this Alliance that cannot appear",
+ ",或本模式禁用": ", or disabled in this mode",
+ "本局禁用干员": "Banned Operators",
+ "本局没有禁用干员": "No Operators are banned this match",
+ "核心盟约": "Core Alliances",
+ "附加盟约": "Add-on Alliances",
+ "折扣价(原价 {basePrice})": "Discounted (was {basePrice})",
+ "加价(原价 {basePrice})": "Marked up (was {basePrice})",
+ "价格": "Price",
+ "精锐干员将出现在作战区原位置": "The Elite Operator will take the original spot on the Battlefield",
+ "精锐干员将进入整备区": "The Elite Operator will go to the Bench",
+ "无法购买": "Cannot Buy",
+ "确认购买": "Confirm Purchase",
+ ",无法购买": ", cannot buy",
+ ",再次点击确认": ", tap again to confirm",
+ "已拥有 {copies}/{need}": "Owned {copies}/{need}",
+ "可晋升": "Promotable",
+ "技能{0}:{1}{2}{3}": "Skill{0}: {1}{2}{3}",
+ "(已调配)": " (Assigned)",
+ " · 模组:{name}": " · Module: {name}",
+ " · 未装备模组": " · No Module equipped",
+ "已购买": "Purchased",
+ "已招募": "Recruited",
+ "再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
+ "升级调度中心({price} 资金) · D": "Upgrade Dispatch Center ({price} Funds) · D",
+ "已满级": "Max Level",
+ "确认升级": "Confirm Upgrade",
+ "稍后选择(回合结束后消失)": "Pick later (expires at the end of the round)",
+ "稍后": "Later",
+ "展开商店": "Expand Shop",
+ "调度中心": "Dispatch Center",
+ "剩余可放置角色:": "Deployable units left:",
+ "目前资金": "Current Funds",
+ "收起": "Collapse",
+ "解冻商店 · F": "Unfreeze Shop · F",
+ "冻结商店(下回合保留) · F": "Freeze Shop (kept next round) · F",
+ "解冻": "Unfreeze",
+ "刷新商店 · R": "Refresh Shop · R",
+ "免费 ×{free}": "Free ×{free}",
+ "联防中,": "In Unite; ",
+ "至多": "up to ",
+ "查看 {name} 的战场": "View {name}'s Battlefield",
+ "查看自己的阵地": "View your Field",
+ "查看 {name} 的阵地": "View {name}'s Field",
+ "连接已断开": "Disconnected",
+ "前往查看": "Go View",
+ "{0} 操作": "{0} actions",
+ "单位": "Unit",
+ "撤退": "Retreat",
+ "撤退至整备区": "Retreat to Bench",
+ "出售,获得 {sell} 资金": "Sell for {sell} Funds",
+ "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)": "Units that arrived after Ready are kept until the next Rest Phase; anything still here then will be destroyed (cancel Ready and they are destroyed at the end of this Rest Phase)",
+ "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁": "Move to the Bench or Battlefield, equip or use them before you can Ready; anything still here at the end of the Rest Phase will be destroyed",
+ "件道具": "item(s)",
+ "个单位": "unit(s)",
+ "{what}待处理": "{what} pending",
+ "结算": "Settlement",
+ "模拟结束": "Simulation Over",
+ "表情套组:卫戍协议": "Emote Set: Stronghold Protocol",
+ "开心": "Happy",
+ "害怕": "Scared",
+ "对不起": "Sorry",
+ "谢谢": "Thanks",
+ "思考": "Thinking",
+ "合作愉快": "Nice teamwork",
+ "表情套组:虫动": "Emote Set: Slug Moves",
+ "合作愉快!": "Nice teamwork!",
+ "谢谢!": "Thanks!",
+ "对不起!": "Sorry!",
+ "再见!": "Bye!",
+ "很快就好!": "Almost done!",
+ "没问题!": "No problem!",
+ "敬礼!": "Salute!",
+ "欢呼!": "Cheers!",
+ "酷!": "Cool!",
+ "伤心": "Sad",
+ "快死了": "Dying",
+ "表情套组:博士士": "Emote Set: Doctor-tor",
+ "博士士 1": "Doctor-tor 1",
+ "博士士 2": "Doctor-tor 2",
+ "博士士 3": "Doctor-tor 3",
+ "博士士 4": "Doctor-tor 4",
+ "博士士 5": "Doctor-tor 5",
+ "博士士 6": "Doctor-tor 6",
+ "表情套组:米米子": "Emote Set: Mimiko",
+ "米米子 1": "Mimiko 1",
+ "米米子 2": "Mimiko 2",
+ "米米子 3": "Mimiko 3",
+ "米米子 4": "Mimiko 4",
+ "米米子 5": "Mimiko 5",
+ "米米子 6": "Mimiko 6",
+ "表情套组:维维美": "Emote Set: Wiwime",
+ "维维美 1": "Wiwime 1",
+ "维维美 2": "Wiwime 2",
+ "维维美 3": "Wiwime 3",
+ "维维美 4": "Wiwime 4",
+ "维维美 5": "Wiwime 5",
+ "维维美 6": "Wiwime 6",
+ "
\n{status} · 卫戍协议:盟约 {status} {2}
\n{3}返回首页 · Back to home
": " \n{status} · Stronghold Protocol: Alliance {status} {2}
\n{3}Back to home
",
+ "页面不存在 · Not found": "Not found",
+ "禁止访问 · Forbidden": "Forbidden",
+ "请求地址过长 · URI too long": "URI too long",
+ "请求地址无效 · Bad request": "Bad request",
+ "不支持的请求方法 · Method not allowed": "Method not allowed",
+ "服务器内部错误 · Internal error": "Internal error",
+ "AI·华法琳": "AI·Warfarin",
+ "AI·阿米娅": "AI·Amiya",
+ "AI·惊蛰": "AI·Leizi",
+ "AI·杜宾": "AI·Dobermann",
+ "AI·凯尔希": "AI·Kal'tsit",
+ "AI·可露希尔": "AI·Closure",
+ "对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。": "Match core not implemented (STUB): the match settles once everyone confirms or the countdown ends.",
+ "精打细算玩偶": "Budget-Savvy Doll",
+ "悬赏决策": "Bounty Decision",
+ "道具补给": "Item Supply",
+ "机密商店": "Secret Shop",
+ "战术决策": "Tactical Decision",
+ "隐秘核心已解锁": "Hidden Core unlocked",
+ "{name}博士中途退出了模拟": "Dr. {name} left the simulation",
+ "你的目标生命值耗尽,已被淘汰": "Your LP ran out. You have been eliminated",
+ "{name}博士的目标生命值已耗尽": "Dr. {name} has run out of LP",
+ "整备区已满,获得的干员已返还": "Bench is full. The obtained Operator was returned",
+ "整备区已满,晋升的精锐干员无法放入": "Bench is full. The promoted Elite Operator cannot be placed",
+ "整备区已满,获得的装备已销毁": "Bench is full. The obtained Equipment was destroyed",
+ "整备区已满,合成的装备已销毁": "Bench is full. The combined Equipment was destroyed",
+ "发生意外错误:{error}": "Unexpected error: {error}",
+ "战场#{no}": "Battlefield #{no}",
+ "与至多 {n} 名博士组成同盟,共享干员池,联防协作抵御敌潮。": "Form an Alliance with up to {n} other Doctors, share the Operator pool, and Unite to hold back the enemy tide.",
+ "1–{n} 名博士 · 可由 AI 队友补位": "1–{n} Doctors · AI Teammates can fill empty seats",
+ "没有找到密钥 {k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)": "No Alliance found for the key {k}: check it with the host (a key expires when its Alliance ends)",
+ "你已经是该同盟的博士:先离开同盟,才能以观战身份进入": "You are already a Doctor in this Alliance: leave it first to enter as a spectator",
+ "以观战者身份进入:不占博士席位,只能观看(每个同盟最多 {MAX_SPECTATORS} 名,模拟进行中也可进入)": "Enter as a spectator: no Doctor seat is taken and you can only watch (up to {MAX_SPECTATORS} per Alliance, also while the simulation runs)",
+ "填入密钥(不会直接加入)": "Fill in the key (does not join yet)",
+ "将该博士移出同盟": "Remove this Doctor from the Alliance",
+ "移出该博士": "Remove this Doctor",
+ "观战席": "Spectators",
+ "移出观战者 {name}": "Remove spectator {name}",
+ "移出该观战者": "Remove this spectator",
+ "入座": "Take a Seat",
+ "移出同盟": "Remove from Alliance",
+ "确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
+ "移出": "Remove",
+ "观战中 · 不占博士席位,模拟开始后可切换观看各位博士": "Spectating · no Doctor seat taken; once the simulation starts you can switch between the Doctors' views",
+ "共": "Total:",
+ "观战中": "Spectating",
+ "toggle::开启": "On",
+ "toggle::关闭": "Off",
+ "撤退选中干员": "Retreat selected Operator",
+ "出售选中干员": "Sell selected Operator",
+ "关闭弹窗": "Close window",
+ "语言": "Language",
+ "右键查看详情": "Right-click for details",
+ "观战中无法进行该操作": "Spectators cannot do that",
+ "标准": "Standard",
+ "险境": "Perilous",
+ "绝境": "Dire",
+ "终极": "Ultimate",
+ "rounds::回合": "Rounds",
+ "{who}:获得{name}": "{who}: obtained {name}",
+ "获得{name}": "Obtained {name}",
+ "联防阶段:{names} 迎战突破防线的敌人": "Unite Phase: {names} take on the enemies that broke through",
+ "地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench",
+ "{names}只能部署在召唤者攻击范围内,已退回整备区": "{names} can only be deployed within the summoner's attack range and returned to the Bench",
+ "{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还": "{names} can only be deployed within the summoner's attack range; the Bench is full, so they come back next round",
+ "【{label}】层数达成,下回合开始时获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds at the start of the next round",
+ "【{label}】层数达成,获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds",
+ "【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
+ "【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}",
+ "【远见】所有干员购买价格永久降低": "[Foresight] Every Operator's purchase price is permanently reduced",
+ "【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced"
+}
diff --git a/public/js/main.js b/public/js/main.js
index 0159e018..8b2c2cce 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -23,6 +23,9 @@
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
+// Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang).
+// Server texts are translated on arrival: m.toast / m.ticker frames (msgid + params, or the text as a msgid; a
+// config.broadcasts line from its id + args), error codes (ui/toasts.js describeError) and room.closed reasons.
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
import './ui/compat.js';
@@ -46,6 +49,8 @@ import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { installLoadoutSync } from './ui/loadoutSync.js';
import { startBuildGuard } from './ui/buildGuard.js';
+import { initLang, useLang, tickerText } from './ui/lang.js';
+import { t, N_, translateWire } from '../../shared/i18n.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -92,7 +97,7 @@ function schedulePendingJoin() {
const code = s.ui.pendingJoin;
if (!code || joinInFlight || !s.session.entered || net.status !== 'online') return;
if (s.room) {
- if (s.room.code !== code) toast('你已在其他同盟中,请先离开当前同盟', 'warn');
+ if (s.room.code !== code) toast(t('你已在其他同盟中,请先离开当前同盟'), 'warn');
clearPendingJoin();
return;
}
@@ -146,7 +151,7 @@ function onWelcome(msg) {
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
const notice = sessionResetNotice(prev, msg.playerId);
backToLobby();
- if (notice) toast(notice, 'warn', { ttl: 7000 });
+ if (notice) toast(t(notice), 'warn', { ttl: 7000 });
} else if (prev.room || prev.match.public) {
// Resumed session: the server re-pushes room/match state; drop whatever it doesn't.
store.patch('ui', { restoring: true });
@@ -170,7 +175,7 @@ function onRoomState(msg) {
const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
// We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
- if (store.get().room) toast('你已不在该同盟中', 'warn');
+ if (store.get().room) toast(t('你已不在该同盟中'), 'warn');
store.set({ room: null, match: emptyMatch() });
return;
}
@@ -184,8 +189,8 @@ function onRoomState(msg) {
const CLOSE_REASON = {
// 'timeout' = this player was removed after staying disconnected past the lobby grace (server/lobby.js)
- host_left: '创建者已离开,同盟已解散', timeout: '由于长时间断开连接,你已离开同盟', empty: '同盟已解散',
- kicked: '你已被移出同盟', ended: '模拟已结束', expired: '同盟已过期', shutdown: '服务器维护中,同盟已关闭',
+ host_left: N_('创建者已离开,同盟已解散'), timeout: N_('由于长时间断开连接,你已离开同盟'), empty: N_('同盟已解散'),
+ kicked: N_('你已被移出同盟'), ended: N_('模拟已结束'), expired: N_('同盟已过期'), shutdown: N_('服务器维护中,同盟已关闭'),
};
function wireNet() {
@@ -201,12 +206,13 @@ function wireNet() {
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
- net.on('replaced', () => toast('该身份已在其他页面登录,本页已断开', 'warn', { ttl: 6000 }));
+ net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
net.on('room.closed', (msg) => {
backToLobby();
- toast(CLOSE_REASON[msg.reason] || (typeof msg.reason === 'string' && msg.reason.length < 60 ? `同盟已关闭:${msg.reason}` : '同盟已关闭'), 'warn');
+ const known = Object.hasOwn(CLOSE_REASON, String(msg.reason)) ? CLOSE_REASON[msg.reason] : null;
+ toast(known ? t(known) : typeof msg.reason === 'string' && msg.reason.length < 60 ? t('同盟已关闭:{reason}', { reason: msg.reason }) : t('同盟已关闭'), 'warn');
});
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
@@ -214,17 +220,19 @@ function wireNet() {
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
- toast(msg.text, kind);
+ // msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
+ toast(translateWire(msg), kind);
});
net.on('m.ticker', (msg) => {
if (typeof msg.text !== 'string') return;
+ const text = tickerText(msg);
// type, player + the round it came in: a BOSS_HIT line is dropped once its boss round is over and superseded by the
// same player's next one (ui/ticker.js tickerLineLive / tickerSupersedes)
const type = typeof msg.type === 'string' ? msg.type : null;
const playerId = typeof msg.playerId === 'string' ? msg.playerId : null;
// its broadcast priority: the strip plays the highest first (ui/ticker.js enqueueTickerLines)
const priority = Number.isFinite(msg.priority) ? msg.priority : 0;
- store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
+ store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
});
net.on('m.emote', (msg) => {
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
@@ -257,15 +265,16 @@ function ScreenCrashed({ error, reset }) {
return html`
<${MicroLabel} tone="mint">SYSTEM FAULT/>
-
界面发生错误
+
${t('界面发生错误')}
${String(error?.message || error).slice(0, 200)}
- <${Button} variant="primary" icon="refresh" onClick=${reset}>重新加载界面/>
+ <${Button} variant="primary" icon="refresh" onClick=${reset}>${t('重新加载界面')}/>
`;
}
function App() {
const route = useStore(selectRoute);
+ useLang(); // a language switch re-renders the whole tree in place
const [error, resetError] = useErrorBoundary((err) => console.error('[ui] screen crashed', err));
const Screen = SCREENS[route] || LobbyScreen;
return html`
@@ -285,8 +294,8 @@ async function waitForFonts(ms) {
const fonts = document.fonts;
if (!fonts || typeof fonts.load !== 'function') return;
const loads = [
- fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'),
- fonts.load('700 1em "Noto Sans SC"', '开始'),
+ fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'), // i18n-ignore (font sample)
+ fonts.load('700 1em "Noto Sans SC"', '开始'), // i18n-ignore (font sample)
fonts.load('700 1em Bender', '0123456789'),
fonts.load('700 1em Rajdhani', '0123456789'),
].map((p) => p.catch(() => null));
@@ -301,7 +310,7 @@ function installGlobalErrorHandlers() {
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
if (err instanceof NetError) toastError(err);
- else toast(`发生意外错误:${describeError(err)}`.slice(0, 120), 'error');
+ else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
@@ -342,7 +351,9 @@ async function boot() {
if (entered) net.setName(savedName);
else net.connect();
});
- await Promise.all([waitForFonts(1200), connectWhenReady]);
+ // the language (and its UI translations) before the first render: no Chinese flash for an English player
+ const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err));
+ await Promise.all([waitForFonts(1200), connectWhenReady, langReady]);
const root = document.getElementById('app');
render(html`<${App} />`, root);
@@ -369,5 +380,5 @@ async function boot() {
boot().catch((err) => {
console.error('[app] boot failed', err);
const el = document.getElementById('boot-err');
- if (el) el.textContent = '启动失败,请刷新页面重试';
+ if (el) el.textContent = t('启动失败,请刷新页面重试');
});
diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js
index 817da7ef..4a31264c 100644
--- a/public/js/screens/lobby.js
+++ b/public/js/screens/lobby.js
@@ -8,6 +8,8 @@
// screen is complete before data is generated. Rounds: solo 标准 = 9, everything else 14 (+R15
// hidden core on 险境+), per research 00-INDEX §2. Battlefield pool (`modes[].stages`): 标准 always
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
+// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
+// config.json mode texts come localized from data.js.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
@@ -18,20 +20,21 @@ import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
import { getConfig, getMode, getStage, useData } from '../data.js';
+import { t, tc, N_ } from '../../../shared/i18n.js';
/** Official mode texts (activity_table act2autochess.modeDataDict), fallback when config.json is absent. */
export const MODE_TEXT = {
single: {
- FUNNY: { code: 'AC-1', desc: '时长较短的模拟训练', effects: ['可以快速完成作战', '常规奖励'] },
- NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
- HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
- ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
+ FUNNY: { code: 'AC-1', desc: N_('时长较短的模拟训练'), effects: [N_('可以快速完成作战'), N_('常规奖励')] },
+ NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
+ HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
+ ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
},
multi: {
- FUNNY: { code: 'AC-1', desc: '敌方攻击强度较低的模拟训练', effects: ['作战环境较为温和', '常规奖励'] },
- NORMAL: { code: 'AC-2', desc: '敌方攻击强度较高的模拟训练', effects: ['可使用盟约数增加', '大幅增加奖励'] },
- HARD: { code: 'AC-3', desc: '敌方攻击强度极高的模拟训练', effects: ['作战环境困难', '出现更加危险的敌人'] },
- ABYSS: { code: 'AC-4', desc: '敌方攻击强度到达极限的模拟训练', effects: ['作战环境无比困难', '出现极度危险的敌人'] },
+ FUNNY: { code: 'AC-1', desc: N_('敌方攻击强度较低的模拟训练'), effects: [N_('作战环境较为温和'), N_('常规奖励')] },
+ NORMAL: { code: 'AC-2', desc: N_('敌方攻击强度较高的模拟训练'), effects: [N_('可使用盟约数增加'), N_('大幅增加奖励')] },
+ HARD: { code: 'AC-3', desc: N_('敌方攻击强度极高的模拟训练'), effects: [N_('作战环境困难'), N_('出现更加危险的敌人')] },
+ ABYSS: { code: 'AC-4', desc: N_('敌方攻击强度到达极限的模拟训练'), effects: [N_('作战环境无比困难'), N_('出现极度危险的敌人')] },
},
};
@@ -40,13 +43,17 @@ export const STAGE_POOL = { FUNNY: ['act1autochess_m01'], NORMAL: 8, HARD: 7, AB
/**
* Display name of a stage: stages.json when it is loaded, else derived from the id (act1 m0N → 战场#0N, act2 m0N → 战场#0(N+4)).
+ * From the data name only its 战场#NN part (with a (上半) / (下半) mark) — 'Battlefield #NN (First Half)' in English.
* @param {string} id e.g. 'act1autochess_m01'
*/
export function stageLabel(id) {
const rec = getStage(id);
- if (rec && typeof rec.name === 'string' && rec.name) return rec.name.split(/\s+/)[0];
+ if (rec && typeof rec.name === 'string' && rec.name) {
+ const m = rec.name.match(/^(.*?#\d+(?:\s*[((][^))]*[))])?)/);
+ return m ? m[1] : rec.name.split(/\s+/)[0];
+ }
const m = String(id || '').match(/^act(\d)autochess_m(\d+)$/);
- return m ? `战场#${String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0')}` : '';
+ return m ? t('战场#{no}', { no: String(Number(m[2]) + (m[1] === '2' ? 4 : 0)).padStart(2, '0') }) : '';
}
/**
@@ -58,22 +65,22 @@ export function stageLabel(id) {
export function stageNote(stages) {
if (Array.isArray(stages)) {
const ids = stages.filter((s) => typeof s === 'string' && s);
- if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? `战场固定为 ${name}` : '战场固定'; }
- return ids.length > 1 ? `战场随机(共${ids.length}张)` : '';
+ if (ids.length === 1) { const name = stageLabel(ids[0]); return name ? t('战场固定为 {name}', { name }) : t('战场固定'); }
+ return ids.length > 1 ? t('战场随机(共{n}张)', { n: ids.length }) : '';
}
- return Number.isInteger(stages) && stages > 1 ? `战场随机(共${stages}张)` : '';
+ return Number.isInteger(stages) && stages > 1 ? t('战场随机(共{stages}张)', { stages }) : '';
}
const MODE_CARDS = [
{
- id: 'solo', name: '独立模拟', en: 'SOLO SIMULATION', icon: 'user',
- desc: '独自调配资金与干员,以自己的节奏完成整场模拟。',
- points: ['1 名博士', '休整期与机变阶段不限时'],
+ id: 'solo', name: N_('独立模拟'), en: 'SOLO SIMULATION', icon: 'user',
+ desc: N_('独自调配资金与干员,以自己的节奏完成整场模拟。'),
+ points: [N_('1 名博士'), N_('休整期与机变阶段不限时')],
},
{
- id: 'coop', name: '同盟模拟', en: 'ALLIANCE SIMULATION', icon: 'users',
- desc: `与至多 ${MAX_SEATS - 1} 名博士组成同盟,共享干员池,联防协作抵御敌潮。`,
- points: [`1–${MAX_SEATS} 名博士 · 可由 AI 队友补位`, '联防阶段 · 最终攻势合并生命值'],
+ id: 'coop', name: N_('同盟模拟'), en: 'ALLIANCE SIMULATION', icon: 'users',
+ desc: N_('与至多 {n} 名博士组成同盟,共享干员池,联防协作抵御敌潮。'), params: { n: MAX_SEATS - 1 },
+ points: [N_('1–{n} 名博士 · 可由 AI 队友补位'), N_('联防阶段 · 最终攻势合并生命值')], pointParams: { n: MAX_SEATS },
},
];
@@ -89,11 +96,11 @@ export function difficultyInfo(roomMode, difficulty) {
const m = DIFFICULTIES.includes(difficulty) ? getMode(modeIdFor(roomMode, difficulty)) : null;
const effects = Array.isArray(m?.effectDescList)
? m.effectDescList.map((e) => String(e).replace(/^[·•\s]+/, '')).filter(Boolean)
- : fallback.effects;
+ : fallback.effects.map((e) => t(e));
const rounds = Number.isFinite(m?.lastRound) ? m.lastRound : roomMode === 'solo' && difficulty === 'FUNNY' ? 9 : 14;
return {
code: typeof m?.code === 'string' ? m.code : fallback.code,
- desc: typeof m?.desc === 'string' ? m.desc : fallback.desc,
+ desc: typeof m?.desc === 'string' ? m.desc : t(fallback.desc),
effects,
rounds,
hidden: difficulty !== 'FUNNY',
@@ -160,20 +167,21 @@ export function rememberRoom(code) {
}
const FALLBACK_TIPS = [
- '联合模拟在选择策略时可以进行一次跳过',
- '调度中心即使冻结,依然可以主动刷新',
- '两件同名装备可以合成一件更强力的装备',
- '只有达成完美作战的队友可以进行联防',
+ N_('联合模拟在选择策略时可以进行一次跳过'),
+ N_('调度中心即使冻结,依然可以主动刷新'),
+ N_('两件同名装备可以合成一件更强力的装备'),
+ N_('只有达成完美作战的队友可以进行联防'),
];
const TIP_ROTATE_MS = 5000; // matchingTipRotateInterval
/** Rotating tactical tips (config.json `tips`, weighted list of { tip, weight }). */
function TipsPanel() {
const cfg = getConfig();
+ const fallback = FALLBACK_TIPS.map((x) => t(x));
const tips = Array.isArray(cfg?.tips)
- ? cfg.tips.map((t) => (typeof t === 'string' ? t : t?.tip)).filter((t) => typeof t === 'string' && t)
- : FALLBACK_TIPS;
- const list = tips.length ? tips : FALLBACK_TIPS;
+ ? cfg.tips.map((x) => (typeof x === 'string' ? x : x?.tip)).filter((x) => typeof x === 'string' && x)
+ : fallback;
+ const list = tips.length ? tips : fallback;
const [idx, setIdx] = useState(() => Math.floor(Math.random() * list.length));
useEffect(() => {
const id = setInterval(() => setIdx((i) => i + 1), TIP_ROTATE_MS);
@@ -183,11 +191,11 @@ function TipsPanel() {
return html`
<${Icon} name="info" />
- 作战提示
+ ${t('作战提示')}
<${MicroLabel}>TACTICAL TIPS/>
${String(i + 1).padStart(2, '0')}/${String(list.length).padStart(2, '0')}
- setIdx(i - 1 + list.length)} aria-label="上一条"><${Icon} name="chevronLeft" />
- setIdx(i + 1)} aria-label="下一条"><${Icon} name="chevronRight" />
+ setIdx(i - 1 + list.length)} aria-label=${t('上一条')}><${Icon} name="chevronLeft" />
+ setIdx(i + 1)} aria-label=${t('下一条')}><${Icon} name="chevronRight" />
${list[i]}
`;
@@ -200,11 +208,11 @@ function ModeCard({ card, selected, onSelect }) {
<${Icon} name=${card.icon} />
<${MicroLabel} tone=${selected ? 'mint' : undefined}>${card.en}/>
- ${card.name}
- ${card.desc}
- ${card.points.map((p) => html`${p} `)}
+ ${t(card.name)}
+ ${t(card.desc, card.params)}
+ ${card.points.map((p) => html`${t(p, card.pointParams)} `)}
-
<${Icon} name="check" />已选定
+
<${Icon} name="check" />${t('已选定')}
`;
}
@@ -215,15 +223,15 @@ function DifficultyCard({ roomMode, difficulty, selected, onSelect }) {
<${DifficultyIcon} difficulty=${difficulty} class="diff-card__glyph" />
- ${DIFFICULTY_NAMES[difficulty]}
+ ${t(DIFFICULTY_NAMES[difficulty])}
${info.code}
- ${info.rounds} 回合${info.hidden ? html`+ 隐秘核心 ` : null}
+ ${info.rounds} ${tc('rounds', '回合')}${info.hidden ? html`${t('+ 隐秘核心')} ` : null}
${info.desc}
${info.effects.map((e) => html`${e} `)}${info.stageNote ? html`<${Icon} name="rook" />${info.stageNote} ` : null}
-
<${Icon} name="check" />已选定
+
<${Icon} name="check" />${t('已选定')}
`;
}
@@ -252,7 +260,7 @@ export function LobbyScreen() {
const run = async (kind, fn) => {
if (inFlight.current) return;
- if (!online) { toast('尚未连接到服务器,请稍候', 'warn'); return; }
+ if (!online) { toast(t('尚未连接到服务器,请稍候'), 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
@@ -265,23 +273,23 @@ export function LobbyScreen() {
// `onClick=${join}` hands the click EVENT as the first argument, and a default parameter only applies to
// `undefined` — codeArg keeps an event target out of the key and falls back to the input field
const k = codeArg(c, code);
- if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
+ if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
run('join', () => net.request('room.join', { code: k }));
};
// a spectator seat: no player seat taken, nothing to do but watch (also a match already running)
const spectate = (c = code) => {
// same guard as join: `onClick=${spectate}` passes the click event, not a code
const k = codeArg(c, code);
- if (!k) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
+ if (!k) { toast(t('同盟密钥为 {ROOM_CODE_LEN} 位字母或数字', { ROOM_CODE_LEN }), 'warn'); return; }
run('spectate', () => net.request('room.spectate', { code: k }).catch((err) => {
// Clearer than the bare ERR_TEXT: the usual cause is a code that is not the host's (a remembered one from an
// earlier room, or another machine's) — the server can only answer "no such room".
if (err?.code === ERR.ROOM_NOT_FOUND) {
- toast(`没有找到密钥 ${k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)`, 'warn');
+ toast(t('没有找到密钥 {k} 对应的同盟:请和房主核对密钥(同盟结束后密钥即失效)', { k }), 'warn');
return;
}
if (err?.code === ERR.ALREADY) {
- toast('你已经是该同盟的博士:先离开同盟,才能以观战身份进入', 'warn');
+ toast(t('你已经是该同盟的博士:先离开同盟,才能以观战身份进入'), 'warn');
return;
}
throw err;
@@ -295,20 +303,20 @@ export function LobbyScreen() {
return html`
- <${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title="返回标题">返回/>
+ <${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}/>
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT/>
-
选择模拟协议
+ ${t('选择模拟协议')}
- <${GuideButton} class="lobby-guide" variant="secondary" />
- <${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" />
+ <${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
+ <${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
- ${me.name || '博士'}
+ ${me.name || t('博士')}
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}/>
@@ -317,45 +325,45 @@ export function LobbyScreen() {
- 01 模拟方式<${MicroLabel}>MODE/>
+ 01 ${t('模拟方式')}<${MicroLabel}>MODE/>
${MODE_CARDS.map((c) => html`<${ModeCard} key=${c.id} card=${c} selected=${roomMode === c.id} onSelect=${pickMode} />`)}
- 03 加入同盟<${MicroLabel}>JOIN WITH ALLIANCE KEY/>
+ 03 ${t('加入同盟')}<${MicroLabel}>JOIN WITH ALLIANCE KEY/>
<${Panel} class="join-panel" tone="amber">
- <${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
+ <${TextField} size="code" icon="key" value=${code} placeholder=${t('输入同盟密钥 / 粘贴邀请链接')}
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
- <${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟/>
- <${Tooltip} text=${`以观战者身份进入:不占博士席位,只能观看(每个同盟最多 ${MAX_SPECTATORS} 名,模拟进行中也可进入)`}>
- <${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>观战/>
+ <${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>${t('加入同盟')}/>
+ <${Tooltip} text=${t('以观战者身份进入:不占博士席位,只能观看(每个同盟最多 {MAX_SPECTATORS} 名,模拟进行中也可进入)', { MAX_SPECTATORS })}>
+ <${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>${t('观战')}/>
/>
/>
<${TipsPanel} />
- 02 模拟难度<${MicroLabel}>DIFFICULTY/>
+ 02 ${t('模拟难度')}<${MicroLabel}>DIFFICULTY/>
${DIFFICULTIES.map((d) => html`<${DifficultyCard} key=${d} roomMode=${roomMode} difficulty=${d} selected=${difficulty === d} onSelect=${pickDifficulty} />`)}
- <${Tooltip} block=${true} text=${online ? null : '正在连接服务器…'}>
+ <${Tooltip} block=${true} text=${online ? null : t('正在连接服务器…')}>
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" loading=${busy === 'create'} disabled=${!online} onClick=${create}>
- ${roomMode === 'solo' ? '开始独立模拟' : '创建同盟'}
+ ${roomMode === 'solo' ? t('开始独立模拟') : t('创建同盟')}
/>
/>
${online
- ? html`${roomMode === 'solo' ? '创建后即可开始模拟' : '创建后可邀请好友或添加 AI 队友'} `
+ ? html`${roomMode === 'solo' ? t('创建后即可开始模拟') : t('创建后可邀请好友或添加 AI 队友')} `
: html`<${Spinner} size="sm" label="CONNECTING" />`}
diff --git a/public/js/screens/room.js b/public/js/screens/room.js
index b14cb989..c94481b2 100644
--- a/public/js/screens/room.js
+++ b/public/js/screens/room.js
@@ -9,6 +9,7 @@
// Spectator seats (community report #26, a remake feature): a co-op room with spectators shows the 观战席 strip under
// the seats — names, offline marks, the host's ✕ (room.removeSpectator) — and a spectator's own view swaps the ready
// button for 观战中 and offers 入座 (room.join of the room) while a player seat is free.
+// Texts go through t() (docs/I18N.md).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS, MAX_SPECTATORS } from '../../../shared/constants.js';
@@ -22,6 +23,7 @@ import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
import { difficultyInfo } from './lobby.js';
+import { t, tc } from '../../../shared/i18n.js';
/**
* Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record.
@@ -90,13 +92,13 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
P${index + 1} <${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}/>
-
等待博士加入
+
${t('等待博士加入')}
<${MicroLabel}>AWAITING DOCTOR/>
`;
}
@@ -110,30 +112,30 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
P${index + 1}
<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}/>
- ${isHostSeat ? html`<${Icon} name="crown" />创建者 ` : null}
+ ${isHostSeat ? html`<${Icon} name="crown" />${t('创建者')} ` : null}
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} />
- ${seat.isBot ? html`
<${Icon} name="robot" />AI 队友 ` : null}
+ ${seat.isBot ? html`
<${Icon} name="robot" />${t('AI 队友')} ` : null}
- ${seat.name || '博士'}
- ${isMe ? html`你 ` : null}
+ ${seat.name || t('博士')}
+ ${isMe ? html`${t('你')} ` : null}
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}/>
`;
@@ -142,29 +144,29 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
/** 观战席: the room's spectators (host: ✕ frees a seat), and 入座 for a spectator while a player seat is free. */
function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
if (!facts.spectators.length) return null;
- return html`
- <${Icon} name="eye" />观战席${facts.spectators.length} /${MAX_SPECTATORS}
+ return html`
+ <${Icon} name="eye" />${t('观战席')}${facts.spectators.length} /${MAX_SPECTATORS}
${facts.spectators.map((s) => html`
- ${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`你 ` : null}
+ ${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || t('博士')}${s.playerId === myId ? html`${t('你')} ` : null}
${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`}
- onClick=${() => onRemove(s.playerId)} aria-label=${`移出观战者 ${s.name || ''}`} title="移出该观战者" />` : null}
+ onClick=${() => onRemove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
`)}
- ${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座/>` : null}
+ ${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>${t('入座')}/>` : null}
`;
}
function InviteBox({ code }) {
const copy = async (what) => {
const ok = await copyText(what === 'code' ? code : inviteLink(code));
- if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success');
- else toast('复制失败,请手动复制', 'warn');
+ if (ok) toast(what === 'code' ? t('已复制同盟密钥 {code}', { code }) : t('已复制邀请链接'), 'success');
+ else toast(t('复制失败,请手动复制'), 'warn');
};
return html`
-
<${Icon} name="key" />同盟密钥 <${MicroLabel}>ALLIANCE KEY/>
-
${[...String(code)].map((ch, i) => html`${ch} `)}
+
<${Icon} name="key" />${t('同盟密钥')} <${MicroLabel}>ALLIANCE KEY/>
+
${[...String(code)].map((ch, i) => html`${ch} `)}
- <${Button} size="sm" icon="copy" onClick=${() => copy('code')}>复制密钥/>
- <${Button} size="sm" icon="link" onClick=${() => copy('link')}>复制链接/>
+ <${Button} size="sm" icon="copy" onClick=${() => copy('code')}>${t('复制密钥')}/>
+ <${Button} size="sm" icon="link" onClick=${() => copy('link')}>${t('复制链接')}/>
`;
}
@@ -173,14 +175,14 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
if (!isHost) {
return html`
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} />
- 由创建者选择
+ ${t('由创建者选择')}
`;
}
- return html`
+ return html`
${DIFFICULTIES.map((d) => html` room.difficulty !== d && onPick(d)}>
- <${DifficultyIcon} difficulty=${d} />${DIFFICULTY_NAMES[d].replace('模拟', '')}
+ <${DifficultyIcon} difficulty=${d} />${t(DIFFICULTY_NAMES[d].replace('模拟', ''))}
`)}
`;
}
@@ -204,7 +206,7 @@ export function RoomScreen() {
const run = async (kind, fn) => {
if (inFlight.current) return;
- if (!online) { toast('连接中断,请稍候重试', 'warn'); return; }
+ if (!online) { toast(t('连接中断,请稍候重试'), 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
@@ -221,7 +223,7 @@ export function RoomScreen() {
// confirmed player's id goes along: if they left and someone else took the seat meanwhile, the server refuses it.
const kick = async (seat, name, playerId) => {
if (inFlight.current) return;
- const ok = await confirmDialog({ title: '移出同盟', text: `确定将「${name || '博士'}」移出同盟吗?对方可以凭同盟密钥重新加入。`, okText: '移出', danger: true });
+ const ok = await confirmDialog({ title: t('移出同盟'), text: t('确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。', { name: name || t('博士') }), okText: t('移出'), danger: true });
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
@@ -232,7 +234,7 @@ export function RoomScreen() {
if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
if (facts.isHost && othersHere) {
- const ok = await confirmDialog({ title: '离开同盟', text: '你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?', okText: '离开', danger: true });
+ const ok = await confirmDialog({ title: t('离开同盟'), text: t('你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?'), okText: t('离开'), danger: true });
if (!ok) return;
}
inFlight.current = true;
@@ -250,37 +252,37 @@ export function RoomScreen() {
};
const statusLine = !online
- ? html`
<${Icon} name="wifiOff" />连接中断,正在重连… `
+ ? html`
<${Icon} name="wifiOff" />${t('连接中断,正在重连…')} `
: facts.spectating
- ? html`
<${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士 `
+ ? html`
<${Icon} name="eye" />${t('观战中 · 不占博士席位,模拟开始后可切换观看各位博士')} `
: !coop
- ? html`
*模拟协议已就绪,准许进入模拟 `
+ ? html`
${t('*模拟协议已就绪,准许进入模拟')} `
: facts.isHost
? facts.canStart
- ? html`
*同盟人数达标,准许进入模拟 `
- : html`
等待所有博士准备就绪 `
+ ? html`
${t('*同盟人数达标,准许进入模拟')} `
+ : html`
${t('等待所有博士准备就绪')} `
: myReady
- ? html`
已就绪 · 等待创建者开始模拟 `
- : html`
准备就绪后,创建者即可开始模拟 `;
+ ? html`
${t('已就绪 · 等待创建者开始模拟')} `
+ : html`
${t('准备就绪后,创建者即可开始模拟')} `;
return html`
- <${Tooltip} text="离开同盟" placement="bottom">
- <${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label="离开同盟" />
+ <${Tooltip} text=${t('离开同盟')} placement="bottom">
+ <${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label=${t('离开同盟')} />
/>
<${PingPill} ms=${conn.ping} online=${online} />
- <${MicroLabel}>当前延迟/>
+ <${MicroLabel}>${t('当前延迟')}/>
- <${GuideButton} class="room-guide" variant="secondary" />
+ <${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}/>
-
${coop ? '同盟模拟' : '独立模拟'} <${DifficultyTag} difficulty=${room.difficulty} size="lg" />
+ ${coop ? t('同盟模拟') : t('独立模拟')} <${DifficultyTag} difficulty=${room.difficulty} size="lg" />
- ${coop ? html`<${InviteBox} code=${room.code} />` : html`
<${MicroLabel}>SINGLE OPERATOR/>仅限 1 名博士
`}
+ ${coop ? html`<${InviteBox} code=${room.code} />` : html`
<${MicroLabel}>SINGLE OPERATOR/>${t('仅限 1 名博士')}
`}
@@ -289,12 +291,12 @@ export function RoomScreen() {
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)}
${coop ? null : html`
<${MicroLabel} tone="mint">BRIEFING/>
- ${DIFFICULTY_NAMES[room.difficulty] || ''} ${info.code}
+ ${DIFFICULTY_NAMES[room.difficulty] ? t(DIFFICULTY_NAMES[room.difficulty]) : ''} ${info.code}
${info.desc}
${info.effects.map((e) => html`${e} `)}
- 共 ${info.rounds} 回合${info.hidden ? ',满足条件时进入隐秘核心' : ''}
- 独立模拟中休整期与机变阶段不限时
+ ${t('共')} ${info.rounds} ${tc('rounds', '回合')}${info.hidden ? t(',满足条件时进入隐秘核心') : ''}
+ ${t('独立模拟中休整期与机变阶段不限时')}
`}
@@ -302,12 +304,12 @@ export function RoomScreen() {
- 模拟难度<${MicroLabel}>DIFFICULTY/>
+ ${t('模拟难度')}<${MicroLabel}>DIFFICULTY/>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
-
已就绪
+
${t('已就绪')}
${facts.readyHumans} /${facts.humans.length}
${facts.humans.map((s) => html`<${Icon} key=${s.playerId} name="user" class=${facts.isReady(s) ? 'is-on' : ''} />`)}
@@ -316,15 +318,15 @@ export function RoomScreen() {
${statusLine}
- <${LoadoutButton} from="room" size="lg" class="room-loadout" />
+ <${LoadoutButton} from="room" size="lg" class="room-loadout" label=${t('干员调配')} />
${facts.isHost
- ? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
- <${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟/>
+ ? html`<${Tooltip} text=${facts.canStart ? null : t('仍有博士未准备就绪')}>
+ <${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>${t('开始模拟')}/>
/>`
: facts.spectating
- ? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>观战中/>`
+ ? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>${t('观战中')}/>`
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
- loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}/>`}
+ loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? t('已就绪') : t('准备就绪')}/>`}
`;
diff --git a/public/js/screens/title.js b/public/js/screens/title.js
index 2b745756..63f99cf0 100644
--- a/public/js/screens/title.js
+++ b/public/js/screens/title.js
@@ -1,4 +1,5 @@
-// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始.
+// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language switch
+// (中文 | English, ui/lang.js; in English the big title is the English one and the small wordmark above it hides).
//
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
@@ -17,6 +18,8 @@ import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
+import { LangToggle, useLang } from '../ui/lang.js';
+import { t, N_ } from '../../../shared/i18n.js';
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
// the client accepts is never rejected by the server's hello validation.
@@ -174,14 +177,15 @@ function Ridges() {
}
const STATUS_TEXT = {
- idle: '准备连接', connecting: '正在连接服务器', connected: '已连接服务器', handshaking: '正在验证身份',
- online: '已连接服务器', reconnecting: '连接中断,正在重连', closed: '连接已关闭',
+ idle: N_('准备连接'), connecting: N_('正在连接服务器'), connected: N_('已连接服务器'), handshaking: N_('正在验证身份'),
+ online: N_('已连接服务器'), reconnecting: N_('连接中断,正在重连'), closed: N_('连接已关闭'),
};
/** Title screen component. */
export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin);
+ const lang = useLang();
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const assetsSettled = useData('assets');
const assets = data.get('assets');
@@ -199,7 +203,7 @@ export function TitleScreen() {
const valid = isValidName(name);
const start = () => {
- if (!valid) { toast('请输入博士代号', 'warn'); return; }
+ if (!valid) { toast(t('请输入博士代号'), 'warn'); return; }
enterSession(name);
};
@@ -232,39 +236,42 @@ export function TitleScreen() {
<${MicroLabel} tone="mint">RHODES ISLAND // SIMULATION SERVICE/> <${MicroLabel}>TACTICAL CO-OP NODE · 02/>
- <${MicroLabel} tone="hi">TARGET POINT/>
<${MicroLabel}>STRONGHOLD PROTOCOL/>
+
+ <${LangToggle} class="title-lang" />
+ <${MicroLabel} tone="hi">TARGET POINT/> <${MicroLabel}>STRONGHOLD PROTOCOL/>
+
<${Emblem} />
-
+ ${lang === 'en' ? null : html`
STRONGHOLD PROTOCOL
ALLIANCE
-
-
卫戍协议: 盟约
-
调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。
+
`}
+ ${t('卫戍协议')}${lang === 'en' ? ': ' : ':'} ${t('盟约')}
+ ${t('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}
${pendingJoin ? html`
<${Icon} name="key" />
- 收到同盟邀请 ${pendingJoin} · 输入代号后将自动加入
+ ${t('收到同盟邀请')} ${pendingJoin} ${t('· 输入代号后将自动加入')}
` : null}
- <${TextField} label="博士代号" micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
- placeholder="输入你的代号(最多 ${NAME_MAX_LEN} 字)" autoFocus=${!touchUi}
+ <${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
+ placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi}
onInput=${setName} onEnter=${start} />
- <${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>开始/>
+ <${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${t('开始')}/>
- ${STATUS_TEXT[conn.status] || conn.status}
+ ${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
- <${GuideButton} class="title-guide" />
+ <${GuideButton} class="title-guide" label=${t('玩法说明')} />
<${FullscreenButton} class="title-fs" />
`;
diff --git a/public/js/ui/components.js b/public/js/ui/components.js
index 5f5bc200..24f9bcfe 100644
--- a/public/js/ui/components.js
+++ b/public/js/ui/components.js
@@ -15,6 +15,7 @@ import { h, Fragment } from '../../vendor/preact.module.js';
import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
+import { t } from '../../../shared/i18n.js';
import { serverNow } from '../store.js';
import { data, useData, localAsset } from '../data.js';
import { uiUrl } from './assetUrls.js';
@@ -781,7 +782,7 @@ export function DifficultyIcon({ difficulty, class: cls }) {
* @param {{ difficulty: string, size?: 'sm'|'md'|'lg', class?: string, code?: string }} props
*/
export function DifficultyTag({ difficulty, size = 'md', class: cls, code }) {
- const name = DIFFICULTY_NAMES[difficulty] || difficulty || '—';
+ const name = DIFFICULTY_NAMES[difficulty] ? t(DIFFICULTY_NAMES[difficulty]) : difficulty || '—';
const color = DIFFICULTY_COLORS[difficulty] || 'var(--text-lo)';
return html`
<${DifficultyIcon} difficulty=${difficulty} class="dtag__icon" />
diff --git a/public/js/ui/lang.js b/public/js/ui/lang.js
new file mode 100644
index 00000000..2eaeebcc
--- /dev/null
+++ b/public/js/ui/lang.js
@@ -0,0 +1,170 @@
+// Language switch of the client (docs/I18N.md): picks the language at boot, loads the UI translations
+// (public/i18n/.json, shared/i18n.js) and the localized game data (data/i18n/.json through data.js), keeps
+// the choice, and translates what the server sends (m.toast / m.ticker / error codes).
+//
+// Chinese is the default; English is a switch (the owner's decision of 2026-10-05) — the browser's language is not
+// consulted. Order at boot: `?lang=en|zh` in the URL (then removed from the address bar and kept as the choice), the
+// stored choice (localStorage `sp.pref.lang`), else Chinese. A switch re-renders the app in place (main.js App
+// subscribes with useLang) — no reload; the game texts follow as soon as the data overlay has downloaded (data.js
+// notifies its subscribers).
+
+import { useEffect, useState } from '../../vendor/hooks.module.js';
+import { LANGS, DEFAULT_LANG, normalizeLang, getLang, setLang, onLangChange, addMessages, setNameResolver, tName, format, translateWire } from '../../../shared/i18n.js';
+import { loadPref, savePref } from '../store.js';
+import { data } from '../data.js';
+import { html } from './components.js';
+
+/** Labels of the language switch (each in its own language). */
+export const LANG_LABELS = Object.freeze({ zh: '中文', en: 'English' });
+const PREF_KEY = 'lang';
+/** How long boot waits for the UI translations before rendering in Chinese anyway (they apply when they arrive). */
+const BOOT_WAIT_MS = 2500;
+
+/** @type {Map>} */
+const uiLoads = new Map();
+
+/**
+ * Download (once) the UI translations of a language.
+ * @param {string} lang
+ * @param {typeof fetch} [doFetch]
+ * @returns {Promise} false when unavailable (the UI stays Chinese)
+ */
+export function loadUiMessages(lang, doFetch = (...a) => globalThis.fetch(...a)) {
+ if (lang === DEFAULT_LANG) return Promise.resolve(true);
+ if (uiLoads.has(lang)) return uiLoads.get(lang);
+ const p = (async () => {
+ try {
+ const res = await doFetch(`/i18n/${lang}.json`, { cache: 'no-cache' });
+ if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
+ addMessages(lang, await res.json());
+ return true;
+ } catch (err) {
+ console.warn(`[i18n] /i18n/${lang}.json unavailable (${err?.message || err}); the interface stays Chinese`);
+ uiLoads.delete(lang);
+ return false;
+ }
+ })();
+ uiLoads.set(lang, p);
+ return p;
+}
+
+/**
+ * The language to start in: the URL's `?lang=`, then the stored choice, then Chinese.
+ * @param {string} [search] location.search
+ * @param {(key: string, fallback: any) => any} [load]
+ * @returns {{ lang: string, fromUrl: boolean }}
+ */
+export function initialLang(search = globalThis.location?.search || '', load = loadPref) {
+ let fromUrl = null;
+ try { fromUrl = normalizeLang(new URLSearchParams(search).get('lang')); } catch { /* ignore */ }
+ if (fromUrl) return { lang: fromUrl, fromUrl: true };
+ let stored = null;
+ try { stored = normalizeLang(load(PREF_KEY, null)); } catch { /* ignore */ }
+ return { lang: stored || DEFAULT_LANG, fromUrl: false };
+}
+
+function stripLangParam() {
+ try {
+ const url = new URL(globalThis.location.href);
+ if (!url.searchParams.has('lang')) return;
+ url.searchParams.delete('lang');
+ globalThis.history?.replaceState(globalThis.history.state, '', url.pathname + (url.search || '') + url.hash);
+ } catch { /* ignore */ }
+}
+
+/** and the tab title follow the language. */
+function applyDocument(lang) {
+ const doc = globalThis.document;
+ if (!doc) return;
+ doc.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN';
+ doc.documentElement.dataset.lang = lang;
+ doc.title = lang === 'en' ? 'Stronghold Protocol: Alliance · Web Simulation' : '卫戍协议:盟约 · STRONGHOLD PROTOCOL';
+}
+
+// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/.json names)
+setNameResolver((name) => data.localeName(name));
+
+let wired = false;
+function wire() {
+ if (wired) return;
+ wired = true;
+ onLangChange((lang) => { applyDocument(lang); });
+}
+
+/**
+ * Boot: choose the language and load its UI translations (waits at most BOOT_WAIT_MS) before the first render; the
+ * game-data overlay downloads in the background.
+ * @returns {Promise} the language in effect
+ */
+export async function initLang() {
+ wire();
+ const { lang, fromUrl } = initialLang();
+ if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
+ applyDocument(getLang());
+ if (lang === DEFAULT_LANG) return getLang();
+ const ok = await Promise.race([loadUiMessages(lang), new Promise((r) => setTimeout(() => r(null), BOOT_WAIT_MS))]);
+ if (ok === null) loadUiMessages(lang).then((loaded) => { if (loaded && normalizeLang(loadPref(PREF_KEY, null)) === lang) setLang(lang); });
+ else if (ok) setLang(lang);
+ data.setLocale(lang).catch(() => {});
+ return getLang();
+}
+
+/**
+ * Switch the language (the switch on the title screen and in 设置): keeps the choice, loads the translations, then
+ * re-renders; the game texts follow when their overlay has downloaded.
+ * @param {string} lang
+ * @returns {Promise} the language in effect
+ */
+export async function switchLang(lang) {
+ wire();
+ const want = normalizeLang(lang) || DEFAULT_LANG;
+ savePref(PREF_KEY, want);
+ if (want !== DEFAULT_LANG && !(await loadUiMessages(want))) return getLang();
+ setLang(want);
+ data.setLocale(want).catch(() => {});
+ return getLang();
+}
+
+/**
+ * Preact hook: the current language; the component re-renders when it changes.
+ * @returns {string}
+ */
+export function useLang() {
+ const [lang, setState] = useState(getLang);
+ useEffect(() => {
+ setState(getLang());
+ return onLangChange((l) => setState(l));
+ }, []);
+ return lang;
+}
+
+/**
+ * The language switch (segmented 中文 | English).
+ * @param {{ class?: string }} props
+ */
+export function LangToggle({ class: cls }) {
+ const lang = useLang();
+ return html`
+ ${LANGS.map((l) => html` { if (lang !== l) switchLang(l); }}>${LANG_LABELS[l]} `)}
+
`;
+}
+
+/**
+ * The text of an m.ticker frame in the current language. A config.broadcasts line (`id` + `args`, server ≥ 0.2.0) is
+ * rebuilt from the localized broadcast template — `{0}` is the player's name (never translated), other args are
+ * game-data names or numbers; any other line goes through translateWire (msgid + params, or its text as a msgid).
+ * @param {{ text?: string, id?: string|null, args?: unknown[], msgid?: string, params?: any }} msg
+ * @returns {string}
+ */
+export function tickerText(msg) {
+ if (getLang() !== DEFAULT_LANG && typeof msg?.id === 'string' && Array.isArray(msg.args)) {
+ const list = data.get('config')?.broadcasts;
+ const b = Array.isArray(list) ? list.find((x) => x && x.id === msg.id) : null;
+ if (b && typeof b.text === 'string') {
+ const args = msg.args.map((a, i) => (i === 0 ? String(a ?? '') : tName(String(a ?? ''))));
+ return format(b.text, args);
+ }
+ }
+ return translateWire(msg);
+}
diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js
index 1b8de0fb..0a03df75 100644
--- a/public/js/ui/settings.js
+++ b/public/js/ui/settings.js
@@ -1,6 +1,6 @@
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
-// the settings modal.
+// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`).
import { useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
@@ -9,6 +9,8 @@ import { sanitizeSettings } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
+import { LangToggle } from './lang.js';
+import { t, tc, N_ } from '../../../shared/i18n.js';
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
@@ -41,11 +43,14 @@ function Toggle({ label, micro, value, onChange }) {
return html`
${label}<${MicroLabel}>${micro}/>
onChange(!value)}>${value ? '开启' : '关闭'}
+ onClick=${() => onChange(!value)}>${value ? tc('toggle', '开启') : tc('toggle', '关闭')}
`;
}
-const QUALITY = [['high', '高'], ['medium', '中'], ['low', '低']];
+const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
+/** Desktop shortcuts of the hint line: key → action (msgids). */
+const SHORTCUTS = [['R', N_('刷新')], ['F', N_('冻结')], ['D', N_('升级')], ['Q', N_('撤退选中干员')], ['X', N_('出售选中干员')],
+ ['Space', N_('准备就绪')], ['Esc', N_('关闭弹窗')]];
/**
* Settings modal.
@@ -55,25 +60,29 @@ export function SettingsModal({ open, onClose }) {
const s = useSettings();
const [tested, setTested] = useState(false);
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
- return html`<${Modal} open=${open} onClose=${onClose} title="设置" micro="SETTINGS" width="7.4rem"
- actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>玩法说明/>
- <${Button} variant="primary" icon="check" onClick=${onClose}>完成/>`}>
+ return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
+ actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}/>
+ <${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}/>`}>
- <${Slider} label="背景音乐" micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
- <${Slider} label="音效" micro="SFX" icon="signal" value=${s.sfx}
- onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
- <${Toggle} label="静音" micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
- <${Toggle} label="显示伤害数字" micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
- 画面质量<${MicroLabel}>QUALITY/>
+ ${t('语言')}<${MicroLabel}>LANGUAGE/>
+ <${LangToggle} class="set-lang" />
+
+ <${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
+ <${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
+ onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
+ <${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
+ <${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
+
+
${t('画面质量')}<${MicroLabel}>QUALITY/>
${QUALITY.map(([id, label]) => html` updateSettings({ quality: id })}>${label} `)}
+ class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}`)}
${touchUi
- ? html`
触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向
`
- : html`
快捷键:R 刷新 · F 冻结 · D 升级 · Q 撤退选中干员 · X 出售选中干员 · Space 准备就绪 · Esc 关闭弹窗 · 右键查看详情
`}
+ ? html`
${t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向')}
`
+ : html`
${t('快捷键:')}${SHORTCUTS.map(([key, label]) => html`${key} ${t(label)} · `)}${t('右键查看详情')}
`}
/>`;
}
diff --git a/public/js/ui/toasts.js b/public/js/ui/toasts.js
index d158dc92..3bd67048 100644
--- a/public/js/ui/toasts.js
+++ b/public/js/ui/toasts.js
@@ -8,6 +8,7 @@ import { h } from '../../vendor/preact.module.js';
import { useEffect, useReducer } from '../../vendor/hooks.module.js';
import htm from '../../vendor/htm.module.js';
import { ERR_TEXT } from '../../../shared/constants.js';
+import { t } from '../../../shared/i18n.js';
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
function hFresh(type, props, ...children) {
@@ -87,17 +88,18 @@ export function dismissToast(id) {
}
/**
- * Text for an error-ish value: NetError / {code,msg} / Error / string.
+ * Text for an error-ish value: NetError / {code,msg} / Error / string — in the current language (the Chinese texts are
+ * msgids: ERR_TEXT, net.js CLIENT_ERR_TEXT, a server `msg`; anything else is shown as it is).
* @param {any} err
* @returns {string}
*/
export function describeError(err) {
- if (!err) return '发生未知错误';
- if (typeof err === 'string') return ERR_TEXT[err] || err;
- if (err.code && ERR_TEXT[err.code]) return ERR_TEXT[err.code];
- if (typeof err.message === 'string' && err.message) return err.message;
- if (typeof err.msg === 'string' && err.msg) return err.msg;
- return '发生未知错误';
+ if (!err) return t('发生未知错误');
+ if (typeof err === 'string') return t(Object.hasOwn(ERR_TEXT, err) ? ERR_TEXT[err] : err);
+ if (err.code && Object.hasOwn(ERR_TEXT, err.code)) return t(ERR_TEXT[err.code]);
+ if (typeof err.message === 'string' && err.message) return t(err.message);
+ if (typeof err.msg === 'string' && err.msg) return t(err.msg);
+ return t('发生未知错误');
}
/**
diff --git a/test/i18n.test.js b/test/i18n.test.js
new file mode 100644
index 00000000..c947d360
--- /dev/null
+++ b/test/i18n.test.js
@@ -0,0 +1,162 @@
+// i18n core (shared/i18n.js) and the UI string table (public/i18n/en.json): t() / tc() fallback and params, plurals,
+// lists, data names, language state, the server message wire format (msg → wireMessage → translateWire), and the
+// tooling's msgid derivation (tools/i18n.mjs). docs/I18N.md.
+
+import { test, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ t, tc, N_, setLang, getLang, onLangChange, addMessages, setMessages, hasMessage, normalizeLang, format, setNameResolver,
+ tName, dn, msg, renderMessage, wireMessage, translateWire, DEFAULT_LANG,
+} from '../shared/i18n.js';
+import { templateMsgid, paramName, quote, codemodSource, scanSource } from '../tools/i18n.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const EN = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
+
+afterEach(() => { setLang('zh'); setMessages('en', {}); setNameResolver(null); });
+
+test('t(): Chinese is the default and the msgid; a missing translation falls back to the Chinese text', () => {
+ assert.equal(getLang(), DEFAULT_LANG);
+ addMessages('en', { 整备区已满: 'Bench is full' });
+ assert.equal(t('整备区已满'), '整备区已满', 'zh shows the msgid');
+ setLang('en');
+ assert.equal(t('整备区已满'), 'Bench is full');
+ assert.equal(t('没有翻译的句子'), '没有翻译的句子', 'untranslated → Chinese');
+ assert.equal(t(null), '');
+ assert.equal(t(42), '42');
+ assert.equal(hasMessage('整备区已满'), true);
+ assert.equal(hasMessage('没有翻译的句子'), false);
+ assert.equal(hasMessage('没有翻译的句子', 'zh'), true, 'the source language has every msgid');
+});
+
+test('params: {name}, positional {0}, plural {n|one|other}, lists and missing values', () => {
+ addMessages('en', { '还剩 {n} 秒': '{n} {n|second|seconds} left', '{0}博士对敌方领袖造成的伤害超过20%!': 'Dr. {0} dealt over 20%!' });
+ assert.equal(t('还剩 {n} 秒', { n: 3 }), '还剩 3 秒');
+ setLang('en');
+ assert.equal(t('还剩 {n} 秒', { n: 1 }), '1 second left');
+ assert.equal(t('还剩 {n} 秒', { n: 5 }), '5 seconds left');
+ assert.equal(t('{0}博士对敌方领袖造成的伤害超过20%!', ['阿米娅']), 'Dr. 阿米娅 dealt over 20%!', 'array params fill {0}');
+ assert.equal(format('{a} / {b}', { a: 'x' }), 'x / {b}', 'a missing value keeps the placeholder');
+ assert.equal(format('{names}', { names: ['甲', '乙'] }, { lang: 'zh' }), '甲、乙');
+ assert.equal(format('{names}', { names: ['A', 'B'] }, { lang: 'en' }), 'A, B');
+ assert.equal(format('官方{0:0%}', { 0: 1 }), '官方{0:0%}', 'official format placeholders are not ours: untouched');
+ assert.equal(format('{constructor}', {}), '{constructor}', 'no prototype lookups');
+});
+
+test('tc(): a context key wins, else the plain msgid; Chinese shows the msgid', () => {
+ addMessages('en', { 'toggle::关闭': 'Off', 关闭: 'Close' });
+ assert.equal(tc('toggle', '关闭'), '关闭');
+ setLang('en');
+ assert.equal(tc('toggle', '关闭'), 'Off');
+ assert.equal(tc('dialog', '关闭'), 'Close');
+ assert.equal(t('关闭'), 'Close');
+ assert.equal(N_('标记'), '标记', 'N_ only marks a msgid');
+});
+
+test('data names: { dn } params and tName go through the resolver, only outside Chinese; player names never do', () => {
+ setNameResolver((name) => ({ 琳琅诗怀雅: 'Swire the Elegant Wit', 阿米娅: 'Amiya' })[name] || name);
+ addMessages('en', { '{names}只能部署在召唤者攻击范围内,已退回整备区': '{names} were returned to the Bench', '{name}博士中途退出了模拟': 'Dr. {name} left the simulation' });
+ const m = msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: ['琳琅诗怀雅', '无名'].map(dn) });
+ assert.equal(translateWire(wireMessage(m)), '琳琅诗怀雅、无名只能部署在召唤者攻击范围内,已退回整备区', 'zh: names as sent');
+ setLang('en');
+ assert.equal(translateWire(wireMessage(m)), 'Swire the Elegant Wit, 无名 were returned to the Bench');
+ // a player called 阿米娅 stays 阿米娅 (plain string param, not dn)
+ assert.equal(translateWire(wireMessage(msg('{name}博士中途退出了模拟', { name: '阿米娅' }))), 'Dr. 阿米娅 left the simulation');
+ assert.equal(tName('阿米娅'), 'Amiya');
+ setLang('zh');
+ assert.equal(tName('阿米娅'), '阿米娅');
+});
+
+test('language state: setLang normalizes, notifies listeners once per change; normalizeLang', () => {
+ const seen = [];
+ const off = onLangChange((l, prev) => seen.push(`${prev}>${l}`));
+ assert.equal(setLang('en-US'), true);
+ assert.equal(setLang('en'), false, 'no change, no event');
+ assert.equal(setLang('xx'), true, 'unknown → default zh');
+ off();
+ setLang('en');
+ assert.deepEqual(seen, ['zh>en', 'en>zh']);
+ assert.equal(normalizeLang('zh_CN'), 'zh');
+ assert.equal(normalizeLang('EN'), 'en');
+ assert.equal(normalizeLang('fr'), null);
+ assert.equal(normalizeLang(undefined), null);
+});
+
+test('server wire format: text keeps the Chinese rendering for older clients; params are sanitized', () => {
+ assert.deepEqual(wireMessage('整备区已满,获得的装备已销毁'), { text: '整备区已满,获得的装备已销毁' });
+ const w = wireMessage(msg('【{label}】层数达成,获得{gain}资金', { label: dn('奇迹'), gain: 20, evil: { x: 1 }, 'bad key': 1 }));
+ assert.deepEqual(w, { text: '【奇迹】层数达成,获得20资金', msgid: '【{label}】层数达成,获得{gain}资金', params: { label: { dn: '奇迹' }, gain: 20, evil: null } });
+ assert.equal(renderMessage(msg('{who}:获得{name}', { who: dn('歌蕾蒂娅'), name: dn('斯卡蒂') })), '歌蕾蒂娅:获得斯卡蒂');
+ // a frame from an older server (no msgid): its text is the msgid
+ addMessages('en', { 隐秘核心已解锁: 'Hidden Core unlocked' });
+ setLang('en');
+ assert.equal(translateWire({ text: '隐秘核心已解锁' }), 'Hidden Core unlocked');
+ assert.equal(translateWire({ text: 'Doctor 7 博士的目标生命值已耗尽' }), 'Doctor 7 博士的目标生命值已耗尽', 'unknown text shows as is');
+ assert.equal(translateWire(null), '');
+});
+
+test('public/i18n/en.json: valid, English values, placeholders kept, the pilot screens and server messages translated', () => {
+ const entries = Object.entries(EN).filter(([k]) => !k.startsWith('_'));
+ assert.ok(entries.length > 800, `seeded from PR #70: ${entries.length}`);
+ for (const [k, v] of entries) {
+ assert.equal(typeof v, 'string', k);
+ assert.ok(v.trim(), `empty translation for ${k}`);
+ assert.ok(!/[一-鿿]/.test(v.replace(/\{[^{}]*\}/g, '')), `Chinese left in the translation of ${k}: ${v}`);
+ const want = new Set([...k.matchAll(/\{([A-Za-z0-9_$]+)\}/g)].map((m) => m[1]));
+ const have = new Set([...v.matchAll(/\{([A-Za-z0-9_$]+)(?:\|[^{}]*)?\}/g)].map((m) => m[1]));
+ for (const p of want) assert.ok(have.has(p), `${k}: {${p}} missing in "${v}"`);
+ }
+ for (const k of ['开始', '博士代号', '设置', '语言', '同盟模拟', '创建同盟', '加入同盟', '开始模拟', 'toggle::关闭', '整备区已满',
+ '{names}只能部署在召唤者攻击范围内,已退回整备区', '{name}博士的目标生命值已耗尽', '联防阶段:{names} 迎战突破防线的敌人', '【维多利亚】获得{n}件维式重锤']) {
+ assert.ok(EN[k], `en.json lacks ${k}`);
+ }
+});
+
+test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => {
+ const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js',
+ 'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js',
+ 'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js',
+ 'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js',
+ 'server/sim/content/bonds/addon/meta.js', 'server/sim/content/bonds/core.js'];
+ const missing = [];
+ for (const f of files) {
+ const { msgids, literals } = await scanSource(readFileSync(path.join(ROOT, f), 'utf8'), f);
+ const server = f.startsWith('server/');
+ for (const m of msgids) if ((!server || m.via === 'msg' || m.via === 'server') && !EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`);
+ if (!server) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`);
+ }
+ assert.deepEqual(missing, []);
+});
+
+test('tools/i18n.mjs: msgids of template literals name their params; the codemod wraps text, attributes and nested strings', async () => {
+ assert.equal(paramName('poolN', 0), 'poolN');
+ assert.equal(paramName('info.rounds', 1), 'rounds');
+ assert.equal(paramName('ids.length', 0), 'n');
+ assert.equal(paramName('MAX_SEATS - 1', 2), '2');
+ assert.deepEqual(templateMsgid(['战场随机(共', '张)'], ['ids.length']), { msgid: '战场随机(共{n}张)', params: [{ name: 'n', src: 'ids.length' }] });
+ assert.equal(templateMsgid(['', ' / ', ''], ['a.x', 'b.x']).msgid, '{x} / {x2}', 'same name from two expressions: numbered');
+ assert.equal(quote("it's\n"), "'it\\'s\\n'");
+ const src = [
+ "import { html } from './components.js';",
+ "const TABLE = { a: '甲' };",
+ "export function V({ name, n }) {",
+ " if (name === '比较') return null;",
+ " toast(`剩余 ${n} 名`, 'warn');",
+ " return html`你好 ${name || '博士'} 世界${n} 回合
`;",
+ " const sample = '字体样例'; // i18n-ignore",
+ "}",
+ ].join('\n');
+ const out = await codemodSource(src, path.join(ROOT, 'public/js/ui/x.js'));
+ assert.match(out.src, /import \{ t, N_ \} from '\.\.\/\.\.\/\.\.\/shared\/i18n\.js';/);
+ assert.match(out.src, /const TABLE = \{ a: N_\('甲'\) \};/, 'module level: marked, not translated');
+ assert.match(out.src, /name === '比较'/, 'compared literals are left alone');
+ assert.match(out.src, /toast\(t\('剩余 \{n\} 名', \{ n \}\), 'warn'\)/);
+ assert.match(out.src, /title=\$\{t\('提示 \{n\}', \{ n \}\)\}/);
+ // a child that is not a plain value splits the run; the string inside it is wrapped too
+ assert.match(out.src, />\$\{t\('你好'\)\} \$\{name \|\| t\('博士'\)\} \$\{t\('世界'\)\}/);
+ assert.match(out.src, /<\/b> \$\{t\('回合'\)\}<\/p>/);
+ assert.equal(out.manual.length, 1);
+});
diff --git a/test/ui/i18n.e2e.test.js b/test/ui/i18n.e2e.test.js
new file mode 100644
index 00000000..f75e3d0f
--- /dev/null
+++ b/test/ui/i18n.e2e.test.js
@@ -0,0 +1,72 @@
+// Browser check of the language switch (puppeteer-core + system Chrome; opt-in: SP_E2E=1, ~20 s):
+//
+// SP_E2E=1 node --test test/ui/i18n.e2e.test.js
+//
+// The title screen opens in Chinese; 中文 | English → English in place (no reload): the title, the start button, the
+// connection line, , the tab title; the choice is kept across a reload (localStorage sp.pref.lang) and the
+// game-data overlay (data/i18n/en.json) is applied; `?lang=zh` switches back and leaves the address bar. No console /
+// page / request errors. docs/I18N.md.
+
+import { test, describe, before, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { CHROME, hasChrome, sleep } from '../e2e/client.mjs';
+import { NAME_MAX_LEN } from '../../shared/constants.js';
+
+const ENABLED = process.env.SP_E2E === '1' && hasChrome();
+
+describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=1 (Chrome)' }, () => {
+ let srv;
+ let browser;
+ let base;
+ before(async () => {
+ const { startServer } = await import('../../server/index.js');
+ const puppeteer = (await import('puppeteer-core')).default;
+ srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
+ base = `http://127.0.0.1:${srv.port}`;
+ browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
+ });
+ after(async () => { await browser?.close(); await srv?.close(); });
+
+ const text = (page, sel) => page.$eval(sel, (el) => el.textContent.replace(/\s+/g, ' ').trim());
+
+ test('中文 by default; English in place, kept across a reload; ?lang=zh back to Chinese', async () => {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 1600, height: 900 });
+ const problems = [];
+ page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
+ page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
+ page.on('response', (r) => { if (r.status() >= 400 && !/fonts\.(googleapis|gstatic)/.test(r.url())) problems.push(`http ${r.status()}: ${r.url()}`); });
+ await page.goto(`${base}/`, { waitUntil: 'networkidle0' });
+ await page.waitForSelector('.title-screen [data-testid="lang-toggle"]');
+ assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约');
+ assert.equal(await text(page, '.title-login .btn--primary'), '开始');
+ assert.equal(await page.evaluate(() => document.documentElement.lang), 'zh-CN');
+
+ await page.click('[data-testid="lang-toggle"] button[data-lang="en"]');
+ await page.waitForFunction(() => document.querySelector('.title-cn')?.textContent.includes('Stronghold Protocol'), { timeout: 8000 });
+ assert.equal(await text(page, '.title-cn'), 'Stronghold Protocol: Alliance');
+ assert.equal(await text(page, '.title-login .btn--primary'), 'Start');
+ assert.match(await text(page, '.title-tag'), /^Allocate Funds and Operators/);
+ assert.match(await text(page, '.title-conn'), /Connect|Ready to connect/);
+ assert.equal(await page.$eval('.title-login input', (el) => el.placeholder), `Callsign (max ${NAME_MAX_LEN} chars)`);
+ assert.deepEqual(await page.evaluate(() => ({ lang: document.documentElement.lang, title: document.title, pref: localStorage.getItem('sp.pref.lang') })),
+ { lang: 'en', title: 'Stronghold Protocol: Alliance · Web Simulation', pref: '"en"' });
+ // the game texts follow (data/i18n/en.json applied by data.js)
+ await page.waitForFunction(() => globalThis.__SP__?.data?.locale() === 'en', { timeout: 8000 });
+ assert.equal(await page.evaluate(() => globalThis.__SP__.data.get('config').modes.mode_multi_abyss.name), 'Ultimate Simulation');
+
+ await page.reload({ waitUntil: 'networkidle0' });
+ await page.waitForSelector('.title-screen .title-cn');
+ assert.equal(await text(page, '.title-cn'), 'Stronghold Protocol: Alliance', 'the choice survives a reload');
+ assert.equal(await text(page, '.title-login .btn--primary'), 'Start');
+
+ await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
+ await page.waitForSelector('.title-screen .title-cn');
+ await sleep(200);
+ assert.equal(await text(page, '.title-cn'), '卫戍协议:盟约');
+ assert.equal(await page.evaluate(() => location.search), '', '?lang is removed from the address bar');
+ assert.equal(await page.evaluate(() => localStorage.getItem('sp.pref.lang')), '"zh"');
+ assert.deepEqual(problems, []);
+ await page.close();
+ });
+});
diff --git a/tools/i18n.mjs b/tools/i18n.mjs
new file mode 100644
index 00000000..efcd7227
--- /dev/null
+++ b/tools/i18n.mjs
@@ -0,0 +1,632 @@
+#!/usr/bin/env node
+// tools/i18n.mjs — UI string tooling for the gettext-style i18n (shared/i18n.js, public/i18n/.json; docs/I18N.md).
+//
+// node tools/i18n.mjs extract [paths…] [--list] [--json]
+// per file: the msgids passed to t() / tc() / N_() / msg(), and the Chinese literals still shown untranslated
+// (string literals, template literals, html`` text and attribute values outside those calls; comments,
+// console.* and Error messages are ignored). --list prints each literal with its line.
+// node tools/i18n.mjs check [paths…] [--lang en] [--strict] [--stale]
+// msgids the code uses that public/i18n/.json lacks (exit 1 with --strict when any); --stale also lists
+// entries no scanned code uses (not an error: phase 2 wraps the rest of the UI later).
+// node tools/i18n.mjs codemod [--write]
+// wrap Chinese literals: html`` text runs / attribute values → ${t('…')}, plain strings → t('…'), template
+// literals → t('…{name}…', { name }); adds the import. Module-level literals (evaluated once, before a language
+// can change) are only marked N_('…') and listed: call t() where they are shown. Literals compared, used as keys,
+// or passed to string methods are left alone, and so is a line marked `// i18n-ignore`. A text run with a child
+// that is not a plain value (`${a || b}`, a vnode) is split into its static pieces. Prints a summary; --write saves
+// the files. Review the result.
+// node tools/i18n.mjs seed --from [--lang en] [--all] [--write]
+// fill public/i18n/.json from a flat { msgid: translation } map for the msgids the code uses (--all: also
+// for the Chinese literals not wrapped yet, under the msgid the codemod would give them). Existing entries win.
+//
+// Default paths: public/js, shared, server (extract / seed skip server/sim, whose strings are game logic; check reads
+// its msg() and ctx.toast texts too). Server texts: a Chinese literal passed to m.toast / ctx.toast / tickerText is a
+// msgid (the client translates the text it receives), as is the first argument of msg(). Message ids: the Chinese text itself; a template literal's expressions become named params (paramName()).
+// Needs the dev dependencies (acorn, which eslint brings).
+
+import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const CJK = /[\u3400-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff60]/;
+const HAN = /[\u3400-\u9fff\uf900-\ufaff]/;
+const DEFAULT_ROOTS = ['public/js', 'shared', 'server'];
+const SKIP_DIRS = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']);
+/** Calls whose first argument is a msgid (tc: the second, keyed `context::msgid`). */
+const MSGID_CALLS = new Set(['t', 'N_', 'msg']);
+/**
+ * Server messaging methods (m.toast, ctx.toast, this.tickerText …, called on an object): a Chinese string literal they
+ * get is sent as text and translated by the client as a msgid (main.js translateWire).
+ */
+const SERVER_TEXT_CALLS = new Set(['toast', 'tickerText', 'ticker']);
+/** Calls whose arguments are never UI text to wrap. */
+const SKIP_CALLS = new Set(['t', 'tc', 'N_', 'msg', 'tName', 'dn', 'format', 'renderMessage', 'require', 'import']);
+/** String methods: a literal argument is data, not display text. */
+const STRING_METHODS = new Set(['includes', 'startsWith', 'endsWith', 'indexOf', 'lastIndexOf', 'replace', 'replaceAll', 'split',
+ 'match', 'matchAll', 'test', 'search', 'has', 'get', 'set', 'delete', 'localeCompare', 'padStart', 'padEnd', 'join', 'add']);
+/**
+ * Object tables of msgids defined without N_() (their values are translated where they are shown): file → names.
+ * `check` treats their string values as msgids.
+ */
+const MSGID_TABLES = [
+ ['shared/constants.js', ['ERR_TEXT', 'DIFFICULTY_NAMES']],
+ ['public/js/net.js', ['CLIENT_ERR_TEXT']],
+ ['public/js/main.js', ['CLOSE_REASON']],
+];
+
+let acornMod = null;
+async function acorn() {
+ if (acornMod) return acornMod;
+ try { acornMod = await import('acorn'); } catch {
+ throw new Error('tools/i18n.mjs needs the dev dependencies (npm install): acorn comes with eslint');
+ }
+ return acornMod;
+}
+
+// ===== message ids ===================================================================================================
+
+/**
+ * The param name of a template-literal expression: an identifier keeps its name, `a.b.c` → `c` (`x.length` → `n`);
+ * anything else is positional (`0`, `1` …, its index among the expressions).
+ * @param {string} src expression source
+ * @param {number} index
+ */
+export function paramName(src, index) {
+ const s = String(src).trim();
+ if (/^[A-Za-z_$][\w$]*$/.test(s)) return s;
+ const m = s.match(/^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+$/);
+ if (m) {
+ const last = s.split(/\??\./).pop();
+ return last === 'length' ? 'n' : last;
+ }
+ return String(index);
+}
+
+/**
+ * Msgid and params of a template literal from its static parts and expression sources.
+ * @param {string[]} quasis cooked static parts (n + 1)
+ * @param {string[]} exprs expression sources (n)
+ * @returns {{ msgid: string, params: { name: string, src: string }[] }}
+ */
+export function templateMsgid(quasis, exprs) {
+ const names = new Map(); // src → name
+ const used = new Set();
+ const params = [];
+ let msgid = quasis[0] ?? '';
+ exprs.forEach((src, i) => {
+ let name = names.get(src);
+ if (!name) {
+ let base = paramName(src, i);
+ name = base;
+ for (let k = 2; used.has(name); k++) name = `${base}${k}`;
+ names.set(src, name);
+ used.add(name);
+ params.push({ name, src });
+ }
+ msgid += `{${name}}${quasis[i + 1] ?? ''}`;
+ });
+ return { msgid, params };
+}
+
+/** JS source of a single-quoted string. */
+export function quote(s) {
+ return `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')}'`;
+}
+
+/** Source of a params object for t(): `{ a, b: expr, 0: expr }`. */
+function paramsSource(params) {
+ if (!params.length) return '';
+ return `, { ${params.map(({ name, src }) => (name === src ? name : `${name}: ${src}`)).join(', ')} }`;
+}
+
+// ===== AST helpers ===================================================================================================
+
+/** Generic AST walk with ancestors (enter(node, ancestors) → false skips the children). */
+function walk(node, enter, ancestors = []) {
+ if (!node || typeof node.type !== 'string') return;
+ if (enter(node, ancestors) === false) return;
+ ancestors.push(node);
+ for (const key of Object.keys(node)) {
+ if (key === 'loc' || key === 'range' || key === 'start' || key === 'end') continue;
+ const v = node[key];
+ if (Array.isArray(v)) { for (const c of v) if (c && typeof c.type === 'string') walk(c, enter, ancestors); } else if (v && typeof v.type === 'string') walk(v, enter, ancestors);
+ }
+ ancestors.pop();
+}
+
+const calleeName = (call) => {
+ const c = call?.callee;
+ if (!c) return null;
+ if (c.type === 'Identifier') return c.name;
+ if (c.type === 'MemberExpression' && !c.computed && c.property.type === 'Identifier') return c.property.name;
+ return null;
+};
+const isConsoleCall = (call) => call?.callee?.type === 'MemberExpression' && call.callee.object?.type === 'Identifier' && call.callee.object.name === 'console';
+
+/** Why a literal must not be wrapped (null = wrap it). */
+function skipReason(node, ancestors) {
+ const parent = ancestors[ancestors.length - 1];
+ for (let i = ancestors.length - 1; i >= 0; i--) {
+ const a = ancestors[i];
+ if (a.type === 'CallExpression') {
+ if (isConsoleCall(a)) return 'console';
+ const name = calleeName(a);
+ if (SKIP_CALLS.has(name) && a.callee.type === 'Identifier') return 'in-call';
+ }
+ if (a.type === 'NewExpression' && a.callee.type === 'Identifier' && /Error$/.test(a.callee.name)) return 'error';
+ if (a.type === 'ThrowStatement') return 'error';
+ if (a.type === 'ImportDeclaration' || a.type === 'ExportAllDeclaration' || (a.type === 'ExportNamedDeclaration' && a.source)) return 'import';
+ }
+ if (!parent) return null;
+ 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';
+ if (parent.type === 'SwitchCase' && parent.test === node) return 'compare';
+ if (parent.type === 'CallExpression' && parent.arguments.includes(node)) {
+ const name = calleeName(parent);
+ if (parent.callee.type === 'MemberExpression' && STRING_METHODS.has(name)) return 'string-method';
+ }
+ if (parent.type === 'TaggedTemplateExpression') return 'tagged';
+ return null;
+}
+
+/** Whether a node sits in a function (render time) rather than at module level (evaluated once at load). */
+const inFunction = (ancestors) => ancestors.some((a) => a.type === 'FunctionDeclaration' || a.type === 'FunctionExpression' || a.type === 'ArrowFunctionExpression' || a.type === 'MethodDefinition');
+
+/**
+ * Split an html`` template into its text runs and attribute values with Chinese (htm syntax: the static parts are
+ * HTML-like, `${}` are children or attribute values). A text run is the text between two tags, `${}` children included
+ * when they are simple values (identifiers, member chains, literals, String(…)); a run with other children (vnodes,
+ * components, conditionals) is split into its static pieces.
+ * @param {any} tpl TemplateLiteral node of an html`` tag
+ * @returns {{ kind: 'text'|'attr', start: number, end: number, quasis: string[], exprs: any[], attr?: string }[]}
+ */
+function htmlSegments(tpl) {
+ const out = [];
+ const qs = tpl.quasis;
+ let state = 'TEXT';
+ let quoteCh = null;
+ /** @type {{ parts: ({ text: string, from: number, to: number } | { expr: any, from: number, to: number })[] } | null} */
+ let run = null;
+ let attr = null;
+ const simple = (e) => e.type === 'Identifier' || e.type === 'MemberExpression' || e.type === 'Literal' || (e.type === 'CallExpression' && calleeName(e) === 'String');
+ const textSeg = (parts) => {
+ const quasis = [];
+ const exprs = [];
+ let cur = '';
+ for (const p of parts) { if ('expr' in p) { quasis.push(cur); cur = ''; exprs.push(p.expr); } else cur += p.text; }
+ quasis.push(cur);
+ const lead = quasis[0].match(/^\s*/)[0];
+ const trail = quasis[quasis.length - 1].match(/\s*$/)[0];
+ if (quasis.length === 1 && lead.length === quasis[0].length) return null;
+ quasis[0] = quasis[0].slice(lead.length);
+ quasis[quasis.length - 1] = quasis[quasis.length - 1].slice(0, quasis[quasis.length - 1].length - trail.length);
+ const first = parts[0];
+ const last = parts[parts.length - 1];
+ const start = first.from + ('expr' in first ? 0 : lead.length);
+ const end = last.to - ('expr' in last ? 0 : trail.length);
+ return { kind: 'text', start, end, quasis, exprs };
+ };
+ const flushRun = () => {
+ if (!run) return;
+ const parts = run.parts;
+ run = null;
+ if (!parts.some((p) => !('expr' in p) && HAN.test(p.text))) return;
+ if (parts.every((p) => !('expr' in p) || simple(p.expr))) {
+ const seg = textSeg(parts);
+ if (seg) out.push(seg);
+ return;
+ }
+ // split around the non-simple children: each piece of static text (with its simple neighbours) on its own
+ let piece = [];
+ const flushPiece = () => {
+ if (piece.some((p) => !('expr' in p) && HAN.test(p.text))) {
+ // drop simple exprs at the edges of a piece only if they are not adjacent to text? keep them: they are values
+ const seg = textSeg(piece);
+ if (seg) out.push(seg);
+ }
+ piece = [];
+ };
+ for (const p of parts) {
+ if ('expr' in p && !simple(p.expr)) { flushPiece(); continue; }
+ piece.push(p);
+ }
+ flushPiece();
+ };
+ for (let qi = 0; qi < qs.length; qi++) {
+ const q = qs[qi];
+ const text = q.value.raw;
+ const base = q.start;
+ let i = 0;
+ while (i < text.length) {
+ if (state === 'TEXT') {
+ const lt = text.indexOf('<', i);
+ const stop = lt < 0 ? text.length : lt;
+ if (stop > i) {
+ if (!run) run = { parts: [] };
+ run.parts.push({ text: text.slice(i, stop), from: base + i, to: base + stop });
+ }
+ if (lt < 0) break;
+ flushRun();
+ if (text.startsWith('', i);
+ if (e < 0) break;
+ state = 'TEXT';
+ i = e + 3;
+ continue;
+ }
+ if (state === 'TAG') {
+ const c = text[i];
+ if (c === '>') { state = 'TEXT'; i++; continue; }
+ if (c === '"' || c === "'") {
+ const nm = text.slice(0, i).match(/([A-Za-z_:][-\w:.]*)\s*=\s*$/);
+ attr = { name: nm ? nm[1] : null, start: base + i, parts: [] };
+ quoteCh = c;
+ state = 'ATTRV';
+ i++;
+ continue;
+ }
+ i++;
+ continue;
+ }
+ // ATTRV
+ const e = text.indexOf(quoteCh, i);
+ if (e < 0) { attr.parts.push({ text: text.slice(i) }); break; }
+ attr.parts.push({ text: text.slice(i, e) });
+ const quasis = [];
+ const exprs = [];
+ let cur = '';
+ for (const p of attr.parts) { if ('expr' in p) { quasis.push(cur); cur = ''; exprs.push(p.expr); } else cur += p.text; }
+ quasis.push(cur);
+ if (attr.name && quasis.some((x) => HAN.test(x))) out.push({ kind: 'attr', attr: attr.name, start: attr.start, end: base + e + 1, quasis, exprs });
+ attr = null;
+ state = 'TAG';
+ i = e + 1;
+ }
+ if (qi < tpl.expressions.length) {
+ const ex = tpl.expressions[qi];
+ // the `${…}` spans from the end of this static part to the start of the next one
+ if (state === 'TEXT') {
+ if (!run) run = { parts: [] };
+ run.parts.push({ expr: ex, from: q.end, to: qs[qi + 1].start });
+ } else if (state === 'ATTRV') {
+ attr.parts.push({ expr: ex });
+ }
+ }
+ }
+ flushRun();
+ return out;
+}
+
+// ===== scanning a file ===============================================================================================
+
+/**
+ * Scan a source file.
+ * @param {string} src
+ * @param {string} file label
+ * @returns {Promise<{ msgids: { msgid: string, line: number }[], literals: any[] }>}
+ * literals: { kind: 'str'|'tpl'|'text'|'attr', start, end, line, msgid, params, module: boolean, reason: string|null }
+ */
+export async function scanSource(src, file = '') {
+ const { parse } = await acorn();
+ let ast;
+ try {
+ ast = parse(src, { ecmaVersion: 'latest', sourceType: 'module', locations: true, allowHashBang: true });
+ } catch (e) {
+ throw new Error(`${file}: cannot parse (${e.message})`, { cause: e });
+ }
+ const msgids = [];
+ const literals = [];
+ const lineOf = (pos) => src.slice(0, pos).split('\n').length;
+ // 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));
+ walk(ast, (node, ancestors) => {
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'tc') {
+ const [c, a] = node.arguments;
+ if (c?.type === 'Literal' && a?.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: `${c.value}::${a.value}`, line: lineOf(a.start), via: 'tc' });
+ }
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && MSGID_CALLS.has(node.callee.name)) {
+ const a = node.arguments[0];
+ if (a && a.type === 'Literal' && typeof a.value === 'string') msgids.push({ msgid: a.value, line: lineOf(a.start), via: node.callee.name });
+ else if (a && a.type === 'TemplateLiteral' && !a.expressions.length) msgids.push({ msgid: a.quasis[0].value.cooked, line: lineOf(a.start), via: node.callee.name });
+ }
+ 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 });
+ }
+ // expressions inside are walked normally (nested html``, strings in ${…})
+ return true;
+ }
+ if (node.type === 'Literal' && typeof node.value === 'string' && HAN.test(node.value)) {
+ const parent = ancestors[ancestors.length - 1];
+ if (parent && parent.type === 'TemplateLiteral') return true;
+ // an argument of m.toast / ctx.toast / tickerText, directly or as a branch of `a ? '…' : '…'` / `x || '…'`
+ let arg = node;
+ let k = ancestors.length - 1;
+ while (k >= 0 && ((ancestors[k].type === 'ConditionalExpression' && ancestors[k].test !== arg) || ancestors[k].type === 'LogicalExpression')) { arg = ancestors[k]; k--; }
+ const call = ancestors[k];
+ if (call && call.type === 'CallExpression' && call.callee.type === 'MemberExpression' && SERVER_TEXT_CALLS.has(calleeName(call)) && call.arguments.includes(arg)) {
+ msgids.push({ msgid: node.value, line: lineOf(node.start), via: 'server' });
+ return true;
+ }
+ const line = lineOf(node.start);
+ const reason = ignored.has(line) ? 'ignored' : skipReason(node, ancestors);
+ literals.push({ kind: 'str', start: node.start, end: node.end, line, msgid: node.value, params: [], module: !inFunction(ancestors), reason });
+ return true;
+ }
+ if (node.type === 'TemplateLiteral') {
+ const parent = ancestors[ancestors.length - 1];
+ 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);
+ 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;
+ }
+ return true;
+ });
+ return { msgids, literals };
+}
+
+/** String values of `const NAME = { … }` / `Object.freeze({ … })` object tables (MSGID_TABLES). */
+async function tableMsgids(src, names) {
+ const { parse } = await acorn();
+ const ast = parse(src, { ecmaVersion: 'latest', sourceType: 'module' });
+ const out = [];
+ walk(ast, (node) => {
+ if (node.type !== 'VariableDeclarator' || node.id.type !== 'Identifier' || !names.includes(node.id.name)) return true;
+ walk(node.init, (n) => {
+ if (n.type === 'Property' && n.value.type === 'Literal' && typeof n.value.value === 'string' && HAN.test(n.value.value)) out.push(n.value.value);
+ return true;
+ });
+ return false;
+ });
+ return out;
+}
+
+/**
+ * JS files under the paths. server/sim is skipped unless `sim` (its strings are game logic and data; only the messages
+ * it sends through ctx.toast matter, and `check` reads those).
+ */
+function listFiles(paths, { sim = false } = {}) {
+ const out = [];
+ const visit = (abs) => {
+ const st = statSync(abs);
+ if (st.isDirectory()) {
+ for (const name of readdirSync(abs).sort()) {
+ if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
+ const rel = path.relative(ROOT, path.join(abs, name)).split(path.sep).join('/');
+ if (rel === 'server/sim' && !sim) continue;
+ visit(path.join(abs, name));
+ }
+ } else if (/\.(m?js)$/.test(abs)) out.push(abs);
+ };
+ for (const p of paths) visit(path.resolve(ROOT, p));
+ return out;
+}
+
+const rel = (abs) => path.relative(ROOT, abs).split(path.sep).join('/');
+
+// ===== codemod =======================================================================================================
+
+/** Relative import path from a file to shared/i18n.js. */
+function importPath(absFile) {
+ let r = path.relative(path.dirname(absFile), path.join(ROOT, 'shared', 'i18n.js')).split(path.sep).join('/');
+ if (!r.startsWith('.')) r = `./${r}`;
+ return r;
+}
+
+/**
+ * Rewrite a source: wrap its Chinese literals. Returns the new source and what was done.
+ * @param {string} src
+ * @param {string} absFile
+ */
+export async function codemodSource(src, absFile) {
+ const { literals } = await scanSource(src, rel(absFile));
+ const manual = [];
+ const skipped = [];
+ let needT = false;
+ let needN = false;
+ /** literal → { start, end, make(paramSrc) } (the text is built once nested edits are known) */
+ const plans = [];
+ for (const l of literals) {
+ if (l.reason) { skipped.push(l); continue; }
+ if (l.kind === 'text' || l.kind === 'attr') {
+ plans.push({ l, start: l.start, end: l.end, make: (p) => `\${t(${quote(l.msgid)}${p})}` });
+ needT = true;
+ if (l.module) manual.push(l);
+ continue;
+ }
+ if (l.module) {
+ if (l.kind === 'str') { plans.push({ l, start: l.start, end: l.end, make: () => `N_(${src.slice(l.start, l.end)})` }); needN = true; }
+ manual.push(l);
+ continue;
+ }
+ plans.push({ l, start: l.start, end: l.end, make: (p) => `t(${quote(l.msgid)}${p})` });
+ needT = true;
+ }
+ // a plan inside another one (a string in a template's ${…}) is applied to that template's param source instead
+ const inner = (outer, x) => x !== outer && x.start >= outer.start && x.end <= outer.end;
+ const top = plans.filter((p) => !plans.some((o) => inner(o, p)));
+ const rewrite = (from, to, nested) => {
+ let text = src.slice(from, to);
+ for (const n of nested.filter((x) => x.start >= from && x.end <= to).sort((a, b) => b.start - a.start)) {
+ text = text.slice(0, n.start - from) + build(n) + text.slice(n.end - from);
+ }
+ return text;
+ };
+ const build = (plan) => {
+ const nested = plans.filter((x) => inner(plan, x) && !plans.some((o) => o !== plan && inner(plan, o) && inner(o, x)));
+ const { params = [], exprs = [] } = plan.l;
+ // params come from the expressions in order; the same expression source maps to one param
+ const srcOf = new Map();
+ exprs.forEach(([a, b]) => { const orig = src.slice(a, b); if (!srcOf.has(orig)) srcOf.set(orig, rewrite(a, b, nested)); });
+ const ps = params.map(({ name, src: orig }) => ({ name, src: srcOf.get(orig) ?? orig }));
+ return plan.make(paramsSource(ps));
+ };
+ const edits = top.map((p) => ({ start: p.start, end: p.end, text: build(p) })).sort((a, b) => b.start - a.start);
+ let out = src;
+ for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
+ const names = [needT && 't', needN && 'N_'].filter(Boolean);
+ if (names.length) {
+ const imp = importPath(absFile);
+ const re = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${imp.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"];?`);
+ const m = out.match(re);
+ if (m) {
+ const have = m[1].split(',').map((s) => s.trim()).filter(Boolean);
+ const want = [...new Set([...have, ...names])];
+ out = out.replace(re, `import { ${want.join(', ')} } from '${imp}';`);
+ } else {
+ // after the last import statement
+ const imports = [...out.matchAll(/^import[^;]*;[^\n]*\n/gm)];
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
+ out = `${out.slice(0, at)}import { ${names.join(', ')} } from '${imp}';\n${out.slice(at)}`;
+ }
+ }
+ return { src: out, edits: plans.length, manual, skipped };
+}
+
+// ===== commands ======================================================================================================
+
+function parseFlags(argv) {
+ const flags = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a.startsWith('--')) {
+ const [k, v] = a.slice(2).split('=');
+ if (v !== undefined) flags[k] = v;
+ else if (['from', 'lang'].includes(k)) flags[k] = argv[++i];
+ else flags[k] = true;
+ } else flags._.push(a);
+ }
+ return flags;
+}
+
+const catalogPath = (lang) => path.join(ROOT, 'public', 'i18n', `${lang}.json`);
+function readCatalog(lang) {
+ const p = catalogPath(lang);
+ return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
+}
+function writeCatalog(lang, cat) {
+ const meta = Object.entries(cat).filter(([k]) => k.startsWith('_'));
+ const rest = Object.entries(cat).filter(([k]) => !k.startsWith('_'));
+ writeFileSync(catalogPath(lang), `${JSON.stringify(Object.fromEntries([...meta, ...rest]), null, 2)}\n`);
+}
+
+async function usedMsgids(files) {
+ const used = new Map(); // msgid → first "file:line"
+ for (const f of files) {
+ const { msgids } = await scanSource(readFileSync(f, 'utf8'), rel(f));
+ // server code never translates: its t() / N_() are other helpers (blackboard lookups in the sim); only msg() and
+ // the texts it sends count there
+ const server = rel(f).startsWith('server/');
+ for (const m of msgids) {
+ if (server && m.via !== 'msg' && m.via !== 'server') continue;
+ if (!used.has(m.msgid)) used.set(m.msgid, `${rel(f)}:${m.line}`);
+ }
+ }
+ for (const [file, names] of MSGID_TABLES) {
+ const abs = path.join(ROOT, file);
+ if (!existsSync(abs)) continue;
+ for (const m of await tableMsgids(readFileSync(abs, 'utf8'), names)) if (!used.has(m)) used.set(m, file);
+ }
+ return used;
+}
+
+async function cmdExtract(flags) {
+ const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS);
+ const rows = [];
+ let tCount = 0, litCount = 0;
+ for (const f of files) {
+ const { msgids, literals } = await scanSource(readFileSync(f, 'utf8'), rel(f));
+ const open = literals.filter((l) => !l.reason || l.reason === 'compare' || l.reason === 'string-method' || l.reason === 'key');
+ const shown = literals.filter((l) => !l.reason);
+ tCount += msgids.length;
+ litCount += shown.length;
+ if (msgids.length || shown.length) rows.push({ file: rel(f), t: msgids.length, untranslated: shown.length, literals: shown, other: open.length - shown.length });
+ }
+ if (flags.json) { console.log(JSON.stringify(rows.map(({ literals, ...r }) => ({ ...r, literals: literals.map((l) => ({ line: l.line, kind: l.kind, msgid: l.msgid, module: l.module })) })), null, 1)); return; }
+ for (const r of rows) {
+ console.log(`${r.file.padEnd(48)} t() ${String(r.t).padStart(4)} untranslated ${String(r.untranslated).padStart(4)}`);
+ if (flags.list) for (const l of r.literals) console.log(` ${String(l.line).padStart(5)} ${l.kind.padEnd(4)}${l.module ? ' (module)' : ''} ${l.msgid.replace(/\n/g, '⏎').slice(0, 90)}`);
+ }
+ const cat = readCatalog(flags.lang || 'en');
+ const ready = rows.reduce((n, r) => n + r.literals.filter((l) => typeof cat[l.msgid] === 'string' && cat[l.msgid]).length, 0);
+ console.log(`total: ${tCount} msgids in t() / tc() / N_() / msg(); ${litCount} Chinese literals not wrapped, in ${rows.filter((r) => r.untranslated).length} files`
+ + ` (${ready} of them already translated in public/i18n/${flags.lang || 'en'}.json, ready for the codemod)`);
+}
+
+async function cmdCheck(flags) {
+ const lang = flags.lang || 'en';
+ const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS, { sim: true });
+ const used = await usedMsgids(files);
+ const cat = readCatalog(lang);
+ const missing = [...used].filter(([m]) => !(typeof cat[m] === 'string' && cat[m]));
+ for (const [m, where] of missing) console.log(`missing ${where.padEnd(40)} ${m.replace(/\n/g, '⏎')}`);
+ if (flags.stale) {
+ for (const k of Object.keys(cat)) if (!k.startsWith('_') && !used.has(k)) console.log(`unused ${k.replace(/\n/g, '⏎')}`);
+ }
+ console.log(`${used.size} msgids used, ${used.size - missing.length} translated in public/i18n/${lang}.json, ${missing.length} missing`);
+ if (flags.strict && missing.length) process.exitCode = 1;
+}
+
+async function cmdCodemod(flags) {
+ if (!flags._.length) throw new Error('codemod needs file paths');
+ for (const p of flags._) {
+ const abs = path.resolve(ROOT, p);
+ const src = readFileSync(abs, 'utf8');
+ const res = await codemodSource(src, abs);
+ console.log(`${rel(abs)}: ${res.edits} literals wrapped, ${res.skipped.length} left (compare / key / string method / console / error)`);
+ for (const l of res.manual) console.log(` manual line ${l.line}: module-level ${l.kind} — call t() where it is shown: ${l.msgid.slice(0, 70)}`);
+ if (flags.write && res.src !== src) writeFileSync(abs, res.src);
+ }
+}
+
+async function cmdSeed(flags) {
+ if (!flags.from) throw new Error('seed needs --from ');
+ const lang = flags.lang || 'en';
+ const from = JSON.parse(readFileSync(path.resolve(ROOT, flags.from), 'utf8'));
+ const files = listFiles(flags._.length ? flags._ : DEFAULT_ROOTS);
+ const want = new Map(await usedMsgids(files));
+ if (flags.all) {
+ for (const f of files) {
+ const { literals } = await scanSource(readFileSync(f, 'utf8'), rel(f));
+ for (const l of literals) if ((!l.reason || l.reason === 'tagged') && !want.has(l.msgid)) want.set(l.msgid, `${rel(f)}:${l.line}`);
+ }
+ }
+ const cat = readCatalog(lang);
+ let added = 0;
+ for (const [m] of want) {
+ if (typeof cat[m] === 'string' && cat[m]) continue;
+ const tr = from[m];
+ if (typeof tr === 'string' && tr && !HAN.test(tr)) { cat[m] = tr; added++; }
+ }
+ console.log(`${want.size} msgids considered, ${added} added to public/i18n/${lang}.json${flags.write ? '' : ' (dry run: --write saves)'}`);
+ if (flags.write) writeCatalog(lang, cat);
+}
+
+async function main() {
+ const [cmd, ...rest] = process.argv.slice(2);
+ const flags = parseFlags(rest);
+ const cmds = { extract: cmdExtract, check: cmdCheck, codemod: cmdCodemod, seed: cmdSeed };
+ if (!cmds[cmd]) {
+ console.log('usage: node tools/i18n.mjs extract|check|codemod|seed … (see the header of tools/i18n.mjs)');
+ process.exitCode = cmd ? 2 : 0;
+ return;
+ }
+ await cmds[cmd](flags);
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch((e) => { console.error(`i18n: ${e.message}`); process.exitCode = 2; });
+}
+
+export { CJK, MSGID_TABLES };