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')} - - + +

${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)}`)} - + `; } @@ -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} - + `; } @@ -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() {
- +
${MODE_CARDS.map((c) => html`<${ModeCard} key=${c.id} card=${c} selected=${roomMode === c.id} onSelect=${pickMode} />`)}
- + <${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('观战')}
- ${recent.length ? html`最近的同盟 - ${recent.map((c) => html``)}` - : html`向同伴索取 ${ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接`} + : html`${t('向同伴索取 {ROOM_CODE_LEN} 位同盟密钥,或直接打开邀请链接', { ROOM_CODE_LEN })}`}
<${TipsPanel} />
- +
${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
${canAdd - ? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>添加 AI 队友` - : html`空位`} + ? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>${t('添加 AI 队友')}` + : html`${t('空位')}`}
`; } @@ -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)}`}
- ${state === 'ready' ? html`<${Icon} name="check" />已就绪` - : state === 'offline' ? html`<${Icon} name="wifiOff" />连接中断` - : state === 'host' ? html`<${Icon} name="crown" />待命中` - : html`<${Icon} name="hourglass" />准备中`} + ${state === 'ready' ? html`<${Icon} name="check" />${t('已就绪')}` + : state === 'offline' ? html`<${Icon} name="wifiOff" />${t('连接中断')}` + : state === 'host' ? html`<${Icon} name="crown" />${t('待命中')}` + : html`<${Icon} name="hourglass" />${t('准备中')}`} - ${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友"> - <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" /> + ${seat.isBot && facts.isHost ? html`<${Tooltip} text=${t('移除该 AI 队友')}> + <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label=${t('移除 AI 队友')} /> ` : null} - ${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text="将该博士移出同盟"> - <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label="移出该博士" /> + ${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text=${t('将该博士移出同盟')}> + <${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label=${t('移出该博士')} /> ` : null}
`; @@ -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``)}
`; } @@ -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``} @@ -302,12 +304,12 @@ export function RoomScreen() {
- 模拟难度<${MicroLabel}>DIFFICULTY + ${t('模拟难度')}<${MicroLabel}>DIFFICULTY <${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
- <${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('调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。')}

- 非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有 + ${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')} <${MicroLabel}>v${APP_VERSION} · WEB SIMULATION
`; 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``)} +
`; +} + +/** + * 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} + 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``)} + 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 };