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. |
| 4 | import { blocks } from './chain' |
| 5 | import { CONFIG } from './config' |
| 6 | import { whoMsg } from './events' |
| 7 | import { msg } from './i18n' |
| 8 | import { AutoReplay, type ReplayFrame, type Timeline, type TLStep } from './replay' |
| 9 | import { OFFLINE } from './runtime' |
| 10 | import { 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. */ |
| 14 | export const playbackGate = { ready: false, loaded: new Set<string>(), startedAt: 0 } |
| 15 | /** This proposal's history is in: it can be replayed */ |
| 16 | export const historyIn = (id: string): boolean => playbackGate.ready || playbackGate.loaded.has(id) |
| 17 | |
| 18 | function 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 | |
| 59 | export 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 |
| 72 | if (!OFFLINE) replay.paused = true |
| 73 | |
| 74 | const 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 */ |
| 83 | export 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 */ |
| 91 | export 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 | |
| 97 | export 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 */ |
| 102 | export function judgeProgress(now = Date.now()): number | null { |
| 103 | return replay.judgeProgress(now) |
| 104 | } |
| 105 | |
| 106 | export 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. */ |
| 113 | export 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 | } |