Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / src / i18n.ts
web/src/i18n.ts1286 lines · 61588 bytes
1// Languages: zh-CN (default), zh-TW (Traditional, Taiwan usage), en.
2// Pick: ?lang=zh-CN|zh-TW|en first, then localStorage, else zh-CN; L or the language switch cycles through them.
3// Switching only redraws (no reconnect, no state reset). Number grouping and the venue clock (Asia/Shanghai) do not
4// change with the language. Event-stream lines are stored as {k, p} and rendered in the current language.
5// zh-CN is the source table; tsc requires zh-TW and en to have every key.
6import type { LabId } from './lab'
7import type { CardId } from './types'
8
9export type Lang = 'zh-CN' | 'zh-TW' | 'en'
10export const LANGS: readonly Lang[] = ['zh-CN', 'zh-TW', 'en']
11export const LANG_LABEL: Record<Lang, string> = { 'zh-CN': '简', 'zh-TW': '繁', en: 'EN' }
12const LS_KEY = 'airlock.lang'
13
14/** Template parameter: strings and numbers go in as they are; a Msg is translated in the same language (nesting) */
15export type Param = string | number | Msg
16export interface Msg {
17 k: string
18 p?: Record<string, Param>
19}
20export const msg = (k: Key, p?: Record<string, Param>): Msg => (p ? { k, p } : { k })
21
22// ---------- fixed strings ----------
23const ZH_CN = {
24 'doc.title': 'Upgrade Airlock 升级气闸 · 战情室',
25 'brand.sub': '升级气闸 · 每次代理升级都要过 Chainlink CRE',
26 // top bar
27 'st.live': '实时 · Ethereum Sepolia',
28 'st.fork': '本机 Sepolia 分叉(彩排)',
29 'st.offline': '回放 · 录制自 Ethereum Sepolia',
30 'st.offlineFork': '回放 · 录制自 Sepolia 分叉',
31 'st.offlineEmpty': '回放 · 还没有录制资料',
32 'st.connecting': '正在连接 Sepolia…',
33 'st.down': '重新连接中…',
34 'st.block': '区块',
35 'cre.by': '判定方',
36 'ctl.details': '详情',
37 'ctl.help': '快捷键',
38 'ctl.langTitle': '切换语言(L)',
39 'ctl.detailsTitle': '开发者详情(D)',
40 'ctl.helpTitle': '讲者快捷键(?)',
41 // phase chips
42 'ph.idle': '空',
43 'ph.chamber': '冷静中',
44 'ph.cleared': '已放行',
45 'ph.waiting': '待放行',
46 'ph.ready': '可执行',
47 'ph.executed': '已执行',
48 'ph.ejected': '已否决',
49 'who.cre': 'Chainlink CRE',
50 'who.human': '人工紧急否决',
51 'who.approver': 'approver(人工)',
52 'who.someone': '未知地址',
53 'who.unknown': '未知地址 {a}',
54 // hero: which proposal
55 'h.eyebrow': '提案 #{id} · {kind}',
56 'h.replay': '回放 · 冷静期快进',
57 'h.replayPaused': '回放已暂停',
58 'h.dropped': 'log 因链重组消失',
59 'h.newImpl': '新实现 {a}',
60 'h.guardOff': '移除 {who} guardian',
61 'h.guardOn': '新增 guardian {who}',
62 'h.delay': '把冷静期改成 {n} 块',
63 'h.paused': '新的暂停实现 {a}',
64 'h.nameRemoveCre': '移除 CRE guardian',
65 'h.nameGuard': 'guardian 变更',
66 'h.nameDelay': '冷静期变更',
67 'h.namePaused': '暂停实现变更',
68 'h.descRemoveCre': '想把 Chainlink CRE 从 guardian 名单里拿掉',
69 'h.descGuard': '改的是气闸本身:由链上读取判定',
70 'h.descUnknown': '本页不认得这个实现',
71 'h.empty': '气闸空着',
72 'h.emptyDesc': '提案先在气闸舱等 {n} 块(≈ {t});内门只认 Chainlink CRE 的放行',
73 'h.loading': '正在读取 Ethereum Sepolia 上的提案…',
74 'h.noSuch': '没有提案 #{id}',
75 'h.noSuchDesc': 'Ethereum Sepolia 上目前最新的是提案 #{n};#{id} 一发起,这页就会显示它',
76 'h.pending': 'Sepolia 合约尚未部署',
77 'h.noRecording': '还没有录制资料',
78 // hero: the airlock picture
79 'g.owner': 'Owner 私钥',
80 'g.ownerSub': '只能提案',
81 'g.proxy': 'DemoBTC 代理',
82 'g.proxyIdle': '跑现有代码',
83 'g.proxySafe': '毫发无伤',
84 'g.proxyLive': '已换上 {name}',
85 'c.left': '{n|块|块}',
86 'c.leftSub': '冷静期剩余',
87 'c.eta': '≈ {t}',
88 'c.empty': '等待提案',
89 'c.over': '冷静期已满',
90 'c.noClear': '链上还没有放行',
91 'c.ready': '可以执行',
92 'c.readySub': '已放行 · 冷静期已满',
93 'c.executed': '升级已生效',
94 'c.ejected': '已弹出',
95 'c.clearStamp': '已放行',
96 'c.vetoAt': '提案后 {n} 块就被否决',
97 'c.awaiting': '等待 Chainlink CRE 判定',
98 'c.cleared': 'Chainlink CRE 已放行',
99 'c.clearedBy': '{who} 已放行',
100 // hero: the line under the picture
101 'b.idle': '等待升级提案',
102 'b.chamber': '已进气闸 · 等待 Chainlink CRE 判定',
103 'b.cleared': '{who} 已放行 · 冷静期满内门就开',
104 'b.ready': '{who} 已放行 · 可以执行',
105 'b.waiting': '冷静期已满 · 没有放行,内门继续锁住',
106 'b.executed': '已执行 · {name} 上线',
107 'b.ejected': '已弹出 · {who} 否决',
108 'b.ejectedFar': '已弹出 · 链上已否决',
109 'd.outer': '外门',
110 'd.chamber': '气闸舱',
111 'd.inner': '内门',
112 'd.outerIdle': '接受提案',
113 'd.outerOn': '提案于区块 #{b}',
114 'd.chamberSub': '冷静期 {n} 块 ≈ {t}',
115 'd.innerLocked': '锁住 · 等 Chainlink CRE 放行',
116 'd.innerUnlocked': '已解锁 · 区块 #{b} 起可开',
117 'd.innerReady': '已解锁 · 冷静期已满',
118 'd.innerOpen': '区块 #{b} 开启',
119 'd.innerSealed': '永久封死 · 执行必定回滚',
120 'd.innerWaiting': '锁住 · 没有放行',
121 // verdict card
122 'v.title': 'Chainlink CRE 判定',
123 'v.veto': '否决',
124 'v.clear': '放行',
125 'v.awaiting': '判定中',
126 'v.none': '—',
127 'v.noneText': '提案进入气闸后,判定会以签名报告的形式上链。',
128 'v.clearText': '撑过攻击模拟:每条规则都守住了。',
129 'v.clearTextCfg': '配置检查通过:approver 已预先核准,或这个变更没有削弱气闸。',
130 'v.awaitText': '放行或否决会以签名报告上链。',
131 'v.after': '提案后 <b>{n}</b> 块 · <b>{t}</b>',
132 'v.afterEst': '提案后 <b>{n}</b> 块 · ≈ <b>{t}</b>',
133 'v.human': '由人工紧急否决(值班人员),不是 Chainlink CRE。',
134 'v.approver': 'approver 人工放行;Chainlink CRE 仍会补判,否决优先。',
135 'v.skipped': '执行前没有 Chainlink CRE 判定(approver 人工放行)。',
136 'v.loadingChip': '读取中',
137 'v.loading': '正在读取链上判决…',
138 'v.far': '链上已否决(否决 log 不在读取范围内)。',
139 'v.clearFar': '链上已放行(放行 log 不在读取范围内)。',
140 'p.sim': '攻击模拟',
141 'p.simS': '4 家 RPC 上跑 eth_simulateV1',
142 'p.agree': '答案全部一致',
143 'p.agreeS': '4 家里至少 2 家回答,答案全部相同',
144 'p.cfg': '配置检查',
145 'p.cfgS': '在提案区块读链上状态:approver 是否预先核准',
146 'p.static': '核准检查',
147 'p.staticS': '在提案区块读链上状态,不需要模拟',
148 'p.simFail': '攻击模拟无法判定',
149 'p.simFailS': '没有明确且一致的答案:fail-closed 否决',
150 'p.don': '共识聚合',
151 'p.donS': '判定与规则须完全一致才聚合',
152 'p.sign': '签名报告',
153 'p.signS': 'EIP-712,绑定这一个提案',
154 'p.chain': '上链',
155 'p.chainS': 'forwarder → AirlockReceiver → AirlockAdmin',
156 'p.landVeto': '否决上链',
157 'p.landClear': '放行上链',
158 'l.proposal': '提案交易',
159 'l.report': '报告交易',
160 'l.execute': '执行交易',
161 'l.evidence': '证据哈希',
162 // strip
163 's.title': 'Ethereum Sepolia 上的提案',
164 's.titleOffline': '录制的提案',
165 's.titleFork': '本机分叉上的提案',
166 's.follow': '自动跟随',
167 's.loop': '回放循环',
168 's.on': '开',
169 's.off': '关',
170 's.hint': '← → 切换 · N 最新 · 空格 重播 · ? 快捷键',
171 's.loading': '正在载入提案…',
172 's.none': '还没有提案',
173 's.veto': '否决',
174 's.clear': '放行',
175 's.after': '+{n} {n|块|块}',
176 's.left': '剩 {n} 块',
177 's.noClear': '还没有放行',
178 's.executed': '已执行',
179 's.ready': '可以执行',
180 's.allHeld': '每条规则都守住',
181 's.cfgOk': '配置检查通过',
182 's.awaiting': '等待 CRE 判定',
183 's.replaying': '回放中',
184 // details drawer
185 'dr.title': '详情',
186 'dr.close': '关闭(D/Esc)',
187 'dr.proposal': '这个提案',
188 'dr.contracts': '合约',
189 'dr.conn': '连线',
190 'dr.stream': '事件串流',
191 'dr.about': '证据哈希怎么来',
192 'dr.kind': '类型',
193 'dr.target': '目标',
194 'dr.codehash': 'codehash',
195 'dr.proposer': '提案者',
196 'dr.proposed': '提案',
197 'dr.cooldown': '冷静期',
198 'dr.cooldownV': '#{a} → #{b}({n} 块)',
199 'dr.verdictTx': '判定交易',
200 'dr.cost': '判定交易成本',
201 'dr.costV': '{c}(gasUsed {g} × {p})',
202 'dr.evidence': '证据哈希',
203 'dr.workflow': 'workflow id',
204 'dr.executed': '执行',
205 'dr.atBlock': '区块 #{b}',
206 'dr.reading': '读取中…',
207 'dr.verified': '✓ Sourcify 已验证',
208 'dr.heads': '区块来源',
209 'dr.logs': 'log 来源',
210 'dr.bt': '出块时间',
211 'dr.endpoint': '节点',
212 'dr.recording': '录制',
213 'dr.recordingV': '{src} · 区块 #{a} – #{b} · 录制于 {d}',
214 'dr.noStream': '还没有事件',
215 'dr.aboutBody':
216 'evidenceHash = keccak256(规范化证据 JSON:提案栏位、proposalDigest、判定与规则,键名排序)。工作流用它的 attester 私钥对这份证据签出 EIP-712 判定;部署到 DON 时,节点还必须对同一份判定达成共识才会签出报告。这里的判定由官方 CRE CLI 在一台服务器上运行这个工作流产生,经 Chainlink 的 Sepolia 测试 forwarder 写上链。AirlockReceiver 验过 forwarder、EIP-712 签名与 proposalDigest 才会放行或否决。任何人都能用链上数据重算这个哈希。',
217 'src.sepolia': 'Ethereum Sepolia',
218 'src.fork': '本机 Sepolia 分叉',
219 'link.ws': 'WebSocket newHeads',
220 'link.poll': 'HTTP 轮询,每 {s} 秒',
221 'link.down': '重新连接中',
222 'link.logsWs': 'WebSocket 订阅',
223 'link.logsPoll': 'eth_getLogs 游标 #{c}',
224 'link.connecting': '连接中…',
225 'link.offline': '不连任何网络',
226 'bt.measured': '实测 {s} 秒/块',
227 'bt.fast': '快进挖块(分叉)',
228 'bt.measuring': '测量中…',
229 // help overlay
230 'help.title': '讲者快捷键',
231 'help.prev': '上一个(较旧)提案',
232 'help.next': '下一个(较新)提案',
233 'help.newest': '跳到最新提案',
234 'help.replay': '重播选中提案的判定瞬间',
235 'help.follow': '自动跟随新提案并播放判定(离线:回放循环)',
236 'help.loop': '播放/暂停回放循环',
237 'help.details': '开发者详情抽屉',
238 'help.lang': '切换语言',
239 'help.help': '显示/隐藏这份说明',
240 'help.esc': '关闭面板',
241 'help.space': '空格',
242 'help.url': '网址参数',
243 'help.urlBody': '?lang=en | zh-CN | zh-TW · ?mode=offline 离线回放 · ?p=<提案编号> · ?clean=1 录制用,不显示讲者控件 · ?rpc=<本机分叉>',
244 'help.foot': '按 ? 或 Esc 关闭 · 鼠标静止 3 秒后讲者控件自动隐藏',
245 // attack picker (lab.ts)
246 'lab.title': '选一种攻击,看 Chainlink CRE 怎么拦下它',
247 'lab.sub': '每张卡都重播 Ethereum Sepolia 上一笔真实提案',
248 'lab.invite': '刚才是「{name}」的真实重播 · 再选一种攻击看看',
249 'lab.tabLab': '攻击重播',
250 'lab.tabRecent': '链上提案',
251 'lab.tabsTitle': '底部显示攻击重播或链上最近的提案',
252 'lab.play': '看重播',
253 'lab.again': '再看一次',
254 'lab.none': '还没有这种提案',
255 'lab.loading': '读取中…',
256 'lab.live': '实时',
257 'lab.liveFoot': '正在链上判定 · 点击查看',
258 'lab.now': '正在回放 #{id} · 真实 Sepolia 提案',
259 'lab.proposalTx': '提案交易',
260 'lab.st.arrive': '提案进外门',
261 'lab.st.judge': 'CRE 判定中',
262 'lab.st.veto': '否决 · 弹出',
263 'lab.st.clear': '放行 · 内门解锁',
264 'lab.st.fast': '冷静期快进',
265 'lab.st.exec': '执行 · 升级生效',
266 'h.replayReal': '回放 · 真实 Sepolia 提案',
267 'h.live': '实时',
268 'st.replayOf': '回放 · Sepolia 提案 #{id}',
269 // first-visit intro (before its replay starts) and a replay's lead-in
270 'h.introEyebrow': '真实攻击重播',
271 'h.introTitle': '正在载入一笔真实攻击…',
272 'h.introDesc': '马上从头重播 Ethereum Sepolia 上的一笔真实提案:进外门 → 在气闸舱等待 → Chainlink CRE 攻击模拟后判定',
273 'c.intro': '重播即将开始',
274 'c.arriving': '提案 #{id} 抵达外门…',
275 'b.intro': '内门锁着:只有 Chainlink CRE 放行才会打开',
276 'b.arriving': '提案先进外门,再在气闸舱里等冷静期',
277 'v.introChip': '等待重播',
278 'v.introText': '重播走到判定那一步时,CRE 的结论会出现在这里。',
279 'h.slowTitle': '连接 Sepolia 比较慢…',
280 'h.slowDesc': '公共 Sepolia RPC 很慢或连不上,页面仍在重试。也可以先看录制好的真实 Sepolia 运行:',
281 'h.slowLink': '离线回放',
282 'c.slow': '仍在读取链上数据…',
283 'c.step': 'CRE · {s}\u00a0{i}/{n}',
284 'c.compressed': '回放 · 时间已压缩',
285 'c.ff': '冷静期快进 ×{n}',
286 'h.onchain': '链上状态 · 提案于 {d}',
287 'h.onchainNoDate': '链上状态',
288 'v.replayTag': '回放',
289 'h.pendEyebrow': '操作员刚刚发起 · 真实交易',
290 'h.pendDesc': '交易已送出,正在等待打包进 Ethereum Sepolia 区块;提案一上链就进外门',
291 'c.pending': '等待上链…',
292 'b.pending': '提案上链后进气闸舱,由 Chainlink CRE 攻击模拟后判定',
293 'v.pendChip': '等待上链',
294 'v.pendText': '提案上链后,Chainlink CRE 通常在 20–40 秒内给出判定。',
295 // operator mode (operator-client.ts)
296 'op.btn': '实战发起',
297 'op.badge': '操作员',
298 'op.title': '在 Sepolia 上真实发起攻击',
299 'op.note': '以 owner 身份送出一笔真实的 Ethereum Sepolia 交易;Chainlink CRE 通常在 20–40 秒内判定,战情室会自动跟随。',
300 'op.gap': '两次发起至少间隔 {n} 块',
301 'op.launch': '在 Sepolia 上真实发起',
302 'op.launchShort': '真实发起',
303 'op.expectVeto': '预期:否决 {rule}',
304 'op.expectClear': '预期:放行,冷静期满后可执行',
305 'op.confirmQ': '确定在 Sepolia 上发起「{name}」?',
306 'op.confirmBody': '这会送出一笔真实交易({fn}),送出后无法撤回。',
307 'op.confirm': '确认发起',
308 'op.cancel': '取消',
309 'op.close': '关闭',
310 'op.busy': '上一笔还在路上',
311 'op.sending': '正在送出交易…',
312 'op.sent': '交易已送出 · 等待上链…',
313 'op.slow': '还没看到这笔交易的提案 · 可在 Etherscan 查看',
314 'op.onchain': '提案 #{id} 已进气闸 · 等待 Chainlink CRE 判定',
315 'op.veto': '提案 #{id} 已否决 · {who}{rule}',
316 'op.clear': '提案 #{id} 已放行 · {who}',
317 'op.exec': '提案 #{id} 已执行',
318 'op.errCard': '这张卡不能发起',
319 'op.errAuth': '操作员口令无效 · 已退出操作员模式',
320 'op.errCooldown': '冷却中 · 再等 {n} 块(约 {t})',
321 'op.errCooldownAny': '冷却中 · 上一笔交易还没确认,稍后再试',
322 'op.errRpc': '节点没有接受这笔交易 · 稍后再试',
323 'op.errDisabled': '这里的操作员模式已关闭 · 没有送出',
324 'op.errReverted': '节点试跑这笔提案会回滚 · 所以没有送出',
325 'op.errServer': '操作员服务出错 · 没有送出,稍后再试',
326 'op.errMisconfigured': '服务器上的私钥不是这个部署的 owner · 没有送出',
327 'op.errForbidden': '操作员服务拒绝了这个来源(只接受本机或同一网站)· 没有送出',
328 'op.errUnclear': '操作员服务的回应看不懂 · 若交易已送出,战情室会自动接上',
329 'op.errNet': '连不上操作员服务 · 没有收到回应',
330 'op.authNone': '本机模式 · 不需要口令',
331 'op.authToken': '已用口令登入',
332 'op.signOut': '退出操作员模式',
333 'op.tokenTitle': '操作员口令',
334 'op.tokenBody': '口令只存在这台浏览器里,不会出现在网址中。',
335 'op.tokenBad': '口令格式不对',
336 'op.tokenSave': '保存',
337 'help.lab': '重播第 1–7 种攻击(攻击卡片从左到右)',
338 'help.operator': '实战发起面板(操作员)',
339 // event stream (details drawer)
340 'rp.readyCleared': '冷静期满 #{id}:块 {b},已放行,内门可以开',
341 'rp.readyWaiting': '冷静期满 #{id}:块 {b},还没有放行,execute 会 revert NotCleared',
342 'ev.proposed': 'Proposed #{id}:块 {b},冷静期满 {rb},tx {tx}',
343 'ev.vetoed': 'Vetoed #{id}:{who} 在块 {b}{after} · {rule} · 第 {n} 票',
344 'ev.cleared': 'Cleared #{id}:{who} 在块 {b}{after} · 证据 {h}',
345 'ev.executed': 'Executed #{id}:块 {b},tx {tx}',
346 'ev.reason': 'ReasonAttached #{id}:证据哈希 {h}',
347 'ev.verdict': 'Verdict #{id}:Chainlink CRE {what} · 证据 {h}',
348 'ev.vClear': '放行',
349 'ev.vVeto': '否决 {rule}',
350 'ev.after': '(提案后 {n} 块)',
351 'ev.removed': '链重组:log {tx} 被移除',
352 'ev.loaded': '已从链上载入最近 {n} 个提案(nextId {next})',
353 'ev.loadFail': '载入最近提案失败(第 {n} 次),稍后重试:{err}',
354 'off.loaded': '离线回放:已载入录制的 {n} 个提案({d}),不连任何网络',
355 'dur.sec': '{s} 秒',
356 'dur.min': '{m} 分 {s} 秒',
357 'dur.minOnly': '{m} 分钟',
358}
359export type Key = keyof typeof ZH_CN
360
361const ZH_TW: Record<Key, string> = {
362 'doc.title': 'Upgrade Airlock 升級氣閘 · 戰情室',
363 'brand.sub': '升級氣閘 · 每次代理升級都要過 Chainlink CRE',
364 'st.live': '即時 · Ethereum Sepolia',
365 'st.fork': '本機 Sepolia 分叉(彩排)',
366 'st.offline': '回放 · 錄製自 Ethereum Sepolia',
367 'st.offlineFork': '回放 · 錄製自 Sepolia 分叉',
368 'st.offlineEmpty': '回放 · 還沒有錄製資料',
369 'st.connecting': '正在連線 Sepolia…',
370 'st.down': '重新連線中…',
371 'st.block': '區塊',
372 'cre.by': '判定方',
373 'ctl.details': '詳情',
374 'ctl.help': '快捷鍵',
375 'ctl.langTitle': '切換語言(L)',
376 'ctl.detailsTitle': '開發者詳情(D)',
377 'ctl.helpTitle': '講者快捷鍵(?)',
378 'ph.idle': '空',
379 'ph.chamber': '冷靜中',
380 'ph.cleared': '已放行',
381 'ph.waiting': '待放行',
382 'ph.ready': '可執行',
383 'ph.executed': '已執行',
384 'ph.ejected': '已否決',
385 'who.cre': 'Chainlink CRE',
386 'who.human': '人工緊急否決',
387 'who.approver': 'approver(人工)',
388 'who.someone': '未知地址',
389 'who.unknown': '未知地址 {a}',
390 'h.eyebrow': '提案 #{id} · {kind}',
391 'h.replay': '回放 · 冷靜期快轉',
392 'h.replayPaused': '回放已暫停',
393 'h.dropped': 'log 因鏈重組消失',
394 'h.newImpl': '新實作 {a}',
395 'h.guardOff': '移除 {who} guardian',
396 'h.guardOn': '新增 guardian {who}',
397 'h.delay': '把冷靜期改成 {n} 塊',
398 'h.paused': '新的暫停實作 {a}',
399 'h.nameRemoveCre': '移除 CRE guardian',
400 'h.nameGuard': 'guardian 變更',
401 'h.nameDelay': '冷靜期變更',
402 'h.namePaused': '暫停實作變更',
403 'h.descRemoveCre': '想把 Chainlink CRE 從 guardian 名單裡拿掉',
404 'h.descGuard': '改的是氣閘本身:由鏈上讀取判定',
405 'h.descUnknown': '本頁不認得這個實作',
406 'h.empty': '氣閘空著',
407 'h.emptyDesc': '提案先在氣閘艙等 {n} 塊(≈ {t});內門只認 Chainlink CRE 的放行',
408 'h.loading': '正在讀取 Ethereum Sepolia 上的提案…',
409 'h.noSuch': '沒有提案 #{id}',
410 'h.noSuchDesc': 'Ethereum Sepolia 上目前最新的是提案 #{n};#{id} 一發起,這頁就會顯示它',
411 'h.pending': 'Sepolia 合約尚未部署',
412 'h.noRecording': '還沒有錄製資料',
413 'g.owner': 'Owner 私鑰',
414 'g.ownerSub': '只能提案',
415 'g.proxy': 'DemoBTC 代理',
416 'g.proxyIdle': '跑現有程式',
417 'g.proxySafe': '毫髮無傷',
418 'g.proxyLive': '已換上 {name}',
419 'c.left': '{n|塊|塊}',
420 'c.leftSub': '冷靜期剩餘',
421 'c.eta': '≈ {t}',
422 'c.empty': '等待提案',
423 'c.over': '冷靜期已滿',
424 'c.noClear': '鏈上還沒有放行',
425 'c.ready': '可以執行',
426 'c.readySub': '已放行 · 冷靜期已滿',
427 'c.executed': '升級已生效',
428 'c.ejected': '已彈出',
429 'c.clearStamp': '已放行',
430 'c.vetoAt': '提案後 {n} 塊就被否決',
431 'c.awaiting': '等待 Chainlink CRE 判定',
432 'c.cleared': 'Chainlink CRE 已放行',
433 'c.clearedBy': '{who} 已放行',
434 'b.idle': '等待升級提案',
435 'b.chamber': '已進氣閘 · 等待 Chainlink CRE 判定',
436 'b.cleared': '{who} 已放行 · 冷靜期滿內門就開',
437 'b.ready': '{who} 已放行 · 可以執行',
438 'b.waiting': '冷靜期已滿 · 沒有放行,內門繼續鎖住',
439 'b.executed': '已執行 · {name} 上線',
440 'b.ejected': '已彈出 · {who} 否決',
441 'b.ejectedFar': '已彈出 · 鏈上已否決',
442 'd.outer': '外門',
443 'd.chamber': '氣閘艙',
444 'd.inner': '內門',
445 'd.outerIdle': '接受提案',
446 'd.outerOn': '提案於區塊 #{b}',
447 'd.chamberSub': '冷靜期 {n} 塊 ≈ {t}',
448 'd.innerLocked': '鎖住 · 等 Chainlink CRE 放行',
449 'd.innerUnlocked': '已解鎖 · 區塊 #{b} 起可開',
450 'd.innerReady': '已解鎖 · 冷靜期已滿',
451 'd.innerOpen': '區塊 #{b} 開啟',
452 'd.innerSealed': '永久封死 · 執行必定回滾',
453 'd.innerWaiting': '鎖住 · 沒有放行',
454 'v.title': 'Chainlink CRE 判定',
455 'v.veto': '否決',
456 'v.clear': '放行',
457 'v.awaiting': '判定中',
458 'v.none': '—',
459 'v.noneText': '提案進入氣閘後,判定會以簽章報告的形式上鏈。',
460 'v.clearText': '撐過攻擊模擬:每條規則都守住了。',
461 'v.clearTextCfg': '設定檢查通過:approver 已預先核准,或這個變更沒有削弱氣閘。',
462 'v.awaitText': '放行或否決會以簽章報告上鏈。',
463 'v.after': '提案後 <b>{n}</b> 塊 · <b>{t}</b>',
464 'v.afterEst': '提案後 <b>{n}</b> 塊 · ≈ <b>{t}</b>',
465 'v.human': '由人工緊急否決(值班人員),不是 Chainlink CRE。',
466 'v.approver': 'approver 人工放行;Chainlink CRE 仍會補判,否決優先。',
467 'v.skipped': '執行前沒有 Chainlink CRE 判定(approver 人工放行)。',
468 'v.loadingChip': '讀取中',
469 'v.loading': '正在讀取鏈上判決…',
470 'v.far': '鏈上已否決(否決 log 不在讀取範圍內)。',
471 'v.clearFar': '鏈上已放行(放行 log 不在讀取範圍內)。',
472 'p.sim': '攻擊模擬',
473 'p.simS': '4 家 RPC 上跑 eth_simulateV1',
474 'p.agree': '答案全部一致',
475 'p.agreeS': '4 家裡至少 2 家回答,答案全部相同',
476 'p.cfg': '設定檢查',
477 'p.cfgS': '在提案區塊讀鏈上狀態:approver 是否預先核准',
478 'p.static': '核准檢查',
479 'p.staticS': '在提案區塊讀鏈上狀態,不需要模擬',
480 'p.simFail': '攻擊模擬無法判定',
481 'p.simFailS': '沒有明確且一致的答案:fail-closed 否決',
482 'p.don': '共識聚合',
483 'p.donS': '判定與規則須完全一致才聚合',
484 'p.sign': '簽章報告',
485 'p.signS': 'EIP-712,綁定這一個提案',
486 'p.chain': '上鏈',
487 'p.chainS': 'forwarder → AirlockReceiver → AirlockAdmin',
488 'p.landVeto': '否決上鏈',
489 'p.landClear': '放行上鏈',
490 'l.proposal': '提案交易',
491 'l.report': '報告交易',
492 'l.execute': '執行交易',
493 'l.evidence': '證據雜湊',
494 's.title': 'Ethereum Sepolia 上的提案',
495 's.titleOffline': '錄製的提案',
496 's.titleFork': '本機分叉上的提案',
497 's.follow': '自動跟隨',
498 's.loop': '回放循環',
499 's.on': '開',
500 's.off': '關',
501 's.hint': '← → 切換 · N 最新 · 空白鍵 重播 · ? 快捷鍵',
502 's.loading': '正在載入提案…',
503 's.none': '還沒有提案',
504 's.veto': '否決',
505 's.clear': '放行',
506 's.after': '+{n} {n|塊|塊}',
507 's.left': '剩 {n} 塊',
508 's.noClear': '還沒有放行',
509 's.executed': '已執行',
510 's.ready': '可以執行',
511 's.allHeld': '每條規則都守住',
512 's.cfgOk': '設定檢查通過',
513 's.awaiting': '等待 CRE 判定',
514 's.replaying': '回放中',
515 'dr.title': '詳情',
516 'dr.close': '關閉(D/Esc)',
517 'dr.proposal': '這個提案',
518 'dr.contracts': '合約',
519 'dr.conn': '連線',
520 'dr.stream': '事件串流',
521 'dr.about': '證據雜湊怎麼來',
522 'dr.kind': '類型',
523 'dr.target': '目標',
524 'dr.codehash': 'codehash',
525 'dr.proposer': '提案者',
526 'dr.proposed': '提案',
527 'dr.cooldown': '冷靜期',
528 'dr.cooldownV': '#{a} → #{b}({n} 塊)',
529 'dr.verdictTx': '判定交易',
530 'dr.cost': '判定交易成本',
531 'dr.costV': '{c}(gasUsed {g} × {p})',
532 'dr.evidence': '證據雜湊',
533 'dr.workflow': 'workflow id',
534 'dr.executed': '執行',
535 'dr.atBlock': '區塊 #{b}',
536 'dr.reading': '讀取中…',
537 'dr.verified': '✓ Sourcify 已驗證',
538 'dr.heads': '區塊來源',
539 'dr.logs': 'log 來源',
540 'dr.bt': '出塊時間',
541 'dr.endpoint': '節點',
542 'dr.recording': '錄製',
543 'dr.recordingV': '{src} · 區塊 #{a} – #{b} · 錄製於 {d}',
544 'dr.noStream': '還沒有事件',
545 'dr.aboutBody':
546 'evidenceHash = keccak256(規範化證據 JSON:提案欄位、proposalDigest、判定與規則,鍵名排序)。工作流程用它的 attester 私鑰對這份證據簽出 EIP-712 判定;部署到 DON 時,節點還必須對同一份判定達成共識才會簽出報告。這裡的判定由官方 CRE CLI 在一台伺服器上執行這個工作流程產生,經 Chainlink 的 Sepolia 測試 forwarder 寫上鏈。AirlockReceiver 驗過 forwarder、EIP-712 簽章與 proposalDigest 才會放行或否決。任何人都能用鏈上資料重算這個雜湊。',
547 'src.sepolia': 'Ethereum Sepolia',
548 'src.fork': '本機 Sepolia 分叉',
549 'link.ws': 'WebSocket newHeads',
550 'link.poll': 'HTTP 輪詢,每 {s} 秒',
551 'link.down': '重新連線中',
552 'link.logsWs': 'WebSocket 訂閱',
553 'link.logsPoll': 'eth_getLogs 游標 #{c}',
554 'link.connecting': '連線中…',
555 'link.offline': '不連任何網路',
556 'bt.measured': '實測 {s} 秒/塊',
557 'bt.fast': '快轉挖塊(分叉)',
558 'bt.measuring': '測量中…',
559 'help.title': '講者快捷鍵',
560 'help.prev': '上一個(較舊)提案',
561 'help.next': '下一個(較新)提案',
562 'help.newest': '跳到最新提案',
563 'help.replay': '重播選取提案的判定瞬間',
564 'help.follow': '自動跟隨新提案並播放判定(離線:回放循環)',
565 'help.loop': '播放/暫停回放循環',
566 'help.details': '開發者詳情抽屜',
567 'help.lang': '切換語言',
568 'help.help': '顯示/隱藏這份說明',
569 'help.esc': '關閉面板',
570 'help.space': '空白鍵',
571 'help.url': '網址參數',
572 'help.urlBody': '?lang=en | zh-CN | zh-TW · ?mode=offline 離線回放 · ?p=<提案編號> · ?clean=1 錄製用,不顯示講者控制項 · ?rpc=<本機分叉>',
573 'help.foot': '按 ? 或 Esc 關閉 · 滑鼠靜止 3 秒後講者控制項自動隱藏',
574 'lab.title': '選一種攻擊,看 Chainlink CRE 怎麼攔下它',
575 'lab.sub': '每張卡都重播 Ethereum Sepolia 上一筆真實提案',
576 'lab.invite': '剛才是「{name}」的真實重播 · 再選一種攻擊看看',
577 'lab.tabLab': '攻擊重播',
578 'lab.tabRecent': '鏈上提案',
579 'lab.tabsTitle': '底部顯示攻擊重播或鏈上最近的提案',
580 'lab.play': '看重播',
581 'lab.again': '再看一次',
582 'lab.none': '還沒有這種提案',
583 'lab.loading': '讀取中…',
584 'lab.live': '即時',
585 'lab.liveFoot': '正在鏈上判定 · 點擊查看',
586 'lab.now': '正在回放 #{id} · 真實 Sepolia 提案',
587 'lab.proposalTx': '提案交易',
588 'lab.st.arrive': '提案進外門',
589 'lab.st.judge': 'CRE 判定中',
590 'lab.st.veto': '否決 · 彈出',
591 'lab.st.clear': '放行 · 內門解鎖',
592 'lab.st.fast': '冷靜期快轉',
593 'lab.st.exec': '執行 · 升級生效',
594 'h.replayReal': '回放 · 真實 Sepolia 提案',
595 'h.live': '即時',
596 'st.replayOf': '回放 · Sepolia 提案 #{id}',
597 'h.introEyebrow': '真實攻擊重播',
598 'h.introTitle': '正在載入一筆真實攻擊…',
599 'h.introDesc': '馬上從頭重播 Ethereum Sepolia 上的一筆真實提案:進外門 → 在氣閘艙等待 → Chainlink CRE 攻擊模擬後判定',
600 'c.intro': '重播即將開始',
601 'c.arriving': '提案 #{id} 抵達外門…',
602 'b.intro': '內門鎖著:只有 Chainlink CRE 放行才會打開',
603 'b.arriving': '提案先進外門,再在氣閘艙裡等冷靜期',
604 'v.introChip': '等待重播',
605 'v.introText': '重播走到判定那一步時,CRE 的結論會出現在這裡。',
606 'h.slowTitle': '連線 Sepolia 比較慢…',
607 'h.slowDesc': '公共 Sepolia RPC 很慢或連不上,頁面仍在重試。也可以先看錄製好的真實 Sepolia 執行:',
608 'h.slowLink': '離線回放',
609 'c.slow': '仍在讀取鏈上資料…',
610 'c.step': 'CRE · {s}\u00a0{i}/{n}',
611 'c.compressed': '回放 · 時間已壓縮',
612 'c.ff': '冷靜期快轉 ×{n}',
613 'h.onchain': '鏈上狀態 · 提案於 {d}',
614 'h.onchainNoDate': '鏈上狀態',
615 'v.replayTag': '回放',
616 'h.pendEyebrow': '操作員剛剛發起 · 真實交易',
617 'h.pendDesc': '交易已送出,正在等待打包進 Ethereum Sepolia 區塊;提案一上鏈就進外門',
618 'c.pending': '等待上鏈…',
619 'b.pending': '提案上鏈後進氣閘艙,由 Chainlink CRE 攻擊模擬後判定',
620 'v.pendChip': '等待上鏈',
621 'v.pendText': '提案上鏈後,Chainlink CRE 通常在 20–40 秒內給出判定。',
622 'op.btn': '實戰發起',
623 'op.badge': '操作員',
624 'op.title': '在 Sepolia 上真實發起攻擊',
625 'op.note': '以 owner 身分送出一筆真實的 Ethereum Sepolia 交易;Chainlink CRE 通常在 20–40 秒內判定,戰情室會自動跟隨。',
626 'op.gap': '兩次發起至少間隔 {n} 塊',
627 'op.launch': '在 Sepolia 上真實發起',
628 'op.launchShort': '真實發起',
629 'op.expectVeto': '預期:否決 {rule}',
630 'op.expectClear': '預期:放行,冷靜期滿後可執行',
631 'op.confirmQ': '確定在 Sepolia 上發起「{name}」?',
632 'op.confirmBody': '這會送出一筆真實交易({fn}),送出後無法撤回。',
633 'op.confirm': '確認發起',
634 'op.cancel': '取消',
635 'op.close': '關閉',
636 'op.busy': '上一筆還在路上',
637 'op.sending': '正在送出交易…',
638 'op.sent': '交易已送出 · 等待上鏈…',
639 'op.slow': '還沒看到這筆交易的提案 · 可在 Etherscan 查看',
640 'op.onchain': '提案 #{id} 已進氣閘 · 等待 Chainlink CRE 判定',
641 'op.veto': '提案 #{id} 已否決 · {who}{rule}',
642 'op.clear': '提案 #{id} 已放行 · {who}',
643 'op.exec': '提案 #{id} 已執行',
644 'op.errCard': '這張卡不能發起',
645 'op.errAuth': '操作員口令無效 · 已退出操作員模式',
646 'op.errCooldown': '冷卻中 · 再等 {n} 塊(約 {t})',
647 'op.errCooldownAny': '冷卻中 · 上一筆交易還沒確認,稍後再試',
648 'op.errRpc': '節點沒有接受這筆交易 · 稍後再試',
649 'op.errDisabled': '這裡的操作員模式已關閉 · 沒有送出',
650 'op.errReverted': '節點試跑這筆提案會回滾 · 所以沒有送出',
651 'op.errServer': '操作員服務出錯 · 沒有送出,稍後再試',
652 'op.errMisconfigured': '伺服器上的私鑰不是這個部署的 owner · 沒有送出',
653 'op.errForbidden': '操作員服務拒絕了這個來源(只接受本機或同一網站)· 沒有送出',
654 'op.errUnclear': '操作員服務的回應看不懂 · 若交易已送出,戰情室會自動接上',
655 'op.errNet': '連不上操作員服務 · 沒有收到回應',
656 'op.authNone': '本機模式 · 不需要口令',
657 'op.authToken': '已用口令登入',
658 'op.signOut': '退出操作員模式',
659 'op.tokenTitle': '操作員口令',
660 'op.tokenBody': '口令只存在這台瀏覽器裡,不會出現在網址中。',
661 'op.tokenBad': '口令格式不對',
662 'op.tokenSave': '儲存',
663 'help.lab': '重播第 1–7 種攻擊(攻擊卡片從左到右)',
664 'help.operator': '實戰發起面板(操作員)',
665 'rp.readyCleared': '冷靜期滿 #{id}:塊 {b},已放行,內門可以開',
666 'rp.readyWaiting': '冷靜期滿 #{id}:塊 {b},還沒有放行,execute 會 revert NotCleared',
667 'ev.proposed': 'Proposed #{id}:塊 {b},冷靜期滿 {rb},tx {tx}',
668 'ev.vetoed': 'Vetoed #{id}:{who} 在塊 {b}{after} · {rule} · 第 {n} 票',
669 'ev.cleared': 'Cleared #{id}:{who} 在塊 {b}{after} · 證據 {h}',
670 'ev.executed': 'Executed #{id}:塊 {b},tx {tx}',
671 'ev.reason': 'ReasonAttached #{id}:證據雜湊 {h}',
672 'ev.verdict': 'Verdict #{id}:Chainlink CRE {what} · 證據 {h}',
673 'ev.vClear': '放行',
674 'ev.vVeto': '否決 {rule}',
675 'ev.after': '(提案後 {n} 塊)',
676 'ev.removed': '鏈重組:log {tx} 被移除',
677 'ev.loaded': '已從鏈上載入最近 {n} 個提案(nextId {next})',
678 'ev.loadFail': '載入最近提案失敗(第 {n} 次),稍後重試:{err}',
679 'off.loaded': '離線回放:已載入錄製的 {n} 個提案({d}),不連任何網路',
680 'dur.sec': '{s} 秒',
681 'dur.min': '{m} 分 {s} 秒',
682 'dur.minOnly': '{m} 分鐘',
683}
684
685const EN: Record<Key, string> = {
686 'doc.title': 'Upgrade Airlock · War Room',
687 'brand.sub': 'Every proxy upgrade, cleared by Chainlink CRE',
688 'st.live': 'Live · Ethereum Sepolia',
689 'st.fork': 'Local Sepolia fork (rehearsal)',
690 'st.offline': 'Replay · recorded on Ethereum Sepolia',
691 'st.offlineFork': 'Replay · recorded on a Sepolia fork',
692 'st.offlineEmpty': 'Replay · no recording yet',
693 'st.connecting': 'Connecting to Sepolia…',
694 'st.down': 'Reconnecting…',
695 'st.block': 'Block',
696 'cre.by': 'Verdicts by',
697 'ctl.details': 'Details',
698 'ctl.help': 'Shortcuts',
699 'ctl.langTitle': 'Switch language (L)',
700 'ctl.detailsTitle': 'Developer details (D)',
701 'ctl.helpTitle': 'Presenter shortcuts (?)',
702 'ph.idle': 'Empty',
703 'ph.chamber': 'Cooling',
704 'ph.cleared': 'Cleared',
705 'ph.waiting': 'No clearance',
706 'ph.ready': 'Ready',
707 'ph.executed': 'Executed',
708 'ph.ejected': 'Vetoed',
709 'who.cre': 'Chainlink CRE',
710 'who.human': 'the emergency guardian',
711 'who.approver': 'the approver (by hand)',
712 'who.someone': 'an unknown address',
713 'who.unknown': 'unknown address {a}',
714 'h.eyebrow': 'Proposal #{id} · {kind}',
715 'h.replay': 'Replay · cooldown fast-forwarded',
716 'h.replayPaused': 'Replay paused',
717 'h.dropped': 'log removed by a reorg',
718 'h.newImpl': 'New implementation {a}',
719 'h.guardOff': 'Remove the {who} guardian',
720 'h.guardOn': 'Add {who} as a guardian',
721 'h.delay': 'Change the cooldown to {n} blocks',
722 'h.paused': 'New break-glass target {a}',
723 'h.nameRemoveCre': 'Remove CRE',
724 'h.nameGuard': 'Guardian change',
725 'h.nameDelay': 'Cooldown change',
726 'h.namePaused': 'Break-glass change',
727 'h.descRemoveCre': 'Tries to take Chainlink CRE off the guardian list',
728 'h.descGuard': 'A change to the airlock itself, judged from chain reads',
729 'h.descUnknown': 'An implementation this page does not recognise',
730 'h.empty': 'The airlock is empty',
731 'h.emptyDesc': 'A proposal waits {n} blocks (≈ {t}) in the chamber; the inner door only opens for a Chainlink CRE Clear',
732 'h.loading': 'Reading proposals from Ethereum Sepolia…',
733 'h.noSuch': 'No proposal #{id}',
734 'h.noSuchDesc': 'The newest proposal on Ethereum Sepolia is #{n}; this page shows #{id} as soon as it is proposed',
735 'h.pending': 'Contracts not deployed on Sepolia yet',
736 'h.noRecording': 'No recording yet',
737 'g.owner': 'Owner key',
738 'g.ownerSub': 'can only propose',
739 'g.proxy': 'DemoBTC proxy',
740 'g.proxyIdle': 'runs current code',
741 'g.proxySafe': 'untouched',
742 'g.proxyLive': 'now runs {name}',
743 'c.left': '{n|block|blocks}',
744 'c.leftSub': 'cooldown left',
745 'c.eta': '≈ {t}',
746 'c.empty': 'Waiting for a proposal',
747 'c.over': 'Cooldown over',
748 'c.noClear': 'No clearance on chain yet',
749 'c.ready': 'Ready to execute',
750 'c.readySub': 'Cleared · cooldown over',
751 'c.executed': 'Upgrade is live',
752 'c.ejected': 'Ejected',
753 'c.clearStamp': 'Cleared',
754 'c.vetoAt': 'Vetoed {n} {n|block|blocks} after the proposal',
755 'c.awaiting': 'Awaiting the Chainlink CRE verdict',
756 'c.cleared': 'Cleared by Chainlink CRE',
757 'c.clearedBy': 'Cleared by {who}',
758 'b.idle': 'Waiting for an upgrade proposal',
759 'b.chamber': 'In the airlock · awaiting the Chainlink CRE verdict',
760 'b.cleared': 'Cleared by {who} · the inner door opens after the cooldown',
761 'b.ready': 'Cleared by {who} · ready to execute',
762 'b.waiting': 'Cooldown over · no clearance, the inner door stays locked',
763 'b.executed': 'Executed · {name} is live',
764 'b.ejected': 'Ejected · vetoed by {who}',
765 'b.ejectedFar': 'Ejected · vetoed on chain',
766 'd.outer': 'Outer door',
767 'd.chamber': 'Chamber',
768 'd.inner': 'Inner door',
769 'd.outerIdle': 'Open for proposals',
770 'd.outerOn': 'Proposed in block #{b}',
771 'd.chamberSub': 'Cooldown {n} blocks ≈ {t}',
772 'd.innerLocked': 'Locked · until a Chainlink CRE Clear',
773 'd.innerUnlocked': 'Unlocked · opens at block #{b}',
774 'd.innerReady': 'Unlocked · cooldown over',
775 'd.innerOpen': 'Opened in block #{b}',
776 'd.innerSealed': 'Sealed for good · execute reverts',
777 'd.innerWaiting': 'Locked · no clearance',
778 'v.title': 'Chainlink CRE verdict',
779 'v.veto': 'Veto',
780 'v.clear': 'Clear',
781 'v.awaiting': 'Judging',
782 'v.none': '—',
783 'v.noneText': 'Once a proposal enters the airlock, its verdict lands on chain as a signed report.',
784 'v.clearText': 'Survived the attack simulation: every rule held.',
785 'v.clearTextCfg': 'The config check passed: the approver pre-approved it, or it does not weaken the airlock.',
786 'v.awaitText': 'A Clear or a Veto lands on chain as a signed report.',
787 'v.after': '<b>{n}</b> {n|block|blocks} · <b>{t}</b> after the proposal',
788 'v.afterEst': '<b>{n}</b> {n|block|blocks} · ≈ <b>{t}</b> after the proposal',
789 'v.human': 'Vetoed by the emergency guardian (a person), not by Chainlink CRE.',
790 'v.approver': 'Cleared by the approver by hand; Chainlink CRE still judges it, and its veto wins.',
791 'v.skipped': 'Executed without a Chainlink CRE verdict (cleared by the approver by hand).',
792 'v.loadingChip': 'Reading',
793 'v.loading': 'Reading the verdict from chain…',
794 'v.far': 'Vetoed on chain (the veto log is out of reach).',
795 'v.clearFar': 'Cleared on chain (the clearance log is out of reach).',
796 'p.sim': 'Attack simulation',
797 'p.simS': 'eth_simulateV1 on 4 RPC providers',
798 'p.agree': 'All answers agree',
799 'p.agreeS': 'at least 2 of 4 answer · every answer matches',
800 'p.cfg': 'Config check',
801 'p.cfgS': 'chain reads at the proposal block: approver pre-approval',
802 'p.static': 'Approval check',
803 'p.staticS': 'chain reads at the proposal block, no simulation needed',
804 'p.simFail': 'Simulation undecided',
805 'p.simFailS': 'no clear, agreed answer: fail-closed veto',
806 'p.don': 'Consensus',
807 'p.donS': 'verdict and rule must be identical to aggregate',
808 'p.sign': 'Signed report',
809 'p.signS': 'EIP-712, bound to this exact proposal',
810 'p.chain': 'On chain',
811 'p.chainS': 'forwarder → AirlockReceiver → AirlockAdmin',
812 'p.landVeto': 'Veto on chain',
813 'p.landClear': 'Clear on chain',
814 'l.proposal': 'Proposal tx',
815 'l.report': 'Report tx',
816 'l.execute': 'Execute tx',
817 'l.evidence': 'Evidence hash',
818 's.title': 'Proposals on Ethereum Sepolia',
819 's.titleOffline': 'Recorded proposals',
820 's.titleFork': 'Proposals on the local fork',
821 's.follow': 'Auto-follow',
822 's.loop': 'Replay loop',
823 's.on': 'on',
824 's.off': 'off',
825 's.hint': '← → step · N newest · Space replay · ? shortcuts',
826 's.loading': 'Loading proposals…',
827 's.none': 'No proposals yet',
828 's.veto': 'Veto',
829 's.clear': 'Clear',
830 's.after': '+{n} {n|block|blocks}',
831 's.left': '{n} {n|block|blocks} left',
832 's.noClear': 'No clearance yet',
833 's.executed': 'executed',
834 's.ready': 'ready to execute',
835 's.allHeld': 'Every rule held',
836 's.cfgOk': 'Config check passed',
837 's.awaiting': 'Awaiting the CRE verdict',
838 's.replaying': 'Replaying',
839 'dr.title': 'Details',
840 'dr.close': 'Close (D / Esc)',
841 'dr.proposal': 'This proposal',
842 'dr.contracts': 'Contracts',
843 'dr.conn': 'Connection',
844 'dr.stream': 'Event stream',
845 'dr.about': 'Where the evidence hash comes from',
846 'dr.kind': 'Kind',
847 'dr.target': 'Target',
848 'dr.codehash': 'Codehash',
849 'dr.proposer': 'Proposer',
850 'dr.proposed': 'Proposed',
851 'dr.cooldown': 'Cooldown',
852 'dr.cooldownV': '#{a} → #{b} ({n} blocks)',
853 'dr.verdictTx': 'Verdict tx',
854 'dr.cost': 'Verdict tx cost',
855 'dr.costV': '{c} (gasUsed {g} × {p})',
856 'dr.evidence': 'Evidence hash',
857 'dr.workflow': 'Workflow id',
858 'dr.executed': 'Executed',
859 'dr.atBlock': 'block #{b}',
860 'dr.reading': 'reading…',
861 'dr.verified': '✓ verified on Sourcify',
862 'dr.heads': 'Block feed',
863 'dr.logs': 'Log feed',
864 'dr.bt': 'Block time',
865 'dr.endpoint': 'Endpoint',
866 'dr.recording': 'Recording',
867 'dr.recordingV': '{src} · blocks #{a} – #{b} · recorded {d}',
868 'dr.noStream': 'No events yet',
869 'dr.aboutBody':
870 "evidenceHash = keccak256 of the canonical evidence JSON (the proposal fields, proposalDigest, verdict and rule, keys sorted). The workflow signs an EIP-712 verdict over this evidence with its attester key; on a DON deployment the nodes must also agree on the same verdict before a report is signed. These verdicts came from the official CRE CLI running the workflow on a server, written through Chainlink's Sepolia test forwarder. AirlockReceiver checks the forwarder, the EIP-712 signature and the proposalDigest before it clears or vetoes. Anyone can recompute the hash from chain data.",
871 'src.sepolia': 'Ethereum Sepolia',
872 'src.fork': 'a local Sepolia fork',
873 'link.ws': 'WebSocket newHeads',
874 'link.poll': 'HTTP polling every {s} s',
875 'link.down': 'reconnecting',
876 'link.logsWs': 'WebSocket subscription',
877 'link.logsPoll': 'eth_getLogs cursor at #{c}',
878 'link.connecting': 'connecting…',
879 'link.offline': 'no network at all',
880 'bt.measured': '{s} s per block (measured)',
881 'bt.fast': 'fast-mined fork',
882 'bt.measuring': 'measuring…',
883 'help.title': 'Presenter shortcuts',
884 'help.prev': 'Previous (older) proposal',
885 'help.next': 'Next (newer) proposal',
886 'help.newest': 'Jump to the newest proposal',
887 'help.replay': "Replay the selected proposal's verdict moment",
888 'help.follow': 'Auto-follow each new proposal (offline: replay loop)',
889 'help.loop': 'Play / pause the replay loop',
890 'help.details': 'Developer details drawer',
891 'help.lang': 'Switch language',
892 'help.help': 'Show / hide this help',
893 'help.esc': 'Close panels',
894 'help.space': 'Space',
895 'help.url': 'URL options',
896 'help.urlBody': '?lang=en | zh-CN | zh-TW · ?mode=offline replay · ?p=<proposal id> · ?clean=1 for recording, no presenter controls · ?rpc=<local fork>',
897 'help.foot': 'Press ? or Esc to close · the presenter controls hide after 3 s without mouse movement',
898 'lab.title': 'Pick an attack and watch Chainlink CRE stop it',
899 'lab.sub': 'Each card replays a real proposal on Ethereum Sepolia',
900 'lab.invite': 'That was a real replay of "{name}" · pick another attack',
901 'lab.tabLab': 'Attack replays',
902 'lab.tabRecent': 'On-chain proposals',
903 'lab.tabsTitle': 'Show the attack replays or the newest proposals on chain',
904 'lab.play': 'Watch the replay',
905 'lab.again': 'Watch again',
906 'lab.none': 'No proposal of this kind yet',
907 'lab.loading': 'Loading…',
908 'lab.live': 'Live',
909 'lab.liveFoot': 'Being judged on chain · tap to see',
910 'lab.now': 'Replaying #{id} · a real Sepolia proposal',
911 'lab.proposalTx': 'Proposal tx',
912 'lab.st.arrive': 'Enters outer door',
913 'lab.st.judge': 'CRE judging',
914 'lab.st.veto': 'Vetoed · ejected',
915 'lab.st.clear': 'Cleared · unlocked',
916 'lab.st.fast': 'Fast-forward',
917 'lab.st.exec': 'Upgrade executed',
918 'h.replayReal': 'Replay · a real Sepolia proposal',
919 'h.live': 'Live',
920 'st.replayOf': 'Replay · Sepolia proposal #{id}',
921 'h.introEyebrow': 'Real attack replay',
922 'h.introTitle': 'Loading a real attack…',
923 'h.introDesc': 'In a moment a real proposal on Ethereum Sepolia replays from the start: outer door → chamber → Chainlink CRE attack-simulates it and rules',
924 'c.intro': 'Replay starting…',
925 'c.arriving': 'Proposal #{id} reaching the outer door…',
926 'b.intro': 'The inner door stays locked: only a Chainlink CRE Clear opens it',
927 'b.arriving': 'A proposal enters through the outer door, then waits out the cooldown in the chamber',
928 'v.introChip': 'Awaiting the replay',
929 'v.introText': 'The CRE verdict appears here when the replay reaches it.',
930 'h.slowTitle': 'Sepolia is slow to answer…',
931 'h.slowDesc': 'The public Sepolia RPCs are slow or unreachable; the page keeps retrying. Or watch the recorded real Sepolia run:',
932 'h.slowLink': 'offline replay',
933 'c.slow': 'Still reading the chain…',
934 'c.step': 'CRE · {s}\u00a0{i}/{n}',
935 'c.compressed': 'replay · time compressed',
936 'c.ff': 'Cooldown fast-forward ×{n}',
937 'h.onchain': 'On-chain state · proposed {d}',
938 'h.onchainNoDate': 'On-chain state',
939 'v.replayTag': 'Replay',
940 'h.pendEyebrow': 'Just launched by the operator · a real transaction',
941 'h.pendDesc': 'The transaction is out, waiting to be mined into an Ethereum Sepolia block; then the proposal enters the outer door',
942 'c.pending': 'Waiting to be mined…',
943 'b.pending': 'Once mined, the proposal waits in the chamber while Chainlink CRE attack-simulates it',
944 'v.pendChip': 'Waiting for the block',
945 'v.pendText': 'Once the proposal is on chain, Chainlink CRE usually rules within 20–40 s.',
946 'op.btn': 'Launch live',
947 'op.badge': 'Operator',
948 'op.title': 'Launch a real attack on Sepolia',
949 'op.note': 'Sends a real Ethereum Sepolia transaction as the owner; Chainlink CRE usually judges it within 20–40 s and the war room follows it.',
950 'op.gap': 'At least {n} {n|block|blocks} between launches',
951 'op.launch': 'Launch on Sepolia',
952 'op.launchShort': 'Launch',
953 'op.expectVeto': 'Expected: veto {rule}',
954 'op.expectClear': 'Expected: clear, executable after the cooldown',
955 'op.confirmQ': 'Launch "{name}" on Sepolia?',
956 'op.confirmBody': 'This sends a real transaction ({fn}); it cannot be taken back.',
957 'op.confirm': 'Confirm launch',
958 'op.cancel': 'Cancel',
959 'op.close': 'Close',
960 'op.busy': 'The last one is still on its way',
961 'op.sending': 'Sending the transaction…',
962 'op.sent': 'Transaction sent · waiting to be mined…',
963 'op.slow': 'No proposal from this transaction yet · check it on Etherscan',
964 'op.onchain': 'Proposal #{id} is in the airlock · awaiting the Chainlink CRE verdict',
965 'op.veto': 'Proposal #{id} vetoed · {who}{rule}',
966 'op.clear': 'Proposal #{id} cleared · {who}',
967 'op.exec': 'Proposal #{id} executed',
968 'op.errCard': 'This card cannot be launched',
969 'op.errAuth': 'Operator token rejected · operator mode is off',
970 'op.errCooldown': 'Cooling down · wait {n} more {n|block|blocks} (≈ {t})',
971 'op.errCooldownAny': 'Cooling down · the last transaction is not confirmed yet, try again shortly',
972 'op.errRpc': 'The node did not take the transaction · try again shortly',
973 'op.errDisabled': 'Operator mode is switched off here · nothing was sent',
974 'op.errReverted': "The node's dry run of this proposal reverted · nothing was sent",
975 'op.errServer': 'The operator service failed · nothing was sent, try again shortly',
976 'op.errMisconfigured': "The server's key is not this deployment's owner · nothing was sent",
977 'op.errForbidden': 'The operator service refused this origin (same site or this machine only) · nothing was sent',
978 'op.errUnclear': 'Unreadable answer from the operator service · if it was sent, the war room picks it up',
979 'op.errNet': 'Cannot reach the operator service · no answer',
980 'op.authNone': 'Local mode · no token needed',
981 'op.authToken': 'Signed in with a token',
982 'op.signOut': 'Leave operator mode',
983 'op.tokenTitle': 'Operator token',
984 'op.tokenBody': 'The token stays in this browser and never in the address bar.',
985 'op.tokenBad': 'That does not look like a token',
986 'op.tokenSave': 'Save',
987 'help.lab': 'Replay attack 1–7 (the picker cards, left to right)',
988 'help.operator': 'Live launch panel (operator)',
989 'rp.readyCleared': 'Cooldown over #{id}: block {b}, cleared, the inner door can open',
990 'rp.readyWaiting': 'Cooldown over #{id}: block {b}, not cleared yet, execute reverts NotCleared',
991 'ev.proposed': 'Proposed #{id}: block {b}, cooldown ends {rb}, tx {tx}',
992 'ev.vetoed': 'Vetoed #{id}: {who} in block {b}{after} · {rule} · vote {n}',
993 'ev.cleared': 'Cleared #{id}: {who} in block {b}{after} · evidence {h}',
994 'ev.executed': 'Executed #{id}: block {b}, tx {tx}',
995 'ev.reason': 'ReasonAttached #{id}: evidence hash {h}',
996 'ev.verdict': 'Verdict #{id}: Chainlink CRE {what} · evidence {h}',
997 'ev.vClear': 'Clear',
998 'ev.vVeto': 'Veto {rule}',
999 'ev.after': ' ({n} {n|block|blocks} after the proposal)',
1000 'ev.removed': 'Reorg: log {tx} removed',
1001 'ev.loaded': 'Loaded the newest {n} proposals from the chain (nextId {next})',
1002 'ev.loadFail': 'Loading recent proposals failed (try {n}), retrying: {err}',
1003 'off.loaded': 'Offline replay: {n} recorded proposals loaded ({d}), no network',
1004 'dur.sec': '{s} s',
1005 'dur.min': '{m} min {s} s',
1006 'dur.minOnly': '{m} min',
1007}
1008
1009// ---------- open sets: rules, proposal kinds, demo implementations ----------
1010type Table = Record<string, string>
1011/** What the rule caught, in one plain sentence (README rules table) */
1012const RULES: Record<Lang, Table> = {
1013 'zh-CN': {
1014 R1_TOTAL_SUPPLY: '陌生地址的一次调用就改变了总供应量。',
1015 R2_OWNER: '陌生地址的一次调用就改变了 owner。',
1016 R3_TRANSFER_CONSERVATION: '持有人转出的金额和实际移动的金额不一致。',
1017 R3B_THIRD_PARTY_FUNDS: '外人没有授权就搬走了持有人的代币。',
1018 R4_TRANSFER_LIVENESS: '持有人的正常转账失败了。',
1019 R5_STRICT_CODEHASH: '严格模式:approver 还没核准这个 codehash。',
1020 R6_SIM_FAILURE: '模拟无法判定(RPC 不一致、回答太少或模拟失败),fail-closed 否决。',
1021 R7_CONFIG_GUARD: '没有 approver 预先核准的 guardian、冷静期或 break-glass 变更。',
1022 R8_INIT_DATA: '升级夹带了 approver 没核准的初始化数据。',
1023 R9_NEW_OPCODES: '代码含有在真实区块和模拟里行为不同的 opcode(如 BASEFEE)。',
1024 },
1025 'zh-TW': {
1026 R1_TOTAL_SUPPLY: '陌生地址的一次呼叫就改變了總供應量。',
1027 R2_OWNER: '陌生地址的一次呼叫就改變了 owner。',
1028 R3_TRANSFER_CONSERVATION: '持有人轉出的金額和實際移動的金額不一致。',
1029 R3B_THIRD_PARTY_FUNDS: '外人沒有授權就搬走了持有人的代幣。',
1030 R4_TRANSFER_LIVENESS: '持有人的正常轉帳失敗了。',
1031 R5_STRICT_CODEHASH: '嚴格模式:approver 還沒核准這個 codehash。',
1032 R6_SIM_FAILURE: '模擬無法判定(RPC 不一致、回答太少或模擬失敗),fail-closed 否決。',
1033 R7_CONFIG_GUARD: '沒有 approver 預先核准的 guardian、冷靜期或 break-glass 變更。',
1034 R8_INIT_DATA: '升級夾帶了 approver 沒核准的初始化資料。',
1035 R9_NEW_OPCODES: '程式碼含有在真實區塊和模擬裡行為不同的 opcode(如 BASEFEE)。',
1036 },
1037 en: {
1038 R1_TOTAL_SUPPLY: "A stranger's call changed the total supply.",
1039 R2_OWNER: "A stranger's call changed the owner.",
1040 R3_TRANSFER_CONSERVATION: "A holder's transfer did not move exactly the amount sent.",
1041 R3B_THIRD_PARTY_FUNDS: "An outsider moved a holder's tokens without an allowance.",
1042 R4_TRANSFER_LIVENESS: "A plain holder transfer failed.",
1043 R5_STRICT_CODEHASH: 'Strict mode: the approver has not approved this codehash.',
1044 R6_SIM_FAILURE: 'The simulation could not decide (providers disagreed, too few answered or it failed): fail-closed.',
1045 R7_CONFIG_GUARD: "A guardian, cooldown or break-glass change without the approver's pre-approval.",
1046 R8_INIT_DATA: 'The upgrade carries init data the approver has not approved.',
1047 R9_NEW_OPCODES: 'The code uses an opcode that acts differently on a real block than in a simulation (e.g. BASEFEE).',
1048 },
1049}
1050/** Rule names short enough for a proposal card */
1051const RULES_SHORT: Record<Lang, Table> = {
1052 'zh-CN': {
1053 R1_TOTAL_SUPPLY: '总供应量被改',
1054 R2_OWNER: 'owner 被夺',
1055 R3_TRANSFER_CONSERVATION: '转账不守恒',
1056 R3B_THIRD_PARTY_FUNDS: '持有人的币被搬',
1057 R4_TRANSFER_LIVENESS: '转账失效',
1058 R5_STRICT_CODEHASH: 'codehash 未核准',
1059 R6_SIM_FAILURE: '模拟无法判定',
1060 R7_CONFIG_GUARD: '配置变更未核准',
1061 R8_INIT_DATA: '初始化数据未核准',
1062 R9_NEW_OPCODES: '规避模拟的 opcode',
1063 },
1064 'zh-TW': {
1065 R1_TOTAL_SUPPLY: '總供應量被改',
1066 R2_OWNER: 'owner 被奪',
1067 R3_TRANSFER_CONSERVATION: '轉帳不守恆',
1068 R3B_THIRD_PARTY_FUNDS: '持有人的幣被搬',
1069 R4_TRANSFER_LIVENESS: '轉帳失效',
1070 R5_STRICT_CODEHASH: 'codehash 未核准',
1071 R6_SIM_FAILURE: '模擬無法判定',
1072 R7_CONFIG_GUARD: '設定變更未核准',
1073 R8_INIT_DATA: '初始化資料未核准',
1074 R9_NEW_OPCODES: '規避模擬的 opcode',
1075 },
1076 en: {
1077 R1_TOTAL_SUPPLY: 'Supply changed',
1078 R2_OWNER: 'Owner seized',
1079 R3_TRANSFER_CONSERVATION: 'Transfer not conserved',
1080 R3B_THIRD_PARTY_FUNDS: 'Holder funds moved',
1081 R4_TRANSFER_LIVENESS: 'Transfers broken',
1082 R5_STRICT_CODEHASH: 'Codehash not approved',
1083 R6_SIM_FAILURE: 'Simulation undecided',
1084 R7_CONFIG_GUARD: 'Config not approved',
1085 R8_INIT_DATA: 'Unapproved init data',
1086 R9_NEW_OPCODES: 'Evades simulation',
1087 },
1088}
1089const KINDS: Record<Lang, Table> = {
1090 'zh-CN': { '0': '升级', '1': 'guardian 变更', '2': '冷静期变更', '3': '暂停实现变更' },
1091 'zh-TW': { '0': '升級', '1': 'guardian 變更', '2': '冷靜期變更', '3': '暫停實作變更' },
1092 en: { '0': 'Upgrade', '1': 'Guardian change', '2': 'Cooldown change', '3': 'Break-glass change' },
1093}
1094const CARD_DESC: Record<Lang, Record<CardId, string>> = {
1095 'zh-CN': {
1096 evilMint: '任何人都能 mint:无限增发,selector 与 v1 完全相同',
1097 evilOwner: '新增 claimOwner():任何人都能夺走 owner',
1098 evilFee: 'selector 完全不变,每笔转账偷抽 10% 给 owner',
1099 evilBaseFee: '模拟里(basefee 为 0)表现得像 v1,上了真实区块任何人都能 mint',
1100 evilDrain: '新增 rescue():任何人都能不经授权搬走持有人的币',
1101 v2: '诚实的升级:只多一个 version()',
1102 },
1103 'zh-TW': {
1104 evilMint: '任何人都能 mint:無限增發,selector 與 v1 完全相同',
1105 evilOwner: '新增 claimOwner():任何人都能奪走 owner',
1106 evilFee: 'selector 完全不變,每筆轉帳偷抽 10% 給 owner',
1107 evilBaseFee: '模擬裡(basefee 為 0)表現得像 v1,上了真實區塊任何人都能 mint',
1108 evilDrain: '新增 rescue():任何人都能不經授權搬走持有人的幣',
1109 v2: '誠實的升級:只多一個 version()',
1110 },
1111 en: {
1112 evilMint: 'Anyone can mint: unlimited supply behind the same selectors as v1',
1113 evilOwner: 'Adds claimOwner(): anyone can seize ownership',
1114 evilFee: 'Same selectors; every transfer skims 10% to the owner',
1115 evilBaseFee: 'Acts like v1 in a simulation (basefee 0); on a real block anyone can mint',
1116 evilDrain: "Adds rescue(): anyone can move a holder's tokens without an allowance",
1117 v2: 'The honest upgrade: it only adds version()',
1118 },
1119}
1120
1121/** Attack picker: each card's short name and one plain sentence of what the attack does (lab.ts LAB_CARDS) */
1122const LAB_NAME: Record<Lang, Record<LabId, string>> = {
1123 'zh-CN': {
1124 'evil-mint': '任意增发',
1125 'evil-owner': '抢走 owner',
1126 'evil-fee': '转账抽成',
1127 'evil-basefee': '只在真实区块作恶',
1128 'evil-drain': '搬走持有人的币',
1129 'remove-receiver': '先撤掉 CRE',
1130 v2: '诚实升级',
1131 },
1132 'zh-TW': {
1133 'evil-mint': '任意增發',
1134 'evil-owner': '搶走 owner',
1135 'evil-fee': '轉帳抽成',
1136 'evil-basefee': '只在真實區塊作惡',
1137 'evil-drain': '搬走持有人的幣',
1138 'remove-receiver': '先撤掉 CRE',
1139 v2: '誠實升級',
1140 },
1141 en: {
1142 'evil-mint': 'Infinite mint',
1143 'evil-owner': 'Steal ownership',
1144 'evil-fee': 'Skim transfers',
1145 'evil-basefee': 'On-chain-only evil',
1146 'evil-drain': 'Drain holders',
1147 'remove-receiver': 'Remove CRE first',
1148 v2: 'Honest upgrade',
1149 },
1150}
1151const LAB_LINE: Record<Lang, Record<LabId, string>> = {
1152 'zh-CN': {
1153 'evil-mint': '升级后任何人都能凭空增发代币',
1154 'evil-owner': '升级后任何人都能把合约的 owner 抢走',
1155 'evil-fee': '每笔转账偷抽 10% 给 owner,函数一个都没变',
1156 'evil-basefee': '模拟里装成好人,上了真实区块才让人增发',
1157 'evil-drain': '新增 rescue():不经授权就能搬走别人的币',
1158 'remove-receiver': '先把 CRE 移出 guardian 名单,再来就没人拦',
1159 v2: '只多一个 version() 的诚实升级:放行后执行',
1160 },
1161 'zh-TW': {
1162 'evil-mint': '升級後任何人都能憑空增發代幣',
1163 'evil-owner': '升級後任何人都能把合約的 owner 搶走',
1164 'evil-fee': '每筆轉帳偷抽 10% 給 owner,函式一個都沒變',
1165 'evil-basefee': '模擬裡裝成好人,上了真實區塊才讓人增發',
1166 'evil-drain': '新增 rescue():不經授權就能搬走別人的幣',
1167 'remove-receiver': '先把 CRE 移出 guardian 名單,再來就沒人攔',
1168 v2: '只多一個 version() 的誠實升級:放行後執行',
1169 },
1170 en: {
1171 'evil-mint': 'After the upgrade anyone can mint tokens out of thin air',
1172 'evil-owner': 'After the upgrade anyone can take over the owner role',
1173 'evil-fee': 'Every transfer quietly skims 10% to the owner',
1174 'evil-basefee': 'Plays nice in a simulation, lets anyone mint on a real block',
1175 'evil-drain': "Adds rescue(): anyone can move other people's tokens",
1176 'remove-receiver': 'Takes CRE off the guardian list before the real attack',
1177 v2: 'Only adds version(): cleared, then executed',
1178 },
1179}
1180
1181const prefix = (pre: string, t: Table) => Object.fromEntries(Object.entries(t).map(([k, v]) => [pre + k, v]))
1182const BASE: Record<Lang, Record<Key, string>> = { 'zh-CN': ZH_CN, 'zh-TW': ZH_TW, en: EN }
1183export const DICT: Record<Lang, Table> = Object.fromEntries(
1184 LANGS.map((l) => [
1185 l,
1186 { ...BASE[l], ...prefix('rule.', RULES[l]), ...prefix('ruleS.', RULES_SHORT[l]), ...prefix('kind.', KINDS[l]), ...prefix('card.', CARD_DESC[l]), ...prefix('labN.', LAB_NAME[l]), ...prefix('labL.', LAB_LINE[l]) },
1187 ]),
1188) as unknown as Record<Lang, Table>
1189
1190// ---------- current language ----------
1191let cur: Lang = 'zh-CN'
1192const listeners = new Set<() => void>()
1193
1194export const isLang = (x: unknown): x is Lang => typeof x === 'string' && (LANGS as readonly string[]).includes(x)
1195export const lang = (): Lang => cur
1196export function onLangChange(fn: () => void) {
1197 listeners.add(fn)
1198}
1199
1200function apply() {
1201 if (typeof document === 'undefined') return
1202 try {
1203 document.title = t('doc.title')
1204 if (document.documentElement) document.documentElement.lang = cur
1205 } catch {
1206 /* the Node test DOM has none of this */
1207 }
1208}
1209
1210/** Once on page load: ?lang= first, then localStorage, else zh-CN */
1211export function initLang(): Lang {
1212 let pick: Lang = 'zh-CN'
1213 try {
1214 const q = new URLSearchParams(globalThis.location?.search ?? '').get('lang')
1215 if (isLang(q)) pick = q
1216 else {
1217 const s = globalThis.localStorage?.getItem(LS_KEY)
1218 if (isLang(s)) pick = s
1219 }
1220 } catch {
1221 /* private mode etc.: default */
1222 }
1223 cur = pick
1224 apply()
1225 return cur
1226}
1227
1228export function setLang(l: Lang, persist = true) {
1229 if (!isLang(l)) return
1230 cur = l
1231 apply()
1232 if (persist) {
1233 try {
1234 globalThis.localStorage?.setItem(LS_KEY, l)
1235 } catch {
1236 /* ignore */
1237 }
1238 // keep ?lang= in the address bar in step, so a reload keeps the language (no navigation)
1239 try {
1240 const u = new URL(location.href)
1241 if (u.searchParams.has('lang')) {
1242 u.searchParams.set('lang', l)
1243 history.replaceState(history.state, '', u)
1244 }
1245 } catch {
1246 /* ignore */
1247 }
1248 }
1249 for (const fn of listeners) fn()
1250}
1251
1252export function cycleLang(): Lang {
1253 setLang(LANGS[(LANGS.indexOf(cur) + 1) % LANGS.length])
1254 return cur
1255}
1256
1257// ---------- translation ----------
1258/** {name} is replaced by the param; {name|one|many} picks a word by the param's number (English plurals) */
1259function fill(l: Lang, s: string, p?: Record<string, Param>): string {
1260 if (!p) return s
1261 return s.replace(/\{(\w+)(?:\|([^|{}]*)\|([^|{}]*))?\}/g, (all, name: string, one?: string, many?: string) => {
1262 const v = p[name]
1263 if (v === undefined || v === null) return all
1264 if (one !== undefined) return Number(v) === 1 ? one : (many ?? '')
1265 return typeof v === 'object' ? tIn(l, v.k, v.p) : String(v)
1266 })
1267}
1268
1269/** Translate in a given language (missing: zh-CN, then the key itself) */
1270export function tIn(l: Lang, key: string, p?: Record<string, Param>): string {
1271 return fill(l, DICT[l][key] ?? DICT['zh-CN'][key] ?? key, p)
1272}
1273
1274/** Current language. Params go in as they are: the caller esc()s outside values that end up in innerHTML */
1275export function t(key: Key, p?: Record<string, Param>): string {
1276 return tIn(cur, key, p)
1277}
1278export const tm = (m: Msg): string => tIn(cur, m.k, m.p)
1279
1280const tOr = (key: string, fallback: string): string => DICT[cur][key] ?? DICT['zh-CN'][key] ?? fallback
1281export const ruleText = (rule: string): string => tOr(`rule.${rule}`, rule)
1282export const ruleShortText = (rule: string): string => tOr(`ruleS.${rule}`, rule)
1283export const kindText = (kind: string): string => tOr(`kind.${kind}`, `kind ${kind}`)
1284export const cardDesc = (id: CardId): string => tOr(`card.${id}`, id)
1285export const labName = (id: LabId): string => tOr(`labN.${id}`, id)
1286export const labLine = (id: LabId): string => tOr(`labL.${id}`, id)