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 / playback.ts
web/src/playback.ts120 lines · 5258 bytes
1// The page's playback controller (replay.ts engine bound to the store): offline mode plays the recorded run in a loop
2// from page load; live mode stays on the real chain state and plays recent ones only after P. Space replays the
3// selected proposal once in either mode.
4import { blocks } from './chain'
5import { CONFIG } from './config'
6import { whoMsg } from './events'
7import { msg } from './i18n'
8import { AutoReplay, type ReplayFrame, type Timeline, type TLStep } from './replay'
9import { OFFLINE } from './runtime'
10import { short, store, type Proposal } from './store'
11
12/** live mode: page loaded and the recent proposals are in (set by chainwatch / offline loaders). loaded: the proposals
13 * whose history (logs) is already in, so each one can replay before all the others are read. */
14export const playbackGate = { ready: false, loaded: new Set<string>(), startedAt: 0 }
15/** This proposal's history is in: it can be replayed */
16export const historyIn = (id: string): boolean => playbackGate.ready || playbackGate.loaded.has(id)
17
18function logStep(tl: Timeline, st: TLStep, src: Proposal) {
19 const id = tl.id
20 const ts = Date.now()
21 switch (st.kind) {
22 case 'proposed':
23 store.logT('chain', 'Proposed', msg('ev.proposed', { id, b: tl.proposeBlock, rb: tl.readyBlock, tx: short(src.proposeTx) }), 'info', ts, true)
24 break
25 case 'veto': {
26 const a = src.guards[st.guard!]
27 const real = a?.block ?? st.block
28 store.logT(
29 'chain',
30 'Vetoed',
31 msg('ev.vetoed', { id, who: whoMsg(st.guard!), b: real, after: msg('ev.after', { n: real - tl.proposeBlock }), rule: a?.rule ?? short(a?.ruleHash, 4), n: tl.steps.filter((x) => x.kind === 'veto').indexOf(st) + 1 }),
32 'bad',
33 ts,
34 true,
35 )
36 break
37 }
38 case 'reason':
39 store.logT('chain', 'ReasonAttached', msg('ev.reason', { id, h: short(src.reason?.hash) }), 'info', ts, true)
40 break
41 case 'clear': {
42 const c = src.clearance!
43 const real = c.block ?? st.block
44 store.logT('chain', 'Cleared', msg('ev.cleared', { id, who: whoMsg(c.by, c.clearer), b: real, after: msg('ev.after', { n: real - tl.proposeBlock }), h: short(c.evidenceHash) }), 'good', ts, true)
45 break
46 }
47 case 'ready': {
48 // only cleared timelines have this step (an ejected one ends shortly after its veto)
49 const cleared = tl.steps.some((x) => x.kind === 'clear' && x.block <= st.block)
50 store.logT('chain', 'ready', msg(cleared ? 'rp.readyCleared' : 'rp.readyWaiting', { id, b: st.block }), cleared ? 'good' : 'warn', ts, true)
51 break
52 }
53 case 'executed':
54 store.logT('chain', 'Executed', msg('ev.executed', { id, b: src.execute?.block ?? st.block, tx: short(src.execute?.tx) }), 'good', ts, true)
55 break
56 }
57}
58
59export const replay = new AutoReplay(
60 Date.now(),
61 {
62 msPerBlock: CONFIG.replay.msPerBlock,
63 dwellMs: CONFIG.replay.dwellMs,
64 firstDelayMs: OFFLINE ? 0 : CONFIG.replay.idleMs,
65 idleMs: OFFLINE ? CONFIG.replay.offlineIdleMs : CONFIG.replay.idleMs,
66 gapMs: CONFIG.replay.gapMs,
67 reducedHoldMs: CONFIG.replay.reducedHoldMs,
68 },
69 logStep,
70)
71// live mode stays on the newest proposal's real state (a live proposal can arrive any moment); P starts playback
72if (!OFFLINE) replay.paused = true
73
74const reducedMotion = () => {
75 try {
76 return window.matchMedia('(prefers-reduced-motion: reduce)').matches
77 } catch {
78 return false
79 }
80}
81
82/** live mode: the newest proposal is still in its cooldown */
83export function liveBusy(): boolean {
84 if (OFFLINE) return false
85 let newest: Proposal | null = null
86 for (const p of store.proposals.values()) if (!newest || Number(p.id) > Number(newest.id)) newest = p
87 return !!newest && newest.chain?.status === 1 && newest.readyBlock !== null && blocks.latest > 0 && blocks.latest < newest.readyBlock
88}
89
90/** Called every frame; returns whether the page must redraw */
91export function tickReplay(now = Date.now()): boolean {
92 // the loop waits for every recent proposal; a single replay (a picker card, Space) only needs its own history
93 if (!playbackGate.ready && !replay.onDemand) return false
94 return replay.tick(now, { proposals: store.proposals.values(), get: (id) => store.proposals.get(id), reduced: reducedMotion(), liveBusy: liveBusy() })
95}
96
97export function replayFrame(now = Date.now()): ReplayFrame | null {
98 return replay.frame(now, (id) => store.proposals.get(id))
99}
100
101/** A replay between its proposal's arrival and its verdict (0..1); null when nothing plays */
102export function judgeProgress(now = Date.now()): number | null {
103 return replay.judgeProgress(now)
104}
105
106export function toggleReplay() {
107 replay.toggle(Date.now())
108 store.emit()
109}
110
111/** Space: replay one proposal's moment (arrival, verdict, and the inner door for an executed upgrade). The attack
112 * picker (lab.ts) calls replay.playOne itself, with its own timing. */
113export function replayProposal(id: string | null): boolean {
114 const p = id ? store.proposals.get(id) : undefined
115 if (!p || !historyIn(p.id)) return false
116 // an explicit replay request plays the stages even with prefers-reduced-motion (style.css tones the motion down)
117 const ok = replay.playOne(Date.now(), p, false)
118 store.emit()
119 return ok
120}