web/src/replay.ts370 lines · 14925 bytes
| 1 | // Playback of a proposal that already finished, on a virtual block clock: offline mode plays the recorded run in a |
| 2 | // loop; live mode plays recent proposals when the viewer presses P, Space replays the selected one, and a card of the |
| 3 | // attack picker (lab.ts) replays the newest real proposal of that attack with its own timing (PlayOpts). Only data |
| 4 | // already read is used (proposal block, readyBlock, each verdict's block and tx, ReasonAttached, Cleared, Executed). |
| 5 | // The clock holds on the proposal, the verdict and the execution for a moment (dwellMs), so the screen can animate |
| 6 | // them; the blocks in between (the cooldown) run at msPerBlock. |
| 7 | // Pure logic: no DOM, no timers, no store; the caller passes the time in (tests drive it directly). |
| 8 | import { GUARDS, type GuardId } from './runtime' |
| 9 | import type { GuardAction, Proposal } from './store' |
| 10 | |
| 11 | export type StepKind = 'proposed' | 'veto' | 'reason' | 'clear' | 'ready' | 'executed' |
| 12 | export interface TLStep { |
| 13 | block: number |
| 14 | kind: StepKind |
| 15 | guard?: GuardId |
| 16 | } |
| 17 | /** ejected: a guardian vetoed it; cleared: a clearance landed (executed or not) */ |
| 18 | export type ReplayType = 'ejected' | 'cleared' |
| 19 | export interface Timeline { |
| 20 | id: string |
| 21 | type: ReplayType |
| 22 | /** virtual clock start: proposeBlock - LEAD_BLOCKS */ |
| 23 | start: number |
| 24 | /** virtual clock end: the last step + TAIL_BLOCKS (at least readyBlock + TAIL_BLOCKS) */ |
| 25 | end: number |
| 26 | proposeBlock: number |
| 27 | readyBlock: number |
| 28 | steps: TLStep[] |
| 29 | } |
| 30 | |
| 31 | export const LEAD_BLOCKS = 2 |
| 32 | export const TAIL_BLOCKS = 3 |
| 33 | /** An ejected proposal is final at its veto: the playback ends this many blocks later, without the rest of the cooldown */ |
| 34 | export const EJECT_TAIL_BLOCKS = 5 |
| 35 | /** Events up to this many blocks after readyBlock stretch the timeline; later ones are pinned to its end (their text |
| 36 | * keeps the real block number) */ |
| 37 | const LATE_WINDOW = 40 |
| 38 | const ORDER: Record<StepKind, number> = { proposed: 0, veto: 1, reason: 2, clear: 3, ready: 4, executed: 5 } |
| 39 | |
| 40 | const vetoBlock = (a: GuardAction | undefined): number | null => (a?.verdict === 'veto' && typeof a.block === 'number' && a.block > 0 ? a.block : null) |
| 41 | |
| 42 | export function replayType(p: Proposal): ReplayType | null { |
| 43 | if (!p.proposeBlock || !p.readyBlock || p.readyBlock < p.proposeBlock) return null |
| 44 | if (GUARDS.some((g) => vetoBlock(p.guards[g]) !== null)) return 'ejected' |
| 45 | if (p.clearance?.block) return 'cleared' |
| 46 | return null |
| 47 | } |
| 48 | |
| 49 | export function buildTimeline(p: Proposal): Timeline | null { |
| 50 | const type = replayType(p) |
| 51 | if (!type) return null |
| 52 | const pb = p.proposeBlock! |
| 53 | const rb = p.readyBlock! |
| 54 | const raw: TLStep[] = [{ block: pb, kind: 'proposed' }] |
| 55 | for (const g of GUARDS) { |
| 56 | const b = vetoBlock(p.guards[g]) |
| 57 | if (b !== null) raw.push({ block: b, kind: 'veto', guard: g }) |
| 58 | } |
| 59 | if (p.reason) raw.push({ block: p.reason.block, kind: 'reason' }) |
| 60 | // a vetoed proposal can not be cleared afterwards; a clearance before the veto is still shown |
| 61 | if (p.clearance?.block) raw.push({ block: p.clearance.block, kind: 'clear' }) |
| 62 | if (type === 'cleared') raw.push({ block: rb, kind: 'ready' }) |
| 63 | if (type === 'cleared' && p.execute) raw.push({ block: p.execute.block, kind: 'executed' }) |
| 64 | const inWindow = raw.filter((s) => s.block <= rb + LATE_WINDOW).map((s) => s.block) |
| 65 | const end = type === 'ejected' ? Math.min(rb + TAIL_BLOCKS, Math.max(...inWindow) + EJECT_TAIL_BLOCKS) : Math.max(rb, ...inWindow) + TAIL_BLOCKS |
| 66 | const steps = raw.map((s) => ({ ...s, block: Math.min(end, Math.max(pb, s.block)) })) |
| 67 | steps.sort((a, b) => a.block - b.block || ORDER[a.kind] - ORDER[b.kind] || String(a.guard).localeCompare(String(b.guard))) |
| 68 | return { id: p.id, type, start: pb - LEAD_BLOCKS, end, proposeBlock: pb, readyBlock: rb, steps } |
| 69 | } |
| 70 | |
| 71 | /** Proposals that can be played, oldest first */ |
| 72 | export function playable(proposals: Iterable<Proposal>): Proposal[] { |
| 73 | return [...proposals].filter((p) => replayType(p) !== null).sort((a, b) => Number(a.id) - Number(b.id)) |
| 74 | } |
| 75 | |
| 76 | /** What the proposal looked like at virtual block v (only steps already reached) */ |
| 77 | export function snapshotAt(src: Proposal, tl: Timeline, v: number): Proposal { |
| 78 | const reached = (kind: StepKind, g?: GuardId) => tl.steps.some((st) => st.kind === kind && (g === undefined || st.guard === g) && st.block <= v) |
| 79 | const proposed = v >= tl.proposeBlock |
| 80 | const guards = {} as Record<GuardId, GuardAction> |
| 81 | let vetoes = 0 |
| 82 | for (const g of GUARDS) { |
| 83 | const a = src.guards[g] |
| 84 | if (vetoBlock(a) !== null && reached('veto', g)) { |
| 85 | guards[g] = { ...a } |
| 86 | vetoes++ |
| 87 | } else if (g === 'cre' && a?.verdict === 'pass' && reached('clear')) guards[g] = { ...a } |
| 88 | else guards[g] = {} |
| 89 | } |
| 90 | const cleared = reached('clear') && !!src.clearance |
| 91 | const executed = tl.type === 'cleared' && reached('executed') |
| 92 | const status = !proposed ? null : vetoes > 0 ? 2 : executed ? 3 : 1 |
| 93 | return { |
| 94 | ...src, |
| 95 | proposeTx: proposed ? src.proposeTx : undefined, |
| 96 | guards, |
| 97 | clearance: cleared ? src.clearance : undefined, |
| 98 | reason: reached('reason') ? src.reason : undefined, |
| 99 | execute: executed ? src.execute : undefined, |
| 100 | chain: |
| 101 | status === null |
| 102 | ? undefined |
| 103 | : { status, vetoCount: vetoes, proposer: src.chain?.proposer ?? '', clearanceOf: cleared ? (src.clearance?.evidenceHash ?? null) : null }, |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | export interface ReplayFrame { |
| 108 | tl: Timeline |
| 109 | /** virtual block number (not the chain head) */ |
| 110 | vblock: number |
| 111 | p: Proposal |
| 112 | /** the clock is frozen (P while a playback runs) */ |
| 113 | paused: boolean |
| 114 | /** played to the end; holding the final state */ |
| 115 | hold: boolean |
| 116 | } |
| 117 | |
| 118 | export interface ReplayCtx { |
| 119 | proposals: Iterable<Proposal> |
| 120 | get(id: string): Proposal | undefined |
| 121 | reduced: boolean |
| 122 | /** live mode: a live proposal is in its cooldown, so nothing is replayed (live always wins) */ |
| 123 | liveBusy: boolean |
| 124 | } |
| 125 | |
| 126 | export interface ReplayOptions { |
| 127 | msPerBlock: number |
| 128 | /** hold the clock on these steps (ms); omitted = no hold. judging: on the block before the verdict (the CRE is at |
| 129 | * work), so a verdict one block after the proposal still shows the workflow running */ |
| 130 | dwellMs?: { lead?: number; proposed?: number; judging?: number; verdict?: number; executed?: number } |
| 131 | /** wait this long after page load before the first playback */ |
| 132 | firstDelayMs: number |
| 133 | /** wait this long after a live event (or a manual pick) before playing again */ |
| 134 | idleMs: number |
| 135 | gapMs: number |
| 136 | reducedHoldMs: number |
| 137 | } |
| 138 | |
| 139 | /** Timing for one playOne() call (the attack picker): a slower lead-in, longer holds and a fast-forwarded cooldown */ |
| 140 | export interface PlayOpts { |
| 141 | msPerBlock?: number |
| 142 | /** ms per block once the verdict has landed (the rest of the cooldown); omitted = msPerBlock */ |
| 143 | fastMsPerBlock?: number |
| 144 | /** lead: an extra hold at the start, while the proposal approaches the outer door */ |
| 145 | dwellMs?: { lead?: number; proposed?: number; judging?: number; verdict?: number; executed?: number } |
| 146 | /** hold the final state this long after the last block */ |
| 147 | gapMs?: number |
| 148 | } |
| 149 | |
| 150 | interface Seg { |
| 151 | tl: Timeline |
| 152 | t0: number |
| 153 | reduced: boolean |
| 154 | logged: number |
| 155 | /** block -> hold (ms) when the clock reaches it */ |
| 156 | dwell: Map<number, number> |
| 157 | /** ms per block before / from fastFrom on */ |
| 158 | ms: number |
| 159 | fastMs: number |
| 160 | fastFrom: number |
| 161 | gap: number |
| 162 | /** Space: one proposal on demand; it plays even while the loop is paused, and the loop does not follow it */ |
| 163 | once?: boolean |
| 164 | } |
| 165 | |
| 166 | /** Playback controller: tick(now) advances; onLive(now) interrupts; toggle(now) pauses and resumes (P key) */ |
| 167 | export class AutoReplay { |
| 168 | paused = false |
| 169 | private seg: Seg | null = null |
| 170 | private frozen: number | null = null |
| 171 | private lastLive = 0 |
| 172 | private lastId: string | null = null |
| 173 | private force = false |
| 174 | |
| 175 | private readonly loadTs: number |
| 176 | private readonly opt: ReplayOptions |
| 177 | private readonly onStep: (tl: Timeline, step: TLStep, src: Proposal) => void |
| 178 | |
| 179 | constructor(loadTs: number, opt: ReplayOptions, onStep: (tl: Timeline, step: TLStep, src: Proposal) => void = () => {}) { |
| 180 | this.loadTs = loadTs |
| 181 | this.opt = opt |
| 182 | this.onStep = onStep |
| 183 | } |
| 184 | |
| 185 | get active(): boolean { |
| 186 | return this.seg !== null |
| 187 | } |
| 188 | |
| 189 | /** A live event: stop playing now; the idle time counts from here */ |
| 190 | onLive(now: number) { |
| 191 | this.lastLive = now |
| 192 | this.seg = null |
| 193 | this.frozen = null |
| 194 | this.force = false |
| 195 | } |
| 196 | |
| 197 | /** The viewer picked a proposal: same as a live event, and the next playback starts after that proposal */ |
| 198 | stop(now: number, pickedId?: string) { |
| 199 | this.onLive(now) |
| 200 | if (pickedId) this.lastId = pickedId |
| 201 | } |
| 202 | |
| 203 | private idleEnough(now: number): boolean { |
| 204 | return this.lastLive ? now - this.lastLive >= this.opt.idleMs : now - this.loadTs >= this.opt.firstDelayMs |
| 205 | } |
| 206 | |
| 207 | /** The next playback starts with this proposal (offline ?p=) */ |
| 208 | startAt(id: string) { |
| 209 | this.lastId = String(Number(id) - 1) |
| 210 | } |
| 211 | |
| 212 | /** Space or a picker card: play this proposal now, whether or not the loop is paused; a live event still |
| 213 | * interrupts it. opts overrides the timing for this one playback only. */ |
| 214 | playOne(now: number, p: Proposal, reduced: boolean, opts?: PlayOpts): boolean { |
| 215 | const tl = buildTimeline(p) |
| 216 | if (!tl) return false |
| 217 | this.seg = this.newSeg(tl, now, reduced, opts) |
| 218 | this.seg.once = true |
| 219 | this.frozen = null |
| 220 | this.force = false |
| 221 | return true |
| 222 | } |
| 223 | |
| 224 | private newSeg(tl: Timeline, now: number, reduced: boolean, opts?: PlayOpts): Seg { |
| 225 | const d = opts?.dwellMs ?? this.opt.dwellMs ?? {} |
| 226 | const dwell = new Map<number, number>() |
| 227 | const add = (b: number, ms: number | undefined) => { |
| 228 | if (ms && ms > 0) dwell.set(b, (dwell.get(b) ?? 0) + ms) |
| 229 | } |
| 230 | add(tl.start, d.lead) |
| 231 | add(tl.proposeBlock, d.proposed) |
| 232 | const verdict = tl.steps.find((s) => s.kind === 'veto' || s.kind === 'clear') |
| 233 | if (verdict) { |
| 234 | add(Math.max(tl.proposeBlock, verdict.block - 1), d.judging) |
| 235 | add(verdict.block, d.verdict) |
| 236 | } |
| 237 | const exec = tl.steps.find((s) => s.kind === 'executed') |
| 238 | if (exec) add(exec.block, d.executed) |
| 239 | const ms = opts?.msPerBlock ?? this.opt.msPerBlock |
| 240 | // the fast clock starts on the block after the verdict, so the verdict itself plays at the normal pace |
| 241 | const fastFrom = verdict && opts?.fastMsPerBlock ? verdict.block + 1 : Infinity |
| 242 | return { tl, t0: now, reduced, logged: 0, dwell, ms, fastMs: opts?.fastMsPerBlock ?? ms, fastFrom, gap: opts?.gapMs ?? this.opt.gapMs } |
| 243 | } |
| 244 | |
| 245 | /** Pause: freeze the virtual block and do not start on its own; resume: continue, or start right away */ |
| 246 | toggle(now: number) { |
| 247 | if (!this.paused) { |
| 248 | this.paused = true |
| 249 | if (this.seg) this.frozen = now - this.seg.t0 |
| 250 | return |
| 251 | } |
| 252 | this.paused = false |
| 253 | if (this.seg && this.frozen !== null) this.seg.t0 = now - this.frozen |
| 254 | else this.force = true |
| 255 | this.frozen = null |
| 256 | } |
| 257 | |
| 258 | private vblockAt(now: number): number { |
| 259 | const s = this.seg! |
| 260 | if (s.reduced) return s.tl.end |
| 261 | let t = this.frozen ?? now - s.t0 |
| 262 | // each block: first its hold (if any), then msPerBlock to the next one |
| 263 | for (let b = s.tl.start; b < s.tl.end; b++) { |
| 264 | t -= s.dwell.get(b) ?? 0 |
| 265 | if (t < 0) return b |
| 266 | t -= b >= s.fastFrom ? s.fastMs : s.ms |
| 267 | if (t < 0) return b |
| 268 | } |
| 269 | return s.tl.end |
| 270 | } |
| 271 | |
| 272 | private segDurationMs(s: Seg): number { |
| 273 | if (s.reduced) return this.opt.reducedHoldMs |
| 274 | let ms = 0 |
| 275 | for (let b = s.tl.start; b < s.tl.end; b++) ms += (s.dwell.get(b) ?? 0) + (b >= s.fastFrom ? s.fastMs : s.ms) |
| 276 | return ms + s.gap |
| 277 | } |
| 278 | |
| 279 | /** ms from the playback's start until the clock reaches block b (before that block's hold) */ |
| 280 | private timeTo(s: Seg, b: number): number { |
| 281 | let ms = 0 |
| 282 | for (let x = s.tl.start; x < Math.min(b, s.tl.end); x++) ms += (s.dwell.get(x) ?? 0) + (x >= s.fastFrom ? s.fastMs : s.ms) |
| 283 | return ms |
| 284 | } |
| 285 | |
| 286 | /** How far the current playback is from the proposal's arrival to its verdict (0..1): the verdict card walks |
| 287 | * through the workflow's steps meanwhile. null when nothing plays, the timeline has no verdict, or motion is |
| 288 | * reduced. */ |
| 289 | judgeProgress(now: number): number | null { |
| 290 | const s = this.seg |
| 291 | if (!s || s.reduced) return null |
| 292 | const verdict = s.tl.steps.find((x) => x.kind === 'veto' || x.kind === 'clear') |
| 293 | if (!verdict) return null |
| 294 | const a = this.timeTo(s, s.tl.proposeBlock) |
| 295 | const b = this.timeTo(s, verdict.block) |
| 296 | if (b <= a) return 1 |
| 297 | const t = this.frozen ?? now - s.t0 |
| 298 | return Math.max(0, Math.min(1, (t - a) / (b - a))) |
| 299 | } |
| 300 | |
| 301 | /** How far the current playback is (0..1, the final hold included); null when nothing plays */ |
| 302 | progress(now: number): { id: string; f: number } | null { |
| 303 | const s = this.seg |
| 304 | if (!s) return null |
| 305 | const t = this.frozen ?? now - s.t0 |
| 306 | return { id: s.tl.id, f: Math.max(0, Math.min(1, t / this.segDurationMs(s))) } |
| 307 | } |
| 308 | |
| 309 | /** The current playback was started on demand (Space or a picker card), not by the loop */ |
| 310 | get onDemand(): boolean { |
| 311 | return !!this.seg?.once |
| 312 | } |
| 313 | |
| 314 | private startNext(now: number, ctx: ReplayCtx): boolean { |
| 315 | const list = playable(ctx.proposals) |
| 316 | if (!list.length) return false |
| 317 | const i = this.lastId === null ? -1 : list.findIndex((p) => Number(p.id) > Number(this.lastId)) |
| 318 | const pick = i >= 0 ? list[i] : list[0] |
| 319 | const tl = buildTimeline(pick) |
| 320 | if (!tl) return false |
| 321 | this.lastId = pick.id |
| 322 | this.seg = this.newSeg(tl, now, ctx.reduced) |
| 323 | this.frozen = null |
| 324 | this.force = false |
| 325 | return true |
| 326 | } |
| 327 | |
| 328 | /** Advance the clock; returns whether the page must redraw */ |
| 329 | tick(now: number, ctx: ReplayCtx): boolean { |
| 330 | const once = !!this.seg?.once |
| 331 | if (ctx.liveBusy && !once) { |
| 332 | if (!this.seg) return false |
| 333 | this.seg = null |
| 334 | this.frozen = null |
| 335 | return true |
| 336 | } |
| 337 | if (this.paused && !once) return false |
| 338 | let changed = false |
| 339 | if (this.seg && this.frozen === null && now - this.seg.t0 >= this.segDurationMs(this.seg)) { |
| 340 | // after a Space replay the loop waits its idle time again before it carries on |
| 341 | if (this.seg.once) this.lastLive = now |
| 342 | this.seg = null |
| 343 | changed = true |
| 344 | } |
| 345 | if (!this.seg) { |
| 346 | if (this.paused) return changed |
| 347 | if (!this.force && !this.idleEnough(now)) return changed |
| 348 | if (!this.startNext(now, ctx)) return changed |
| 349 | changed = true |
| 350 | } |
| 351 | const s = this.seg! |
| 352 | const v = this.vblockAt(now) |
| 353 | const src = ctx.get(s.tl.id) |
| 354 | while (s.logged < s.tl.steps.length && s.tl.steps[s.logged].block <= v) { |
| 355 | if (src) this.onStep(s.tl, s.tl.steps[s.logged], src) |
| 356 | s.logged++ |
| 357 | changed = true |
| 358 | } |
| 359 | return changed |
| 360 | } |
| 361 | |
| 362 | /** What the page shows while playing; null when not playing */ |
| 363 | frame(now: number, get: (id: string) => Proposal | undefined): ReplayFrame | null { |
| 364 | if (!this.seg) return null |
| 365 | const src = get(this.seg.tl.id) |
| 366 | if (!src) return null |
| 367 | const vblock = this.vblockAt(now) |
| 368 | return { tl: this.seg.tl, vblock, p: snapshotAt(src, this.seg.tl, vblock), paused: this.frozen !== null, hold: vblock >= this.seg.tl.end } |
| 369 | } |
| 370 | } |