From 72916455249c9887cebf93d3b51f32de8c892165 Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Wed, 6 May 2026 22:45:38 +0200 Subject: [PATCH] Ch01: serial slo-mo timeline + beat-bound narration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the parallel-beats GossipScript with a strictly serial Ch01 timeline (~75 micro-beats over ~5.5 minutes at 1×). Pedagogical principle: the learner's eye can only follow serial events, so even though Crisis is parallel by design we serialize for teaching. Every micro-event is its own beat with its own narration sentence: think → select payload → select parents → grind PoW → seal → choose recipient → fly (one at a time) → arrive → open → read body → read parents → resolve each parent against the receiver's own local view → verify hash → accept into view. The asymmetry beat (Carl writes γ before β reaches him) is the centerpiece of the chapter. Architecture: - `Ch01Timeline.state(at: t)` is a pure function — replaying every beat up to t produces the world state. This makes the chapter scrub-able and reverse-play-able cleanly later. - The 7 existing scenes become navigation labels — windows of the same continuous timeline. Arrow keys still let you jump between them. Scene durations are now per-scene overrides matching each scene's window in the timeline. - `Ch02_Graph.swift` rewritten end-to-end (1506 → ~470 lines): one timeline-driven render path, no per-scene switch, no dense graph rendering for scenes 4-6 (their content folds into the main timeline as resolveParent / verifyHash / acceptIntoView beats). - `ImmersiveView`'s narration overlay now wraps in its own TimelineView so the displayed text updates at frame rate to match the active beat. Ch01 reads from `Ch01Scenes.narrationAt(scene, localTime)`; other chapters fall back to the static per-scene SceneNarrations. - `GossipScript.swift` deleted. Old helpers in Ch02_Graph (renderStagedBeat, renderHashOneWayVignette, renderLocalDAGDeterminismVignette, renderAncestorConeWalk, drawCastBubble + view-bubble + composing box) are gone. - SceneEngine + SceneVideoCapture duration overrides updated for the 7 new scene windows. Cast members fade onto the stage only when the timeline introduces them — Ben isn't on screen during "Aaron writes α", Carl isn't on screen until "Aaron decides to send α to Carl". No ghosted lanes. Build clean, harness still 55/55 invariants, 0 audit errors, 281 PNGs, 36/36 MP4 written. Co-Authored-By: Claude Opus 4.7 --- .../CrisisViz/Chapters/Ch02_Graph.swift | 1938 +++++------------ .../CrisisViz/Engine/Ch01Timeline.swift | 550 +++++ .../CrisisViz/Engine/GossipScript.swift | 236 -- .../CrisisViz/Engine/SceneEngine.swift | 16 +- .../CrisisViz/Testbed/SceneVideoCapture.swift | 14 +- .../CrisisViz/Views/ImmersiveView.swift | 51 +- 6 files changed, 1130 insertions(+), 1675 deletions(-) create mode 100644 CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift delete mode 100644 CrisisViz/Sources/CrisisViz/Engine/GossipScript.swift diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift index b5cf2eb..e5c555f 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift @@ -1,12 +1,21 @@ import SwiftUI -/// Ch02: "Building the Graph" — 7 scenes showing DAG construction with smooth progressive reveal. -/// Uses the FULL step-9 dataset for layout (so positions never jump) but only reveals a subset -/// of vertices per scene. The subset grows across scenes to create a smooth unfolding. +/// Ch01 — "Aaron speaks. Ben listens. The graph begins." /// -/// In scenes 1+ vertices are tappable: a tap hit-tests against the live layout and selects -/// the closest visible vertex via `InspectionState`. The Inspector overlay is rendered by -/// `ImmersiveView`; this chapter only emits the selection. +/// Every scene in this chapter renders from a single continuous serial +/// timeline (`Ch01Timeline`). The 7 scenes are just navigation labels: +/// each scene corresponds to a window of the timeline. The renderer is a +/// pure function of timeline position `t`. +/// +/// Pedagogical principles (see also `Ch01Timeline.swift`): +/// - Strictly serial: never two events on screen simultaneously +/// - Extreme slow motion: every micro-event (think / select payload / +/// select parents / PoW / seal / decide / fly / arrive / open / +/// read body / read parents / resolve each parent / verify / accept) +/// is its own beat +/// - Cast members appear only when the story brings them on stage +/// - Narration text in `GlassNarration` is bound to the *currently +/// active beat*, not the scene struct Ch02_Graph: View { let sceneIndex: Int let localTime: Double @@ -16,1491 +25,590 @@ struct Ch02_Graph: View { @Environment(AppSettings.self) private var settings var body: some View { - GeometryReader { geo in - Canvas { context, size in - render(context: &context, size: size, time: localTime) - } - .contentShape(Rectangle()) - .gesture( - SpatialTapGesture().onEnded { event in - handleTap(at: event.location, size: geo.size) - } - ) - .overlay(alignment: .topTrailing) { - if sceneIndex >= 1 { - Text("CLICK ANY VERTEX TO INSPECT") - .scaledFont(size: 10, weight: .heavy, design: .monospaced) - .foregroundStyle(.yellow.opacity(0.55)) - .kerning(1.4) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(.black.opacity(0.55)) - ) - .padding(.top, 36) - .padding(.trailing, 16) - .allowsHitTesting(false) - } - } + Canvas { context, size in + let t = Ch01Scenes.timelineT(sceneIndex: sceneIndex, + localTime: localTime) + render(in: &context, size: size, t: t) } } - // MARK: - Tap → vertex hit test + // MARK: - Top-level render - private func handleTap(at point: CGPoint, size: CGSize) { - guard sceneIndex >= 1 else { return } - guard let snap = dm.honestData(step: 5), dm.sim != nil else { return } + private func render(in context: inout GraphicsContext, size: CGSize, t: Double) { + let world = Ch01Timeline.state(at: t) - let layout = DAGLayout.compute( - vertices: snap.vertices, - edges: snap.edges, - nodes: dm.castOrderedNodes(), - canvasSize: size, - margin: 60 - ) + // 1. Lanes only for cast members already on stage. The grid is + // drawn very faintly so it doesn't compete with the focused + // beat — the chapter is about ONE thing happening at a time. + drawStagedLanes(in: &context, size: size, world: world) - let visible = visibleDigests(snap: snap, time: localTime) - var best: (digest: String, dist: CGFloat)? = nil - for digest in visible { - guard let pos = layout.positions[digest] else { continue } - let dx = pos.x - point.x - let dy = pos.y - point.y - let d = (dx * dx + dy * dy).squareRoot() - if d <= 28, best == nil || d < best!.dist { - best = (digest, d) - } + // 2. Cast circles on their lane Y values. If a cast is the + // spotlight of the active beat, pulse them. + for cast in Ch01Cast.allCases where world.introduced.contains(cast) { + let pos = castPosition(cast: cast, size: size) + drawCastVertex(in: &context, at: pos, cast: cast, world: world, t: t) } - if let hit = best { - withAnimation(.easeInOut(duration: 0.3)) { - inspection.select(hit.digest) - } + + // 3. Vertices already accepted into each cast's lifeline. + drawAcceptedVertices(in: &context, size: size, world: world) + + // 4. Parent edges drawn between accepted vertices in each cast's view. + drawAcceptedEdges(in: &context, size: size, world: world) + + // 5. Thought bubble above the focal cast, if one is thinking. + if let thought = world.thought { + drawThoughtBubble(in: &context, size: size, thought: thought) } + + // 6. Composing slot at top-center, with colored connector to author. + if let composing = world.composing { + drawComposingSlot(in: &context, size: size, composing: composing) + } + + // 7. Decide arrow (sender → recipient) when active. + if let decide = world.decideArrow { + drawDecideArrow(in: &context, size: size, decide: decide) + } + + // 8. In-flight envelope. + if let flight = world.inFlight { + drawInFlight(in: &context, size: size, flight: flight) + } + + // 9. Open envelope card next to recipient when they're reading. + if let env = world.openEnvelope { + drawOpenEnvelope(in: &context, size: size, env: env) + } + + // 10. Footer: timeline position + active beat label, faint. + drawFooter(in: &context, size: size, t: t, world: world) } - private func visibleDigests(snap: NodeSnapshot, time: Double) -> [String] { - let sorted = snap.vertices.sorted { - if $0.round != $1.round { return $0.round < $1.round } - return $0.processIdHex < $1.processIdHex - } - let growthRate = 1.5 - let timeBonus = min(8, Int(time * growthRate)) - let visCount = min(sceneVertexCount + timeBonus, sorted.count) - return Array(sorted.prefix(visCount)).map { $0.digestHex } - } + // MARK: - Lane geometry - /// Fixed vertex count per scene — deterministic, no time dependency - private var sceneVertexCount: Int { - switch sceneIndex { - case 0: return 12 // genesis round — just the initial vertices - case 1: return 24 // early gossip - case 2: return 36 // tips visible - case 3: return 50 // inspection - case 4: return 58 // commit-reveal - case 5: return 68 // graph identity - case 6: return 82 // full graph (all vertices) - default: return 30 - } - } - - private func render(context: inout GraphicsContext, size: CGSize, time: Double) { - guard dm.sim != nil, - let snap = dm.honestData(step: 5) else { - context.draw(Text("Loading data...").foregroundColor(.white), - at: CGPoint(x: size.width / 2, y: size.height / 2)) - return - } - - let allVertices = snap.vertices - let allEdges = snap.edges - let nodes = dm.castOrderedNodes() - - // Always compute layout from the FULL dataset so positions never jump - let layout = DAGLayout.compute( - vertices: allVertices, - edges: allEdges, - nodes: nodes, - canvasSize: size, - margin: 60 - ) - - // Sort vertices by round, then by processIdHex for stable ordering - let sortedVertices = allVertices.sorted { - if $0.round != $1.round { return $0.round < $1.round } - return $0.processIdHex < $1.processIdHex - } - - // Visible vertex set: - // - Scenes 0/1/2 are NARRATIVE BEATS. The titles say "Aaron's first - // message", "Ben copies what he saw", "Carl arrives and links in". - // We curate exactly Aaron, then +Ben (with his edge back to - // Aaron's round-0), then +Carl (with edges to both). No - // progressive reveal — the on-canvas vertex count must equal the - // narrated message count, or the story is a lie. - // - Scenes 3+ keep the time-based progressive reveal so the larger - // graph fills in naturally. - let visibleSet: Set - let visCount: Int - if let staged = narrativeStagedSet(snap: snap) { - visibleSet = staged - visCount = staged.count - } else if sceneIndex == 3 { - // Scene 3 must morph in from scene 2's exact 3-vertex set, not - // snap-cut to 50 vertices. The user explicitly asked for slower - // gossip-like reveal: "show every message traveling and arriving - // and hashing and arrow back to where it came from." - // - // Within the existing scene framework we approximate that with: - // - 8-second warm-up (was 4) - // - cubic ease-out so the first new vertices arrive gently and - // accelerate toward the end - // - vertices appear with their full parent-edge fan, drawing - // the gossip arrows (handled in `drawVertices` via animation - // timing) - let prevStaged = stagedFromSceneIndex(2, snap: snap) ?? [] - let target = sceneVertexCount // 50 for scene 3 - let warmup: Double = 8.0 - let raw = min(1.0, max(0, time / warmup)) - let eased = pow(raw, 1.8) // slower start than before - let count = prevStaged.count - + Int(Double(max(0, target - prevStaged.count)) * eased) - visCount = min(count, sortedVertices.count) - var set = prevStaged - for v in sortedVertices where set.count < visCount { - set.insert(v.digestHex) - } - visibleSet = set - } else { - let growthRate = 1.5 // vertices per second - let timeBonus = min(8, Int(time * growthRate)) - visCount = min(sceneVertexCount + timeBonus, sortedVertices.count) - visibleSet = Set(sortedVertices.prefix(visCount).map { $0.digestHex }) - } - - // Filter edges to only those between visible vertices - let visibleEdges = allEdges.filter { visibleSet.contains($0.from) && visibleSet.contains($0.to) } - let visibleVerts = sortedVertices.filter { visibleSet.contains($0.digestHex) } - - // Draw infrastructure - let minRound = allVertices.map { $0.round }.min() ?? 0 - layout.drawNodeLanes(in: &context, nodes: nodes, canvasSize: size, dm: dm, textScale: settings.textScale) - layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: minRound, textScale: settings.textScale) - // No-clock banner only for the dense scenes; the staged scenes 0/1/2 - // already carry the "no global clock" lesson via the narration. - if sceneIndex >= 3 { - layout.drawNoClockBanner(in: &context, canvasSize: size, textScale: settings.textScale) - } - - // Scene-specific rendering. Scenes 0/1/2/3 all share ONE renderer - // (`renderGossipBeats`) so the writing-and-flying slow-motion that - // scene 3 dramatizes is also visible in the staged scenes — and - // there's no hard cut from the staged beat into the dramatization - // (the user's `6/36 → 7/36` complaint). Each scene maps its 0..N - // local seconds onto a different time window of the gossip script - // so each one zooms into a different beat: - // - // Scene 0 (8s) → script 0..6.5 : Aaron composes α, seals, sends - // Scene 1 (8s) → script 4..14 : α flies to Ben, Ben writes β - // Scene 2 (8s) → script 9..21 : Carl receives α, writes γ before β arrives - // Scene 3 (24s) → script 0..24 : full asynchronous timeline - // - // The cast circles always sit ON their lanes (lifeline rule), the - // perspective panel rides the top of the canvas, and the same - // composing/flight overlays drive every scene. - switch sceneIndex { - case 0, 1, 2, 3: - let scriptT: Double = scriptTimeForScene(sceneIndex, sceneTime: time) - renderGossipBeats(in: &context, size: size, - sceneTime: time, scriptT: scriptT) - return - - case 30: // unreachable; kept so original case 3 logic stays as ref - layout.drawEdges(in: &context, edges: visibleEdges, alpha: 0.3, lineWidth: 1.2) - // Hash inspection - layout.drawVertices(in: &context, vertices: visibleVerts, nodes: nodes, dm: dm, - showLabels: true, visibleCount: visCount, textScale: settings.textScale) - - // Pick a vertex near the middle of the visible range for inspection - let midIdx = visibleVerts.count / 2 - if midIdx > 0 { - let inspected = visibleVerts[midIdx] - if let pos = layout.positions[inspected.digestHex] { - // Highlight circle - let r: CGFloat = 20 - let glowRect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) - context.stroke(Circle().path(in: glowRect), - with: .color(.yellow.opacity(0.6 + 0.3 * sin(time * 3))), - lineWidth: 2.5) - - // Position info box: prefer toward center of screen, with margin from edges - let boxW: CGFloat = 300 - let boxH: CGFloat = 84 - let boxX: CGFloat - if pos.x > size.width * 0.6 { - boxX = pos.x - boxW - 35 - } else if pos.x < size.width * 0.4 { - boxX = pos.x + 35 - } else { - boxX = pos.x - boxW / 2 - } - let boxY = max(50, min(pos.y - boxH / 2, size.height - boxH - 50)) - let boxRect = CGRect(x: boxX, y: boxY, width: boxW, height: boxH) - - context.fill(RoundedRectangle(cornerRadius: 10).path(in: boxRect), - with: .color(.black.opacity(0.9))) - context.stroke(RoundedRectangle(cornerRadius: 10).path(in: boxRect), - with: .color(.yellow.opacity(0.5)), lineWidth: 1.5) - - // Connector line - let lineAnchorX = pos.x > size.width * 0.6 ? boxRect.maxX : boxRect.minX - var connector = Path() - connector.move(to: CGPoint(x: lineAnchorX, y: boxRect.midY)) - connector.addLine(to: pos) - context.stroke(connector, with: .color(.yellow.opacity(0.3)), lineWidth: 1) - - let digestStr = String(inspected.digestFull.prefix(28)) - context.draw( - Text("digest: \(digestStr)...") - .font(DAGLayout.fontBody(scale: settings.textScale)) - .foregroundColor(.yellow.opacity(0.9)), - at: CGPoint(x: boxRect.midX, y: boxRect.minY + 20) - ) - context.draw( - Text("round: \(inspected.round) weight: \(inspected.weight) isLast: \(String(describing: inspected.isLast))") - .font(DAGLayout.fontBody(scale: settings.textScale)) - .foregroundColor(.white.opacity(0.7)), - at: CGPoint(x: boxRect.midX, y: boxRect.minY + 42) - ) - context.draw( - Text("payload: \(inspected.payloadStr)") - .font(DAGLayout.fontBody(scale: settings.textScale)) - .foregroundColor(.white.opacity(0.7)), - at: CGPoint(x: boxRect.midX, y: boxRect.minY + 64) - ) - } - } - - case 4: - // "Hashes are one-way" — slo-mo cast vignette. No dense graph; - // we morph from the gossip script's α envelope into a SHA-256 - // demonstration. Lanes still draw underneath as the chapter's - // visual through-line. - renderHashOneWayVignette(in: &context, size: size, time: time) - return - - case 5: - // "Each player keeps a LOCAL DAG; same messages → same graph" - // — slo-mo dual-pane comparison of Aaron's and Ben's local - // views, populated from the same scripted messages used in - // scene 3 so the chapter narrative carries forward. - renderLocalDAGDeterminismVignette(in: &context, size: size, time: time) - return - - case 6: - // Ancestor cone — but walked back ONE LEVEL AT A TIME. The - // earlier implementation revealed the entire cone instantly - // (the user's "totally static" complaint); now depth grows - // with `time` so the cone fans out in front of the viewer. - renderAncestorConeWalk( - in: &context, size: size, time: time, - layout: layout, visibleVerts: visibleVerts, - visibleEdges: visibleEdges - ) - return - - default: - layout.drawEdges(in: &context, edges: visibleEdges, alpha: 0.3, lineWidth: 1.2) - layout.drawVertices(in: &context, vertices: visibleVerts, nodes: nodes, dm: dm, showLabels: true, textScale: settings.textScale) - } - - // Vertex count — top right (the perspective panel now occupies the - // top-center band on staged scenes, so the count is parked in the - // corner where it doesn't fight for space). - context.draw( - Text("\(visCount)/\(allVertices.count) VERTICES · \(visibleEdges.count) EDGES") - .font(DAGLayout.fontCaption(scale: settings.textScale)) - .foregroundColor(.white.opacity(0.25)), - at: CGPoint(x: size.width - 130, y: 16) - ) - } - - // MARK: - Scene 4: hash one-way vignette - - /// Slow-motion demonstration of the "hash is a one-way function" - /// pedagogy. Carries Aaron's α envelope from the gossip script forward - /// (visual continuity with scene 3) and stages four beats: - /// - /// t=0..1.5 α envelope slides into view from the left - /// t=1.5..3.0 payload lines reveal one by one, then SHA arrow appears - /// t=3.0..5.0 reverse arrow attempt — red ✗, "PREIMAGE IMPOSSIBLE" - /// t=5.0..7.0 forward arrow restored — green ✓, "VERIFY DETERMINISTIC" - /// t=7.0..8.0 bridge-line to chapter 8 (data availability) - private func renderHashOneWayVignette( - in context: inout GraphicsContext, size: CGSize, time: Double - ) { - // The same α from GossipScript so the cast continuity is intact. - let alpha = GossipScript.ch01.messages.first { $0.id == "α" }! - let cardW: CGFloat = min(360, size.width * 0.30) - let cardH: CGFloat = 200 - let cy: CGFloat = size.height * 0.52 - let cardX: CGFloat = size.width * 0.18 - let cardRect = CGRect(x: cardX, y: cy - cardH / 2, - width: cardW, height: cardH) - - // Slide-in interpolation (eased) - let slideRaw = max(0, min(1, time / 1.5)) - let slideEased = 1 - pow(1 - slideRaw, 3) - let cardOpacity = slideEased - let actualX = cardX - 80 * (1 - slideEased) - let drawnRect = cardRect.offsetBy(dx: actualX - cardX, dy: 0) - - // Card background - context.fill(RoundedRectangle(cornerRadius: 14).path(in: drawnRect), - with: .color(.black.opacity(0.7 * cardOpacity))) - context.stroke(RoundedRectangle(cornerRadius: 14).path(in: drawnRect), - with: .color(Cast.coral.opacity(0.85 * cardOpacity)), - lineWidth: 1.5) - - // Card title - context.draw( - Text("MESSAGE α — AARON") - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(Cast.coral.opacity(0.95 * cardOpacity)), - at: CGPoint(x: drawnRect.midX, y: drawnRect.minY + 18) - ) - - // Payload lines fade in serially between t=1.5 and 3.0 - let lines = [ - "from: aaron", - "round: 0", - "parents: []", - "payload: \(alpha.payload)", - ] - for (i, line) in lines.enumerated() { - let lineFade = max(0, min(1, (time - 1.5 - Double(i) * 0.25) / 0.4)) - context.draw( - Text(line) - .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.85 * lineFade)), - at: CGPoint(x: drawnRect.minX + 16 + 70, - y: drawnRect.minY + 50 + CGFloat(i) * 22) - ) - } - - // Hash bubble to the right of the card - let hashFade = max(0, min(1, (time - 2.6) / 0.6)) - let hashBubbleW: CGFloat = 200 - let hashBubbleH: CGFloat = 80 - let hashCenter = CGPoint(x: drawnRect.maxX + 220, y: drawnRect.midY) - let hashRect = CGRect(x: hashCenter.x - hashBubbleW / 2, - y: hashCenter.y - hashBubbleH / 2, - width: hashBubbleW, height: hashBubbleH) - context.fill(RoundedRectangle(cornerRadius: 10).path(in: hashRect), - with: .color(.black.opacity(0.7 * hashFade))) - context.stroke(RoundedRectangle(cornerRadius: 10).path(in: hashRect), - with: .color(.white.opacity(0.5 * hashFade)), - lineWidth: 1.2) - context.draw( - Text("hash(α)") - .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) - .foregroundColor(.white.opacity(0.7 * hashFade)), - at: CGPoint(x: hashRect.midX, y: hashRect.minY + 16) - ) - context.draw( - Text("\(alpha.hashShort)…") - .font(.system(size: settings.scaled(18), weight: .heavy, design: .monospaced)) - .foregroundColor(Cast.coral.opacity(0.95 * hashFade)), - at: CGPoint(x: hashRect.midX, y: hashRect.midY + 6) - ) - context.draw( - Text("(SHA-256)") - .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.4 * hashFade)), - at: CGPoint(x: hashRect.midX, y: hashRect.maxY - 12) - ) - - // Forward arrow body → hash - let arrowFade = hashFade - var fwd = Path() - let fwdStart = CGPoint(x: drawnRect.maxX + 8, y: drawnRect.midY) - let fwdEnd = CGPoint(x: hashRect.minX - 8, y: hashRect.midY) - fwd.move(to: fwdStart) - fwd.addLine(to: fwdEnd) - let goodPhase = time > 5.0 - let fwdColor: Color = goodPhase ? .green : .white - context.stroke(fwd, with: .color(fwdColor.opacity(0.8 * arrowFade)), lineWidth: 2.0) - // Arrowhead - let head1 = CGPoint(x: fwdEnd.x - 10, y: fwdEnd.y - 6) - let head2 = CGPoint(x: fwdEnd.x - 10, y: fwdEnd.y + 6) - var headPath = Path() - headPath.move(to: fwdEnd); headPath.addLine(to: head1) - headPath.move(to: fwdEnd); headPath.addLine(to: head2) - context.stroke(headPath, with: .color(fwdColor.opacity(0.8 * arrowFade)), lineWidth: 2.0) - - // Reverse arrow during 3.0..5.0 - if time > 3.0 { - let revFade = max(0, min(1, (time - 3.0) / 0.6)) - // Hide the reverse during forward-verify phase (after 5.0) - let revAlive = max(0, min(1, (5.0 - time) / 0.6)) - let revOpacity = revFade * (time < 5.0 ? 1.0 : revAlive) - var rev = Path() - let revStart = CGPoint(x: hashRect.minX - 8, y: hashRect.midY + 22) - let revEnd = CGPoint(x: drawnRect.maxX + 8, y: drawnRect.midY + 22) - rev.move(to: revStart) - rev.addLine(to: revEnd) - let dashed = StrokeStyle(lineWidth: 2.0, dash: [6, 4]) - context.stroke(rev, with: .color(.red.opacity(0.85 * revOpacity)), - style: dashed) - // Big red ✗ at midpoint - let mid = CGPoint(x: (revStart.x + revEnd.x) / 2, - y: (revStart.y + revEnd.y) / 2) - context.draw( - Text("✗") - .font(.system(size: settings.scaled(28), weight: .heavy, design: .monospaced)) - .foregroundColor(.red.opacity(revOpacity)), - at: mid - ) - context.draw( - Text("PREIMAGE IMPOSSIBLE — HASH ALONE TELLS YOU NOTHING") - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(.red.opacity(0.9 * revOpacity)), - at: CGPoint(x: size.width / 2, y: drawnRect.maxY + 36) - ) - } - - // Forward verify ✓ at 5.0..7.0 - if time > 5.0 { - let verFade = max(0, min(1, (time - 5.0) / 0.6)) - context.draw( - Text("✓") - .font(.system(size: settings.scaled(20), weight: .heavy, design: .monospaced)) - .foregroundColor(.green.opacity(0.95 * verFade)), - at: CGPoint(x: (fwdStart.x + fwdEnd.x) / 2, y: drawnRect.midY - 22) - ) - context.draw( - Text("VERIFY: BODY → HASH IS DETERMINISTIC. RECEIVERS RECOMPUTE THE HASH AND CHECK IT MATCHES.") - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(.green.opacity(0.9 * verFade)), - at: CGPoint(x: size.width / 2, y: drawnRect.maxY + 36) - ) - } - - // Bridge-line to Ch08 at the bottom - if time > 7.0 { - let endFade = max(0, min(1, (time - 7.0) / 0.6)) - context.draw( - Text("→ DATA AVAILABILITY (CHAPTER 8) IS WHY THIS MATTERS: IF YOU LOSE THE BODY YOU CAN'T VERIFY ANYTHING.") - .font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced)) - .foregroundColor(.yellow.opacity(0.85 * endFade)), - at: CGPoint(x: size.width / 2, y: size.height - 56) - ) - } - } - - // MARK: - Scene 5: local DAG / determinism vignette - - /// Two parallel "local DAG" panes — one for Aaron, one for Ben — that - /// fill in identically as gossip catches up. The lesson is "same set - /// of messages received → same graph computed". Uses the SAME α/β/γ - /// scripted messages from scene 3 so the chapter's gossip story - /// continues uninterrupted. - private func renderLocalDAGDeterminismVignette( - in context: inout GraphicsContext, size: CGSize, time: Double - ) { - let paneWidth: CGFloat = (size.width - 100) / 2 - let paneHeight: CGFloat = size.height * 0.55 - let paneY: CGFloat = size.height * 0.20 - let leftPaneX: CGFloat = 30 - let rightPaneX: CGFloat = size.width - 30 - paneWidth - - // Slide-in fade - let slideRaw = max(0, min(1, time / 1.0)) - let slideOpacity = pow(slideRaw, 1.4) - - // Each pane's "received timeline" — when each message arrives. - // Aaron's: α at t=2 (his own), β at t=4 (Ben gossips), γ at t=6 (Carl gossips). - // Ben's: α at t=2.5 (received), β at t=4 (his own), γ at t=6 (Carl gossips). - // Identical FINAL state at t=6+; identical visualization confirms determinism. - struct ReceiveEvent { let id: String; let t: Double; let color: Color } - let aaronTimeline: [ReceiveEvent] = [ - ReceiveEvent(id: "α", t: 2.0, color: Cast.coral), - ReceiveEvent(id: "β", t: 4.0, color: Cast.teal), - ReceiveEvent(id: "γ", t: 6.0, color: Cast.amber), - ] - let benTimeline: [ReceiveEvent] = [ - ReceiveEvent(id: "α", t: 2.5, color: Cast.coral), - ReceiveEvent(id: "β", t: 4.0, color: Cast.teal), - ReceiveEvent(id: "γ", t: 6.0, color: Cast.amber), - ] - - func drawPane(label: String, accent: Color, x: CGFloat, timeline: [ReceiveEvent]) { - let rect = CGRect(x: x, y: paneY, width: paneWidth, height: paneHeight) - context.fill(RoundedRectangle(cornerRadius: 14).path(in: rect), - with: .color(.black.opacity(0.45 * slideOpacity))) - context.stroke(RoundedRectangle(cornerRadius: 14).path(in: rect), - with: .color(accent.opacity(0.7 * slideOpacity)), - lineWidth: 1.5) - context.draw( - Text(label) - .font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced)) - .foregroundColor(accent.opacity(0.95 * slideOpacity)), - at: CGPoint(x: rect.midX, y: rect.minY + 22) - ) - // Three vertex slots laid out left → right inside the pane. - for (i, evt) in timeline.enumerated() { - let slotX = rect.minX + 60 + CGFloat(i) * (rect.width - 120) / 2 - let slotY = rect.midY + 6 - let arrived = max(0, min(1, (time - evt.t) / 0.6)) - let r: CGFloat = 28 - let circleRect = CGRect(x: slotX - r, y: slotY - r, width: r * 2, height: r * 2) - // Empty placeholder ring before arrival - context.stroke(Circle().path(in: circleRect), - with: .color(.white.opacity(0.18 * slideOpacity)), - lineWidth: 1.0) - if arrived > 0 { - context.fill(Circle().path(in: circleRect), - with: .color(evt.color.opacity(0.85 * arrived))) - context.stroke(Circle().path(in: circleRect), - with: .color(.white.opacity(0.5 * arrived)), - lineWidth: 1.4) - context.draw( - Text(evt.id) - .font(.system(size: settings.scaled(20), weight: .heavy, design: .monospaced)) - .foregroundColor(.white.opacity(arrived)), - at: CGPoint(x: slotX, y: slotY) - ) - } - // Parent edge α → β, α → γ, drawn in once both endpoints have arrived. - if i > 0 { - let bothArrived = arrived > 0.6 && (time - timeline[0].t) / 0.6 > 0.6 - if bothArrived { - let prevX = rect.minX + 60 - let edgeFade = max(0, min(1, (time - evt.t - 0.4) / 0.6)) - var path = Path() - path.move(to: CGPoint(x: slotX - r - 1, y: slotY)) - path.addLine(to: CGPoint(x: prevX + r + 1, y: slotY)) - context.stroke(path, - with: .color(.white.opacity(0.45 * edgeFade)), - lineWidth: 1.4) - } - } - } - } - drawPane(label: "AARON'S LOCAL DAG", accent: Cast.coral, - x: leftPaneX, timeline: aaronTimeline) - drawPane(label: "BEN'S LOCAL DAG", accent: Cast.teal, - x: rightPaneX, timeline: benTimeline) - - // Convergence flash + caption once both panes are fully populated. - let aaronComplete = aaronTimeline.allSatisfy { time >= $0.t + 0.6 } - let benComplete = benTimeline.allSatisfy { time >= $0.t + 0.6 } - if aaronComplete && benComplete { - let convergeFade = max(0, min(1, (time - max(aaronTimeline.last!.t, - benTimeline.last!.t) - 0.4) / 0.8)) - let pulse = 0.7 + 0.3 * sin(time * 2) - context.draw( - Text("SAME MESSAGES → SAME GRAPH — BYTE-FOR-BYTE") - .font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced)) - .foregroundColor(.green.opacity(0.95 * convergeFade * pulse)), - at: CGPoint(x: size.width / 2, y: paneY + paneHeight + 30) - ) - // Equality bar between the panes. - let barY = size.height / 2 - var bar = Path() - bar.move(to: CGPoint(x: leftPaneX + paneWidth + 4, y: barY - 6)) - bar.addLine(to: CGPoint(x: rightPaneX - 4, y: barY - 6)) - bar.move(to: CGPoint(x: leftPaneX + paneWidth + 4, y: barY + 6)) - bar.addLine(to: CGPoint(x: rightPaneX - 4, y: barY + 6)) - context.stroke(bar, - with: .color(.green.opacity(0.85 * convergeFade)), - lineWidth: 2.0) - } else { - context.draw( - Text("EACH PLAYER BUILDS THEIR OWN LOCAL DAG. GOSSIP DELIVERS THE SAME MESSAGES TO BOTH.") - .font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced)) - .foregroundColor(.white.opacity(0.65 * slideOpacity)), - at: CGPoint(x: size.width / 2, y: paneY + paneHeight + 30) - ) - } - } - - // MARK: - Scene 6: ancestor cone walk-back - - /// Slow walk back from a chosen leaf vertex, expanding the ancestor - /// cone ONE LEVEL per second instead of revealing it all at once. Cast - /// vertices in the cone get cast-colored highlights so Aaron/Ben/Carl - /// can be tracked across hops; pure-peer ancestors stay in yellow. - private func renderAncestorConeWalk( - in context: inout GraphicsContext, size: CGSize, time: Double, - layout: DAGLayout, visibleVerts: [VertexData], visibleEdges: [EdgeData] - ) { - // Background graph at very low alpha — gives the cone something - // to stand against without competing for attention. - layout.drawEdges(in: &context, edges: visibleEdges, alpha: 0.15, lineWidth: 0.9) - layout.drawVertices( - in: &context, vertices: visibleVerts, - nodes: dm.castOrderedNodes(), dm: dm, - showLabels: false, textScale: settings.textScale - ) - - // Pick the leaf — prefer Aaron's latest visible vertex so the - // walk-back reads as "Aaron's history". - let aaronPid = pid(of: Cast.aaron) - let aaronLeaf = visibleVerts.filter { $0.processIdHex == aaronPid } - .max { $0.round < $1.round } - let leaf = aaronLeaf ?? visibleVerts.max { $0.round < $1.round } - guard let leaf else { return } - guard let leafPos = layout.positions[leaf.digestHex] else { return } - - // BFS layers from the leaf outward — done UP-FRONT so we can pace - // the reveal to fit the 8-second scene regardless of cone depth. - var layersByDepth: [[String]] = [[leaf.digestHex]] - var seen: Set = [leaf.digestHex] - var frontier: [String] = [leaf.digestHex] - let maxDepth = 8 - for _ in 0..= totalDepthSteps - if revealedAll { - let genesisFade = max(0, min(1, (time - warmup - Double(totalDepthSteps) * levelTime - 0.2) / 0.6)) - // A "genesis" vertex is one in the cone whose outgoing parent edges - // (within visibleEdges) all leave the cone — it doesn't point - // back further within the cone we have on screen. - let coneSet = seen - let genesisHexes = coneSet.filter { d in - !visibleEdges.contains { $0.from == d && coneSet.contains($0.to) } - } - for hex in genesisHexes { - if let pos = layout.positions[hex] { - context.draw( - Text("★ GENESIS") - .font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced)) - .foregroundColor(.yellow.opacity(0.95 * genesisFade)), - at: CGPoint(x: pos.x, y: pos.y + 24) - ) - } - } - } - - // Caption tracks the current depth so the viewer can pace. - let depthLabel: String - if depthFloor == 0 { - depthLabel = "DEPTH 0 — JUST THE LEAF" - } else if revealedAll { - depthLabel = "REACHED GENESIS — \(seen.count) ANCESTORS · \(layersByDepth.count - 1) HOPS" - } else { - depthLabel = "WALKING BACK · DEPTH \(depthFloor) → \(depthFloor + 1)" - } - context.draw( - Text(depthLabel) - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(.yellow.opacity(0.85)), - at: CGPoint(x: size.width / 2, y: size.height - 56) - ) - } - - private func castColorForVertex(hex: String) -> Color? { - guard let snap = dm.honestData(step: 5) else { return nil } - guard let v = snap.vertices.first(where: { $0.digestHex == hex }) else { return nil } - guard let role = dm.castByPid[v.processIdHex] else { return nil } - switch role.id { - case Cast.aaron.id: return Cast.coral - case Cast.ben.id: return Cast.teal - case Cast.carl.id: return Cast.amber - case Cast.dave.id: return Cast.violet - default: return nil - } - } - - // MARK: - Helpers - - private func buildParentMap(edges: [EdgeData]) -> [String: [String]] { - var map: [String: [String]] = [:] - for e in edges { - map[e.from, default: []].append(e.to) - } - return map - } - - /// Find a vertex with 2+ parents that's well-positioned (not at screen edges) - private func findVertexWithParents( - vertices: [VertexData], - parentMap: [String: [String]], - layout: DAGLayout, - canvasSize: CGSize - ) -> (VertexData, [String])? { - let margin: CGFloat = 150 - for v in vertices.reversed() { - if let parents = parentMap[v.digestHex], parents.count >= 2 { - // Check that it's not clipped at screen edges - if let pos = layout.positions[v.digestHex] { - if pos.x > margin && pos.x < canvasSize.width - margin && - pos.y > margin && pos.y < canvasSize.height - margin { - return (v, parents) - } - } - } - } - // Fallback: any vertex with parents - for v in vertices.reversed() { - if let parents = parentMap[v.digestHex], parents.count >= 2 { - return (v, parents) - } - } - return nil - } - - /// Compute the staged visible set for an arbitrary sceneIndex (used by - /// scene 3 to start morphing from scene 2's staged set). Mirrors the - /// strict-then-relaxed staging in `narrativeStagedSet`. - private func stagedFromSceneIndex(_ idx: Int, snap: NodeSnapshot) -> Set? { - guard idx <= 2, - let aaronPid = pid(of: Cast.aaron), - let benPid = pid(of: Cast.ben), - let carlPid = pid(of: Cast.carl) else { return nil } - - guard let aaronR0 = snap.vertices - .filter({ $0.processIdHex == aaronPid }) - .min(by: { $0.round < $1.round - || ($0.round == $1.round && $0.digestHex < $1.digestHex) }) - else { return nil } - let aaronR0Hex = aaronR0.digestHex - let allAaronHex = Set(snap.vertices.filter { $0.processIdHex == aaronPid }.map(\.digestHex)) - - var visible: Set = [aaronR0Hex] - if idx == 0 { return visible } - - let benCandidates = snap.vertices.filter { $0.processIdHex == benPid } - .sorted { $0.round < $1.round || ($0.round == $1.round && $0.digestHex < $1.digestHex) } - let benVertex = benCandidates.first(where: { bv in - snap.edges.contains { $0.from == bv.digestHex && $0.to == aaronR0Hex } - }) - ?? benCandidates.first(where: { bv in - snap.edges.contains { $0.from == bv.digestHex && allAaronHex.contains($0.to) } - }) - ?? benCandidates.first - if let bv = benVertex { visible.insert(bv.digestHex) } - if idx == 1 { return visible } - - let carlCandidates = snap.vertices.filter { $0.processIdHex == carlPid } - .sorted { $0.round < $1.round || ($0.round == $1.round && $0.digestHex < $1.digestHex) } - let carlVertex = carlCandidates.first(where: { cv in - snap.edges.contains { $0.from == cv.digestHex && $0.to == aaronR0Hex } - }) - ?? carlCandidates.first(where: { cv in - snap.edges.contains { $0.from == cv.digestHex && visible.contains($0.to) } - }) - ?? carlCandidates.first - if let cv = carlVertex { visible.insert(cv.digestHex) } - return visible - } - - // MARK: - Slow-motion gossip dramatization (scene 3) - - /// Map the local time in scene 0..3 onto a window of the GossipScript - /// timeline. Each scene zooms into the slice of the script that - /// matches its narration; scene 3 runs the full 24-second script. - private func scriptTimeForScene(_ sceneIdx: Int, sceneTime t: Double) -> Double { - switch sceneIdx { - case 0: - // 0..8 → 0..6.5 (Aaron composes α, seals, starts sending) - return (t / 8.0) * 6.5 - case 1: - // 0..8 → 4..14 (α flies to Ben/Carl, Ben composes β, sends) - return 4.0 + (t / 8.0) * 10.0 - case 2: - // 0..8 → 9..22 (α arrives at Carl, Carl composes γ before β, - // β arrives late at Carl, γ flies) - return 9.0 + (t / 8.0) * 13.0 - case 3: - // 0..24 → 0..24 (full timeline replay) - return t - default: - return 0 - } - } - - /// Lane center Y — must mirror `DAGLayoutEngine.compute`'s lane math so - /// cast circles in the gossip dramatization sit on the same horizontal - /// axis as the cast vertices in the rest of the chapter. + /// Lane center Y for cast index 0..3 (Aaron/Ben/Carl/Dave) using the + /// same math `DAGLayoutEngine` uses for the rest of the curriculum. private func castLaneY(_ laneIdx: Int, size: CGSize) -> CGFloat { let margin: CGFloat = 60 - let nodeCount = max(7, dm.castOrderedNodes().count) - let laneHeight = (size.height - 2 * margin) / CGFloat(nodeCount) + let nodeCount: CGFloat = 7 // Aaron/Ben/Carl/Dave + 3 peers + let laneHeight = (size.height - 2 * margin) / nodeCount return margin + (CGFloat(laneIdx) + 0.5) * laneHeight } - /// Unified gossip-beat renderer. Scenes 0/1/2/3 all funnel through - /// this — each with its own `scriptT` window. Cast circles stay on - /// their lanes (lifeline rule), composing happens in a fixed - /// top-center slot just below the perspective panel, flight envelopes - /// glide between cast positions, and the perspective panel is fed - /// from `GossipScript.state` so the panel ✓/✗ marks update in - /// lockstep with the writing/flight beats. - private func renderGossipBeats( + /// Cast position (X staircased so flight diagonals are clean). + private func castPosition(cast: Ch01Cast, size: CGSize) -> CGPoint { + let laneIdx: Int + let xFrac: CGFloat + switch cast { + case .aaron: (laneIdx, xFrac) = (0, 0.20) + case .ben: (laneIdx, xFrac) = (1, 0.50) + case .carl: (laneIdx, xFrac) = (2, 0.80) + case .dave: (laneIdx, xFrac) = (3, 0.50) + } + return CGPoint(x: size.width * xFrac, y: castLaneY(laneIdx, size: size)) + } + + private func castColor(_ cast: Ch01Cast) -> Color { + switch cast { + case .aaron: return Cast.coral + case .ben: return Cast.teal + case .carl: return Cast.amber + case .dave: return Cast.violet + } + } + + // MARK: - Lanes + + private func drawStagedLanes( in context: inout GraphicsContext, size: CGSize, - sceneTime: Double, scriptT: Double + world: Ch01WorldState ) { - let script = GossipScript.ch01 - let world = script.state(at: scriptT) - - // Cast positions sit ON THEIR LANES (Aaron lane 0, Ben lane 1, - // Carl lane 2) at staircased X so flight paths read as clean - // diagonals. - let aaronY = castLaneY(0, size: size) - let benY = castLaneY(1, size: size) - let carlY = castLaneY(2, size: size) - let layout: [GossipScript.CastRoleKey: CGPoint] = [ - .aaron: CGPoint(x: size.width * 0.20, y: aaronY), - .ben: CGPoint(x: size.width * 0.50, y: benY), - .carl: CGPoint(x: size.width * 0.80, y: carlY), - ] - - // Tiny script-time stamp in the corner so the user can pace - // without the title fighting the panel for the top of the canvas. - context.draw( - Text(String(format: "scriptT=%.1fs", scriptT)) - .font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.28)), - at: CGPoint(x: size.width - 70, y: size.height - 12) - ) - - // 1. Cast circles + view bubbles. Pass an explicit `bubbleSide` - // so Aaron/Ben hang their bubbles to the right and Carl hangs - // his to the left, regardless of NSScreen geometry. - let bubbleSides: [GossipScript.CastRoleKey: BubbleSide] = [ - .aaron: .right, .ben: .right, .carl: .left - ] - for (key, pos) in layout { - drawCastBubble( - in: &context, at: pos, key: key, - view: world.views[key] ?? GossipScript.ViewState(), - script: script, - spotlight: world.spotlight?.0 == key, - bubbleSide: bubbleSides[key] ?? .right, - time: scriptT - ) - } - - // 2. Composing-in-progress message rendered in a fixed top-center - // slot just under the perspective panel — author's color - // highlights the box, with a colored connector linking the - // box to the author so the viewer knows who is at the keyboard. - if let composing = world.composing.first, - let authorPos = layout[composing.author] { - drawComposingSlot( - in: &context, canvasSize: size, - composing: composing, authorPos: authorPos - ) - } - - // 3. In-flight envelopes glide along the diagonal between cast - // lanes. Drawn after circles so they read as "above" them. - for f in world.inFlight { - guard let src = layout[f.from], let dst = layout[f.to] else { continue } - let p = CGFloat(f.progress) - let pos = CGPoint(x: src.x + (dst.x - src.x) * p, - y: src.y + (dst.y - src.y) * p) - drawFlightEnvelope(in: &context, at: pos, message: f.message, - from: src, to: dst, progress: f.progress) - } - - // 4. Caption tied to scriptT (not sceneTime) so the same beat - // surfaces the same caption regardless of which scene is - // hosting that slice of the script. - let cx = size.width / 2 - let captionY = size.height - 30 - let captionText: String? - let captionColor: Color - if scriptT > 19 { - captionText = "CARL'S γ DOES NOT REFERENCE β — HE WROTE γ BEFORE β ARRIVED. ASYMMETRY IS THE NORM." - captionColor = .yellow.opacity(0.85) - } else if scriptT > 13 { - captionText = "BEN HAS α. HE WRITES β REFERENCING α. CARL ALSO RECEIVED α — INDEPENDENTLY." - captionColor = .white.opacity(0.7) - } else if scriptT > 4 { - captionText = "AARON'S α TRAVELS — TWO COPIES, ONE TO BEN, ONE TO CARL, AT DIFFERENT SPEEDS." - captionColor = .white.opacity(0.7) - } else if scriptT > 0.5 { - captionText = "AARON IS WRITING THE FIRST MESSAGE. NO ONE ELSE KNOWS IT YET." - captionColor = Cast.coral.opacity(0.9) - } else { - captionText = nil - captionColor = .clear - } - if let captionText { + let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)] + for (cast, idx) in casts where world.introduced.contains(cast) { + let y = castLaneY(idx, size: size) + // Thin axis line across the canvas for the cast's lifeline. + var path = Path() + path.move(to: CGPoint(x: 36, y: y)) + path.addLine(to: CGPoint(x: size.width - 24, y: y)) + context.stroke(path, with: .color(castColor(cast).opacity(0.18)), + style: StrokeStyle(lineWidth: 0.8, dash: [4, 6])) + // Lane name at the left. context.draw( - Text(captionText) + Text(cast.role.displayName.capitalized) .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(captionColor), - at: CGPoint(x: cx, y: captionY) + .foregroundColor(castColor(cast).opacity(0.75)), + at: CGPoint(x: 24, y: y), + anchor: .leading ) } + } - // 5. Perspective panel at the top — fed from the live gossip - // state so ✓ / ✗ flips track the writing/flight beats below. - let items: [PanelItem] = [ - PanelItem(label: "α", id: "α", color: Cast.coral), - PanelItem(label: "β", id: "β", color: Cast.teal), - PanelItem(label: "γ", id: "γ", color: Cast.amber), - ] - func gossipKnows(_ key: GossipScript.CastRoleKey) -> Set { - let view = world.views[key] ?? GossipScript.ViewState() - return Set(view.received.compactMap { $0.value >= 1.0 ? $0.key : nil }) - } - drawPerspectivePanel( - in: &context, size: size, time: sceneTime, - items: items, - aaronKnows: gossipKnows(.aaron), - benKnows: gossipKnows(.ben), - carlKnows: gossipKnows(.carl) + // MARK: - Cast vertex (the cast member's "self") + + private func drawCastVertex( + in context: inout GraphicsContext, at pos: CGPoint, + cast: Ch01Cast, world: Ch01WorldState, t: Double + ) { + let isActive: Bool = { + switch world.activeBeat?.kind { + case .introduce(let c), .think(let c, _): + return c == cast + case .selectPayload(let mid), .selectParents(let mid), + .computePoW(let mid), .seal(let mid): + return Ch01Timeline.messages[mid]?.author == cast + case .decideSend(let from, _, _): + return from == cast + case .arrive(let at, _), .open(let at, _), + .readBody(let at, _), .readParents(let at, _), + .resolveParent(let at, _, _), + .verifyHash(let at, _), .acceptIntoView(let at, _): + return at == cast + default: + return false + } + }() + let pulse: CGFloat = isActive ? 1.0 + 0.06 * CGFloat(sin(t * 4)) : 1.0 + let r: CGFloat = 28 * pulse + let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) + let color = castColor(cast) + + // Halo + let haloR = r * 1.6 + context.fill( + Circle().path(in: CGRect(x: pos.x - haloR, y: pos.y - haloR, + width: haloR * 2, height: haloR * 2)), + with: .color(color.opacity(isActive ? 0.22 : 0.10)) + ) + context.fill(Circle().path(in: rect), with: .color(color.opacity(0.95))) + context.stroke(Circle().path(in: rect), + with: .color(.white.opacity(0.5)), lineWidth: 1.5) + context.draw( + Text(String(cast.role.displayName.prefix(1))) + .font(.system(size: settings.scaled(20), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: pos + ) + // Name caption below + context.draw( + Text(cast.role.displayName.uppercased()) + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(color.opacity(0.95)), + at: CGPoint(x: pos.x, y: pos.y + r + 12) ) } - enum BubbleSide { case left, right } + // MARK: - Accepted vertices on cast lanes - /// Composing slot — fixed top-center box just below the perspective - /// panel that surfaces whichever cast member is currently writing. A - /// thin colored connector ties the box to the author's cast circle so - /// the viewer can see who is at the keyboard. - private func drawComposingSlot( - in context: inout GraphicsContext, canvasSize: CGSize, - composing: GossipScript.ComposingMessage, authorPos: CGPoint + /// Each cast's accepted messages get small vertices on their lifeline, + /// to the right of their cast circle, in chronological order. These + /// represent "things this player has, in their own local DAG." + private func drawAcceptedVertices( + in context: inout GraphicsContext, size: CGSize, world: Ch01WorldState ) { - let boxW: CGFloat = 260 - let boxH: CGFloat = 72 - // Position just under the perspective panel band (panel y=14..110 - // + caption at y=120). 132 leaves a 12pt gap. - let boxRect = CGRect( - x: canvasSize.width / 2 - boxW / 2, - y: 132, - width: boxW, height: boxH + let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2)] + let messageOrder: [String] = ["α", "β", "γ"] + + for (cast, laneIdx) in casts where world.introduced.contains(cast) { + let view = world.views[cast] ?? [] + let lane = castLaneY(laneIdx, size: size) + let castX = castPosition(cast: cast, size: size).x + // Lay vertices out to the RIGHT of the cast circle. Spacing + // adapts to the available canvas width. + let firstX = castX + 80 + let gap: CGFloat = 64 + for (i, mid) in messageOrder.enumerated() where view.contains(mid) { + let x = firstX + CGFloat(i) * gap + if x > size.width - 60 { break } + drawAcceptedVertex(in: &context, at: CGPoint(x: x, y: lane), + messageId: mid) + } + } + } + + private func drawAcceptedVertex( + in context: inout GraphicsContext, at pos: CGPoint, messageId: String + ) { + guard let msg = Ch01Timeline.messages[messageId] else { return } + let r: CGFloat = 16 + let color = castColor(msg.author) + let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) + context.fill(Circle().path(in: rect), + with: .color(color.opacity(0.85))) + context.stroke(Circle().path(in: rect), + with: .color(.white.opacity(0.55)), lineWidth: 1.2) + context.draw( + Text(messageId) + .font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: pos ) - let color = composing.author.role.color + // Hash digest below + context.draw( + Text(msg.hashShort) + .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.55)), + at: CGPoint(x: pos.x, y: pos.y + r + 8) + ) + } - // Connector line from box bottom to the author's cast circle top - // edge — gives the viewer an unambiguous link. - var connector = Path() - connector.move(to: CGPoint(x: boxRect.midX, y: boxRect.maxY)) - connector.addLine(to: CGPoint(x: authorPos.x, y: authorPos.y - 40)) - context.stroke(connector, - with: .color(color.opacity(0.45)), - style: StrokeStyle(lineWidth: 1.4, dash: [3, 3])) + /// Parent edges between accepted vertices in each cast's lifeline: + /// β → α, γ → α. Each edge sits in the receiver's lane. + private func drawAcceptedEdges( + in context: inout GraphicsContext, size: CGSize, world: Ch01WorldState + ) { + let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2)] + let messageOrder: [String] = ["α", "β", "γ"] - context.fill(RoundedRectangle(cornerRadius: 8).path(in: boxRect), + for (cast, laneIdx) in casts where world.introduced.contains(cast) { + let view = world.views[cast] ?? [] + let lane = castLaneY(laneIdx, size: size) + let castX = castPosition(cast: cast, size: size).x + let firstX = castX + 80 + let gap: CGFloat = 64 + // Indexed positions for each message slot. + var positions: [String: CGPoint] = [:] + for (i, mid) in messageOrder.enumerated() where view.contains(mid) { + positions[mid] = CGPoint(x: firstX + CGFloat(i) * gap, y: lane) + } + for (mid, childPos) in positions { + guard let msg = Ch01Timeline.messages[mid] else { continue } + for parentId in msg.parents { + guard let parentPos = positions[parentId] else { continue } + var path = Path() + let from = CGPoint(x: childPos.x - 16, y: childPos.y) + let to = CGPoint(x: parentPos.x + 16, y: parentPos.y) + path.move(to: from) + path.addLine(to: to) + context.stroke(path, + with: .color(castColor(msg.author).opacity(0.70)), + lineWidth: 1.5) + // Arrowhead at parent + var head = Path() + head.move(to: to) + head.addLine(to: CGPoint(x: to.x + 6, y: to.y - 4)) + head.move(to: to) + head.addLine(to: CGPoint(x: to.x + 6, y: to.y + 4)) + context.stroke(head, + with: .color(castColor(msg.author).opacity(0.70)), + lineWidth: 1.5) + } + } + } + } + + // MARK: - Thought bubble + + private func drawThoughtBubble( + in context: inout GraphicsContext, size: CGSize, + thought: Ch01WorldState.ThoughtState + ) { + let pos = castPosition(cast: thought.cast, size: size) + let bubbleW: CGFloat = max(140, CGFloat(thought.label.count) * 7.0 + 24) + let bubbleH: CGFloat = 36 + let bubbleRect = CGRect( + x: pos.x - bubbleW / 2, + y: pos.y - 80 - bubbleH, + width: bubbleW, height: bubbleH + ) + let color = castColor(thought.cast) + context.fill(RoundedRectangle(cornerRadius: 18).path(in: bubbleRect), + with: .color(.black.opacity(0.78))) + context.stroke(RoundedRectangle(cornerRadius: 18).path(in: bubbleRect), + with: .color(color.opacity(0.85)), lineWidth: 1.4) + context.draw( + Text(thought.label) + .font(.system(size: settings.scaled(11), weight: .medium, design: .default)) + .foregroundColor(.white.opacity(0.92)) + .italic(), + at: CGPoint(x: bubbleRect.midX, y: bubbleRect.midY) + ) + // Two little circles like a comic "thought" tail + for (offset, scale) in [(50.0, 6.0), (28.0, 4.0)] { + let cx = pos.x + let cy = pos.y - offset + let s = CGFloat(scale) + context.fill(Circle().path(in: CGRect(x: cx - s, y: cy - s, + width: s * 2, height: s * 2)), + with: .color(.black.opacity(0.78))) + context.stroke(Circle().path(in: CGRect(x: cx - s, y: cy - s, + width: s * 2, height: s * 2)), + with: .color(color.opacity(0.85)), lineWidth: 1.0) + } + } + + // MARK: - Composing slot + + private func drawComposingSlot( + in context: inout GraphicsContext, size: CGSize, + composing: Ch01WorldState.ComposingState + ) { + guard let msg = Ch01Timeline.messages[composing.messageId] else { return } + let boxW: CGFloat = 320 + let boxH: CGFloat = 110 + let authorPos = castPosition(cast: composing.author, size: size) + // Position the slot to the side of the author's lane, on the + // half of the canvas that has more room. Always vertically + // centered on the author's lane Y so the box and cast circle + // read as part of the same gesture. + let placeRight = authorPos.x < size.width / 2 + let boxX: CGFloat = placeRight + ? authorPos.x + 56 + : authorPos.x - 56 - boxW + let boxY: CGFloat = authorPos.y - boxH / 2 + let boxRect = CGRect(x: boxX, y: boxY, width: boxW, height: boxH) + + let color = castColor(composing.author) + context.fill(RoundedRectangle(cornerRadius: 10).path(in: boxRect), with: .color(.black.opacity(0.85))) - context.stroke(RoundedRectangle(cornerRadius: 8).path(in: boxRect), + context.stroke(RoundedRectangle(cornerRadius: 10).path(in: boxRect), with: .color(color.opacity(0.95)), lineWidth: 1.5) context.draw( Text("✎ \(composing.author.role.displayName.uppercased()) WRITING") .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) .foregroundColor(color), - at: CGPoint(x: boxRect.midX, y: boxRect.minY + 12) + at: CGPoint(x: boxRect.minX + 12, y: boxRect.minY + 12), + anchor: .leading ) - // Lines fill in progressively. Thresholds make each line feel - // deliberate — payload first, parents next, then the PoW-derived - // hash once it's been "computed". - let lines: [(String, threshold: Double)] = [ - ("payload: \(composing.message.payload)", 0.20), - ("parents: \(composing.message.parents.isEmpty ? "(genesis)" : composing.message.parents.joined(separator: ", "))", 0.50), - ("hash: \(composing.progress > 0.85 ? composing.message.hashShort + "…" : "computing PoW…")", 0.85), - ] var rowY = boxRect.minY + 30 - for (text, threshold) in lines { - if composing.progress < threshold { continue } - let alpha = min(1.0, (composing.progress - threshold) / 0.10) + // payload line + if composing.payloadFilled { context.draw( - Text(text) + Text("payload: \(msg.payload)") .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.85 * alpha)), + .foregroundColor(.white.opacity(0.85)), at: CGPoint(x: boxRect.minX + 12, y: rowY), anchor: .leading ) - rowY += 14 + rowY += 16 } - } - - /// Cast member node — circle in their cast color with name underneath, plus - /// a "view bubble" showing absorbed message hashes. `bubbleSide` is - /// explicit so the dramatization can place each cast's view bubble on - /// a guaranteed-clear side regardless of NSScreen geometry. - private func drawCastBubble( - in context: inout GraphicsContext, at pos: CGPoint, - key: GossipScript.CastRoleKey, view: GossipScript.ViewState, - script: GossipScript, spotlight: Bool, - bubbleSide: BubbleSide = .right, - time: Double - ) { - let role = key.role - let radius: CGFloat = 38 - let pulse: CGFloat = spotlight ? 1.0 + 0.06 * CGFloat(sin(time * 5)) : 1.0 - let r = radius * pulse - - let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) - // Halo - let haloR: CGFloat = r * 1.7 - context.fill( - Circle().path(in: CGRect(x: pos.x - haloR, y: pos.y - haloR, - width: haloR * 2, height: haloR * 2)), - with: .color(role.color.opacity(0.15)) - ) - context.fill(Circle().path(in: rect), with: .color(role.color.opacity(0.95))) - context.stroke(Circle().path(in: rect), - with: .color(.white.opacity(0.55)), lineWidth: 1.5) - context.draw( - Text(String(role.displayName.prefix(1))) - .font(.system(size: settings.scaled(22), weight: .heavy, design: .monospaced)) - .foregroundColor(.white), - at: pos - ) - context.draw( - Text(role.displayName.uppercased()) - .font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced)) - .foregroundColor(role.color.opacity(0.95)), - at: CGPoint(x: pos.x, y: pos.y + r + 14) - ) - - // View bubble: a rounded rect to the SIDE of the node listing all - // received message hashes. We use the node's quadrant to decide which - // side the bubble sits on. - let bubbleW: CGFloat = 130 - let bubbleH: CGFloat = 110 - let bubbleX: CGFloat = bubbleSide == .right - ? pos.x + r + 18 - : pos.x - r - 18 - bubbleW - let bubbleRect = CGRect(x: bubbleX, y: pos.y - bubbleH / 2, - width: bubbleW, height: bubbleH) - context.fill(RoundedRectangle(cornerRadius: 10).path(in: bubbleRect), - with: .color(.black.opacity(0.55))) - context.stroke(RoundedRectangle(cornerRadius: 10).path(in: bubbleRect), - with: .color(role.color.opacity(0.55)), lineWidth: 1) - context.draw( - Text("\(role.displayName.uppercased()) SEES") - .font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced)) - .foregroundColor(role.color.opacity(0.9)), - at: CGPoint(x: bubbleRect.midX, y: bubbleRect.minY + 12) - ) - // Each absorbed message becomes a small line in the bubble. - var rowY = bubbleRect.minY + 30 - let allMsgs = script.messages - for msg in allMsgs { - guard let progress = view.received[msg.id] else { continue } - let alpha = progress - let prefix = "\(msg.id) ·" - let txt = "\(prefix) \(msg.hashShort)…" + // parents line + if composing.parentsFilled { + let parentsText = msg.parents.isEmpty ? "(genesis)" : msg.parents.joined(separator: ", ") context.draw( - Text(txt) - .font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced)) - .foregroundColor(msg.author.role.color.opacity(0.95 * alpha)), - at: CGPoint(x: bubbleRect.minX + 12, y: rowY), + Text("parents: \(parentsText)") + .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.85)), + at: CGPoint(x: boxRect.minX + 12, y: rowY), anchor: .leading ) - rowY += 18 + rowY += 16 } - if rowY == bubbleRect.minY + 30 { + // PoW progress / hash line + if composing.sealed { context.draw( - Text("(empty)") - .font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced)) - .foregroundColor(.white.opacity(0.3)), - at: CGPoint(x: bubbleRect.midX, y: bubbleRect.midY + 4) + Text("hash: \(msg.hashShort)… ✓") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(color.opacity(0.95)), + at: CGPoint(x: boxRect.minX + 12, y: rowY), + anchor: .leading + ) + } else if composing.powProgress > 0 { + let bars = Int(composing.powProgress * 20) + let bar = String(repeating: "█", count: bars) + + String(repeating: "·", count: 20 - bars) + context.draw( + Text("PoW: [\(bar)]") + .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.75)), + at: CGPoint(x: boxRect.minX + 12, y: rowY), + anchor: .leading ) } } - /// In-flight envelope — small rectangle with the message id + hash at the - /// interpolated position between sender and recipient. - private func drawFlightEnvelope( - in context: inout GraphicsContext, at pos: CGPoint, - message: GossipScript.ScriptedMessage, - from src: CGPoint, to dst: CGPoint, progress: Double - ) { - // Subtle dashed path so the eye can follow. - var path = Path() - path.move(to: src) - path.addLine(to: dst) - let dash: [CGFloat] = [3, 5] - context.stroke(path, - with: .color(message.author.role.color.opacity(0.20)), - style: StrokeStyle(lineWidth: 1, dash: dash)) + // MARK: - Decide arrow - let envW: CGFloat = 70 - let envH: CGFloat = 28 + private func drawDecideArrow( + in context: inout GraphicsContext, size: CGSize, + decide: Ch01WorldState.DecideArrowState + ) { + let from = castPosition(cast: decide.from, size: size) + let to = castPosition(cast: decide.to, size: size) + // Curved line from sender to recipient + var path = Path() + let mid = CGPoint(x: (from.x + to.x) / 2, y: (from.y + to.y) / 2) + path.move(to: from) + path.addQuadCurve(to: to, + control: CGPoint(x: mid.x, y: mid.y - 30)) + context.stroke(path, + with: .color(castColor(decide.from).opacity(0.5)), + style: StrokeStyle(lineWidth: 1.6, dash: [5, 5])) + // Label at midpoint + context.draw( + Text("→ send \(decide.messageId) to \(decide.to.role.displayName.uppercased())") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.white.opacity(0.8)), + at: CGPoint(x: mid.x, y: mid.y - 36) + ) + } + + // MARK: - In-flight envelope + + private func drawInFlight( + in context: inout GraphicsContext, size: CGSize, + flight: Ch01WorldState.InFlightState + ) { + let from = castPosition(cast: flight.from, size: size) + let to = castPosition(cast: flight.to, size: size) + // Path + var path = Path() + path.move(to: from) + path.addLine(to: to) + context.stroke(path, + with: .color(castColor(flight.from).opacity(0.30)), + style: StrokeStyle(lineWidth: 1.0, dash: [3, 5])) + // Envelope at progress + let p = CGFloat(flight.progress) + let pos = CGPoint(x: from.x + (to.x - from.x) * p, + y: from.y + (to.y - from.y) * p) + guard let msg = Ch01Timeline.messages[flight.messageId] else { return } + let envW: CGFloat = 76 + let envH: CGFloat = 32 let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2, width: envW, height: envH) - context.fill(RoundedRectangle(cornerRadius: 4).path(in: rect), - with: .color(message.author.role.color.opacity(0.92))) - context.stroke(RoundedRectangle(cornerRadius: 4).path(in: rect), - with: .color(.white.opacity(0.6)), lineWidth: 0.8) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(castColor(flight.from).opacity(0.92))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.7)), lineWidth: 1.0) context.draw( - Text("\(message.id) · \(message.hashShort)") - .font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced)) + Text("\(flight.messageId) · \(msg.hashShort)") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) .foregroundColor(.white), at: pos ) - - _ = progress } - // MARK: - Perspective panel (Aaron/Ben/Carl/Common) + // MARK: - Open envelope card - /// Compact display item for a vertex referenced by the panel. - private struct PanelItem { - let label: String - let id: String // digest hex, or scripted message id ("α"/"β"/"γ") - let color: Color - } - - /// Top-of-canvas perspective panel. Shows what each cast member has seen - /// so far + the common nucleus, regardless of whether the underlying - /// data is staged DAG vertices (scenes 0-2) or scripted gossip messages - /// (scene 3). Lives at the TOP of the canvas because the LIVE app - /// overlays `GlassNarration` at the bottom — anything drawn at the - /// bottom of the canvas would be hidden in the running app even if the - /// MP4 testbed (no overlay) renders it just fine. - private func drawPerspectivePanel( - in context: inout GraphicsContext, size: CGSize, time: Double, - items: [PanelItem], - aaronKnows: Set, - benKnows: Set, - carlKnows: Set, - fadeStart: Double = 0.4, - fadeDuration: Double = 1.0 + private func drawOpenEnvelope( + in context: inout GraphicsContext, size: CGSize, + env: Ch01WorldState.OpenEnvelopeState ) { - let fade = max(0, min(1, (time - fadeStart) / fadeDuration)) - if fade < 0.05 { return } + guard let msg = Ch01Timeline.messages[env.messageId] else { return } + let recipientPos = castPosition(cast: env.recipient, size: size) + let cardW: CGFloat = 320 + let cardH: CGFloat = 140 + // Place to the side of the recipient that has space; if recipient + // is on the right, put card to the left, and vice versa. + let placeRight = recipientPos.x < size.width / 2 + let cardX: CGFloat = placeRight + ? recipientPos.x + 56 + : recipientPos.x - 56 - cardW + let cardY: CGFloat = recipientPos.y - cardH / 2 + let cardRect = CGRect(x: cardX, y: cardY, width: cardW, height: cardH) + let color = castColor(msg.author) - let common = aaronKnows.intersection(benKnows).intersection(carlKnows) - let panels: [(title: String, knows: Set, accent: Color, isCommon: Bool)] = [ - ("AARON'S VIEW", aaronKnows, Cast.coral, false), - ("BEN'S VIEW", benKnows, Cast.teal, false), - ("CARL'S VIEW", carlKnows, Cast.amber, false), - ("COMMON KNOWLEDGE", common, .green, true), - ] - let panelHeight: CGFloat = 96 - let panelY: CGFloat = 14 // top edge — clears narration overlay below - let totalW = size.width - 60 - let colW = totalW / 4 - - for (i, p) in panels.enumerated() { - let x = 30 + CGFloat(i) * colW - let rect = CGRect(x: x + 8, y: panelY, width: colW - 16, height: panelHeight) - context.fill(RoundedRectangle(cornerRadius: 8).path(in: rect), - with: .color(.black.opacity(0.55 * fade))) - context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rect), - with: .color(p.accent.opacity((p.isCommon ? 0.9 : 0.55) * fade)), - lineWidth: p.isCommon ? 2 : 1) - context.draw( - Text(p.title) - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(p.accent.opacity((p.isCommon ? 0.95 : 0.8) * fade)), - at: CGPoint(x: rect.midX, y: rect.minY + 14) - ) - let dotY = rect.minY + 46 - // Distribute up to 3 item dots evenly across the column. We use - // a fixed slot width so panels with 1 or 2 items stay aligned. - for (j, cv) in items.enumerated() { - let slotCount = max(1, items.count - 1) - let cx = rect.minX + 26 + CGFloat(j) * (rect.width - 52) / CGFloat(slotCount) - let known = p.knows.contains(cv.id) - let dotR: CGFloat = 10 - let dotRect = CGRect(x: cx - dotR, y: dotY - dotR, - width: dotR * 2, height: dotR * 2) - let bright = known ? 1.0 : 0.16 - context.fill(Circle().path(in: dotRect), - with: .color(cv.color.opacity(bright * fade))) - context.stroke(Circle().path(in: dotRect), - with: .color(.white.opacity((known ? 0.6 : 0.22) * fade)), - lineWidth: 1) - context.draw( - Text(cv.label) - .font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced)) - .foregroundColor(cv.color.opacity((known ? 0.95 : 0.28) * fade)), - at: CGPoint(x: cx, y: dotY + dotR + 10) - ) - context.draw( - Text(known ? "✓" : "✗") - .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) - .foregroundColor((known ? Color.green : Color.red).opacity((known ? 0.9 : 0.5) * fade)), - at: CGPoint(x: cx, y: dotY + dotR + 22) - ) - } - } - - // Caption directly under the panel band, summarizing the convergence - // state in one line. - let sharedLabels = items.filter { common.contains($0.id) }.map(\.label) - let unsharedLabels = items.filter { !common.contains($0.id) }.map(\.label) - let captionTxt: String - if sharedLabels.isEmpty { - captionTxt = "DIFFERENT VIEWS · NO SHARED VERTEX YET · GOSSIP MUST CONTINUE" - } else if unsharedLabels.isEmpty { - captionTxt = "EVERY VIEW MATCHES — \(sharedLabels.joined(separator: ", ")) — FULL CONVERGENCE" - } else { - captionTxt = "SHARED: \(sharedLabels.joined(separator: " · ")) · STILL TRAVELING: \(unsharedLabels.joined(separator: " · "))" - } + context.fill(RoundedRectangle(cornerRadius: 10).path(in: cardRect), + with: .color(.black.opacity(0.88))) + context.stroke(RoundedRectangle(cornerRadius: 10).path(in: cardRect), + with: .color(color.opacity(0.95)), lineWidth: 1.5) context.draw( - Text(captionTxt) + Text("\(env.recipient.role.displayName.uppercased()) READS \(env.messageId)") .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) - .foregroundColor(.white.opacity(0.75 * fade)), - at: CGPoint(x: size.width / 2, y: panelY + panelHeight + 10) + .foregroundColor(color), + at: CGPoint(x: cardRect.minX + 12, y: cardRect.minY + 14), + anchor: .leading ) - } - - /// For scenes 0/1/2, return a hand-curated narrative beat. The narration - /// promises a strict story arc — Aaron's FIRST message → Ben referencing - /// THAT message → Carl referencing THAT message — so the staging must - /// PREFER edges into Aaron's earliest vertex (round 0) over edges into - /// later Aaron vertices. - /// - /// Scene 0: Aaron's earliest vertex (round 0). - /// Scene 1: + the Ben vertex whose parent edge points DIRECTLY to - /// Aaron's R0. If no such Ben vertex exists in this snapshot, - /// relax to "any Aaron vertex" but log it and let an - /// invariant flag the curriculum mismatch. - /// Scene 2: + the Carl vertex whose parent edge points DIRECTLY to - /// Aaron's R0 (preferred) or to Ben's chosen vertex. - /// - /// Returns nil for scenes ≥ 3 so the caller falls back to progressive - /// reveal. - private func narrativeStagedSet(snap: NodeSnapshot) -> Set? { - guard sceneIndex <= 2 else { return nil } - - guard let aaronPid = pid(of: Cast.aaron), - let benPid = pid(of: Cast.ben), - let carlPid = pid(of: Cast.carl) else { return nil } - - // Aaron's earliest vertex (round 0, lexicographically lowest digest - // tiebreaker — the same vertex shown in scene 0). - guard let aaronR0 = snap.vertices - .filter({ $0.processIdHex == aaronPid }) - .min(by: { $0.round < $1.round - || ($0.round == $1.round && $0.digestHex < $1.digestHex) }) - else { return nil } - let aaronR0Hex = aaronR0.digestHex - let allAaronHex = Set(snap.vertices.filter { $0.processIdHex == aaronPid }.map(\.digestHex)) - - // Visible always includes Aaron R0 — that's the anchor of the chapter. - var visible: Set = [aaronR0Hex] - if sceneIndex == 0 { return visible } - - // Scene 1: Ben. - // PASS A — strict: pick Ben's earliest vertex with an edge to Aaron R0. - // PASS B — relaxed: pick Ben's earliest vertex with an edge to ANY Aaron vertex. - let benCandidates = snap.vertices.filter { $0.processIdHex == benPid } - .sorted { $0.round < $1.round || ($0.round == $1.round && $0.digestHex < $1.digestHex) } - let benVertex = benCandidates.first(where: { bv in - snap.edges.contains { $0.from == bv.digestHex && $0.to == aaronR0Hex } - }) - ?? benCandidates.first(where: { bv in - snap.edges.contains { $0.from == bv.digestHex && allAaronHex.contains($0.to) } - }) - ?? benCandidates.first - if let bv = benVertex { visible.insert(bv.digestHex) } - if sceneIndex == 1 { return visible } - - // Scene 2: Carl. - // PASS A — strict: edge into Aaron R0. - // PASS B — edge into any visible vertex. - let carlCandidates = snap.vertices.filter { $0.processIdHex == carlPid } - .sorted { $0.round < $1.round || ($0.round == $1.round && $0.digestHex < $1.digestHex) } - let carlVertex = carlCandidates.first(where: { cv in - snap.edges.contains { $0.from == cv.digestHex && $0.to == aaronR0Hex } - }) - ?? carlCandidates.first(where: { cv in - snap.edges.contains { $0.from == cv.digestHex && visible.contains($0.to) } - }) - ?? carlCandidates.first - if let cv = carlVertex { visible.insert(cv.digestHex) } - return visible - } - - /// pid for a cast role. - private func pid(of role: CastRole) -> String? { - dm.castByPid.first(where: { $0.value.id == role.id })?.key - } - - /// Earliest vertex from the given node that has at least one parent edge - /// into `visibleParents`; falls back to the node's earliest vertex if no - /// such edge exists in the snapshot. - private func earliestVertex(for pid: String, with snap: NodeSnapshot, pointingInto visibleParents: Set) -> VertexData? { - let candidates = snap.vertices.filter { $0.processIdHex == pid } - .sorted { $0.round < $1.round || ($0.round == $1.round && $0.digestHex < $1.digestHex) } - for v in candidates { - let hasEdgeIn = snap.edges.contains { $0.from == v.digestHex && visibleParents.contains($0.to) } - if hasEdgeIn { return v } + var rowY = cardRect.minY + 32 + if env.bodyRevealed { + context.draw( + Text("body: \(msg.payload)") + .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.85)), + at: CGPoint(x: cardRect.minX + 12, y: rowY), + anchor: .leading + ) + rowY += 16 + } + if env.parentsRevealed { + let parentsText = msg.parents.isEmpty ? "(genesis)" : msg.parents.joined(separator: ", ") + context.draw( + Text("parents: \(parentsText)") + .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.85)), + at: CGPoint(x: cardRect.minX + 12, y: rowY), + anchor: .leading + ) + rowY += 16 + } + if !env.resolvedParents.isEmpty { + let resolved = env.resolvedParents.sorted().joined(separator: ", ") + context.draw( + Text("resolved: \(resolved) ✓ (found in local view)") + .font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced)) + .foregroundColor(.green.opacity(0.85)), + at: CGPoint(x: cardRect.minX + 12, y: rowY), + anchor: .leading + ) + rowY += 16 + } + if env.verified { + context.draw( + Text("hash: \(msg.hashShort)… ✓ (verified)") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.green.opacity(0.95)), + at: CGPoint(x: cardRect.minX + 12, y: rowY), + anchor: .leading + ) } - return candidates.first } - /// Find the cast member's earliest visible vertex (lowest round; tie-break by digest) - /// and draw a name-bearing callout next to it. If `parentEdges` is provided, also - /// glow the parent edges leading from that vertex back into the existing graph — - /// makes "Ben copies what Aaron said" / "Carl links in" visually concrete. - private func drawCastFirstVertexCallout( - role: CastRole, - in context: inout GraphicsContext, - layout: DAGLayout, - visibleVerts: [VertexData], - time: Double, - parentEdges: [EdgeData] = [] + // MARK: - Footer + + private func drawFooter( + in context: inout GraphicsContext, size: CGSize, + t: Double, world: Ch01WorldState ) { - guard let pid = dm.castByPid.first(where: { $0.value.id == role.id })?.key else { return } - let candidate = visibleVerts.filter { $0.processIdHex == pid } - .sorted { $0.round < $1.round || ($0.round == $1.round && $0.digestHex < $1.digestHex) } - .first - guard let vertex = candidate, let pos = layout.positions[vertex.digestHex] else { return } - - // Pulsing ring on the highlighted vertex. - let pulse = 0.6 + 0.35 * sin(time * 2.5) - let r: CGFloat = 22 - let ringRect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) - context.stroke(Circle().path(in: ringRect), - with: .color(role.color.opacity(0.85 * pulse)), - lineWidth: 2.5) - - // Parent edges: glow in the cast color. - if !parentEdges.isEmpty { - for edge in parentEdges where edge.from == vertex.digestHex { - guard let parentPos = layout.positions[edge.to] else { continue } - var path = Path() - path.move(to: pos) - path.addLine(to: parentPos) - context.stroke(path, with: .color(role.color.opacity(0.7)), lineWidth: 2.5) - } - } - - // Name label, offset to avoid the lane label on the left. - let labelOffsetX: CGFloat = pos.x < 200 ? 30 : -30 - let labelAlign: HorizontalAlignment = pos.x < 200 ? .leading : .trailing - let labelPos = CGPoint(x: pos.x + labelOffsetX, y: pos.y - 28) + let total = Ch01Timeline.totalDuration context.draw( - Text(role.displayName.uppercased()) - .font(DAGLayout.fontHeading(scale: settings.textScale)) - .foregroundColor(role.color.opacity(0.95)), - at: labelPos, anchor: labelAlign == .leading ? .leading : .trailing + Text(String(format: "t=%.1fs / %.0fs · beat: %@", + t, total, world.activeBeat?.id ?? "—")) + .font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.30)), + at: CGPoint(x: 24, y: size.height - 14), + anchor: .leading ) } - - /// BFS backward through ALL parent edges (not just one) to fixed depth, - /// returning every vertex reachable by walking parents. This is the - /// ancestor *cone*, not a chain — vertices have multiple parents and - /// the walk fans out into a tree. - private func ancestorClosure(of root: String, edges: [EdgeData], depth: Int) -> Set { - var seen: Set = [root] - var frontier: [String] = [root] - // Pre-index edges by `from` for cheaper lookup at each hop. - var parentsOf: [String: [String]] = [:] - for e in edges { parentsOf[e.from, default: []].append(e.to) } - for _ in 0.. [String] { - var parentOf: [String: String] = [:] - for e in edges { - if parentOf[e.from] == nil { - parentOf[e.from] = e.to - } - } - - guard let start = vertices.sorted(by: { $0.round > $1.round }).first else { return [] } - var chain: [String] = [start.digestHex] - var current = start.digestHex - - for _ in 0..<50 { - if let parent = parentOf[current] { - chain.append(parent) - current = parent - } else { - break - } - } - return chain - } +} + +// MARK: - Scene → timeline mapping + +/// Each Ch01 scene maps to a window of the unified timeline. The 7 scenes +/// stay as navigation labels — arrow keys still let the viewer jump +/// between them — but the visualization is one continuous slo-mo story. +/// +/// `SceneEngine` reads `durationFor(scene:)` so its auto-advance and the +/// `localTime → progress` math match. Total Ch01 duration ≈ 326s. +enum Ch01Scenes { + /// Cumulative start time of each scene in the Ch01 timeline. + static let sceneStarts: [Double] = [ + 0, // 0: Aaron writes α + sends to Ben (≈ 69s) + 69, // 1: α to Carl (≈ 38s) + 107, // 2: Ben writes β + sends to Aaron (≈ 67.5s) + 174.5, // 3: Carl writes γ — asymmetry (≈ 33s) + 207.5, // 4: γ to Aaron (≈ 37s) + 244.5, // 5: β to Carl (≈ 37.5s) + 282.0, // 6: γ to Ben + convergence (≈ 44.5s) + ] + + static let sceneDurations: [Double] = [69, 38, 67.5, 33, 37, 37.5, 44.5] + + static func timelineT(sceneIndex: Int, localTime: Double) -> Double { + let idx = max(0, min(sceneIndex, sceneStarts.count - 1)) + return sceneStarts[idx] + localTime + } + + static func durationFor(scene: Int) -> Double { + let idx = max(0, min(scene, sceneDurations.count - 1)) + return sceneDurations[idx] + } + + /// Narration of the currently active beat (or scene-fallback if no + /// beat exists at this t — shouldn't happen for sceneIndex in range). + static func narrationAt(sceneIndex: Int, localTime: Double) -> String { + let t = timelineT(sceneIndex: sceneIndex, localTime: localTime) + return Ch01Timeline.activeBeat(at: t)?.narration ?? "" + } } diff --git a/CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift b/CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift new file mode 100644 index 0000000..f0ed963 --- /dev/null +++ b/CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift @@ -0,0 +1,550 @@ +import SwiftUI + +/// Ch01 unified serial timeline. +/// +/// Replaces the old `GossipScript` (which had parallel beats — α flying to +/// Ben and Carl simultaneously). The pedagogical principle the user +/// articulated: even though Crisis is parallel by design, the LEARNER's +/// eye can only follow serial events. So this timeline strictly serializes +/// every micro-event: +/// +/// compose → seal → choose recipient → flight (one at a time) → +/// arrive → open → read body → read parents → resolve each parent +/// recursively → verify hash → accept into local view +/// +/// Every beat is a deterministic function of timeline position `t`. State +/// at any `t` is whatever you'd get by replaying every beat up to `t`. This +/// makes the timeline scrub-able and reverse-playable cleanly. +/// +/// The chapter's 7 scenes are now just navigation labels — windows of +/// the same continuous timeline. The actual rendering reads `t` and +/// produces state. Narration is bound to the *currently active beat*, not +/// to scenes. + +// MARK: - Types + +enum Ch01Cast: String, Hashable, CaseIterable { + case aaron, ben, carl, dave + + var role: CastRole { + switch self { + case .aaron: return Cast.aaron + case .ben: return Cast.ben + case .carl: return Cast.carl + case .dave: return Cast.dave + } + } +} + +struct Ch01Message: Hashable { + let id: String // "α", "β", "γ" + let author: Ch01Cast + let payload: String + let parents: [String] + let hashShort: String // e.g. "43f3" +} + +enum Ch01BeatKind { + /// A cast member fades onto the stage for the first time. Until this + /// beat fires, that cast's lane is invisible — they don't yet exist + /// in the story. + case introduce(Ch01Cast) + /// Author looks inward and decides what to do next. Renders as a + /// thought bubble above the cast circle. + case think(Ch01Cast, label: String) + /// Author writes the payload. Composing slot fills the body line. + case selectPayload(messageId: String) + /// Author selects parent references. Composing slot reveals the + /// parents line; in the cast's view, the parent vertices pulse. + case selectParents(messageId: String) + /// Author grinds proof-of-work. Composing slot shows a spinner / + /// "computing PoW…" line. This is intentionally long — PoW is the + /// real bottleneck in Crisis. + case computePoW(messageId: String) + /// Hash is now sealed. Composing slot shows the hash line filled in + /// and a small lock icon. The author's view permanently gains the + /// message. + case seal(messageId: String) + /// Author chooses a recipient. An arrow points from author to that + /// recipient (and the recipient's lane fades in if not already on + /// stage). + case decideSend(from: Ch01Cast, to: Ch01Cast, messageId: String) + /// Envelope physically flies from sender to recipient over the + /// beat's duration. + case fly(from: Ch01Cast, to: Ch01Cast, messageId: String) + /// Envelope arrives at the recipient — small flash, then settles + /// against the recipient's lane. + case arrive(at: Ch01Cast, messageId: String) + /// Recipient opens the envelope (animation: envelope unfolds into + /// the body card). + case open(at: Ch01Cast, messageId: String) + /// Body lines reveal one by one inside the recipient's view bubble. + case readBody(at: Ch01Cast, messageId: String) + /// Recipient sees the parents list. Highlight: parents line glows. + case readParents(at: Ch01Cast, messageId: String) + /// Recipient resolves a single parent reference by looking it up in + /// their own local view. Animation: connector line from the + /// just-arrived envelope's parents-line to the matching vertex in + /// the recipient's view. + case resolveParent(at: Ch01Cast, messageId: String, parentId: String) + /// Recipient hashes the body and confirms it equals the envelope's + /// claimed hash. Animation: SHA arrow body→hash, ✓. + case verifyHash(at: Ch01Cast, messageId: String) + /// Recipient permanently accepts the message into their local view. + /// Their view bubble grows by one row. + case acceptIntoView(at: Ch01Cast, messageId: String) + /// Quiet beat — no new event, just give the eye time to settle. + case settle(label: String) +} + +struct Ch01Beat: Identifiable { + let id: String + let kind: Ch01BeatKind + let durationSeconds: Double + /// Narration bound to this beat. The GlassNarration overlay shows + /// this exact text whenever the timeline cursor is inside the beat. + let narration: String + /// Cumulative start time, computed once at timeline-build time. + var startTime: Double = 0 + var endTime: Double { startTime + durationSeconds } +} + +// MARK: - World state + +/// Snapshot of the dramatized world at one moment in time. Pure function +/// of the timeline `t` — replaying beats up to `t` produces this. +struct Ch01WorldState { + /// Cast members currently on the stage. Lanes for cast NOT in this + /// set are invisible. + var introduced: Set = [] + /// Messages whose seal beat has fired (so their hash exists). + var sealedMessages: Set = [] + /// Each cast member's local view: messages they have fully accepted. + var views: [Ch01Cast: Set] = [:] + /// What the active recipient has read of the just-arrived envelope — + /// payload (if .readBody fired), parents list (if .readParents + /// fired), each individual resolved parent. + var openEnvelope: OpenEnvelopeState? = nil + /// In-flight envelope animation, if active. + var inFlight: InFlightState? = nil + /// Composing animation, if active. + var composing: ComposingState? = nil + /// "Send decision" arrow from sender to recipient, if active. + var decideArrow: DecideArrowState? = nil + /// Thought bubble above a cast member, if active. + var thought: ThoughtState? = nil + /// The currently active beat plus its progress 0..1. Drives the + /// "spotlight" effect on whichever cast member is the focus. + var activeBeat: Ch01Beat? = nil + var activeProgress: Double = 0 + + struct OpenEnvelopeState { + let recipient: Ch01Cast + let messageId: String + var bodyRevealed: Bool = false + var parentsRevealed: Bool = false + var resolvedParents: Set = [] + var verified: Bool = false + } + struct InFlightState { + let messageId: String + let from: Ch01Cast + let to: Ch01Cast + let progress: Double // 0..1 + } + struct ComposingState { + let messageId: String + let author: Ch01Cast + var payloadFilled: Bool = false + var parentsFilled: Bool = false + var powProgress: Double = 0 + var sealed: Bool = false + } + struct DecideArrowState { + let from: Ch01Cast + let to: Ch01Cast + let messageId: String + } + struct ThoughtState { + let cast: Ch01Cast + let label: String + } +} + +// MARK: - Timeline + +enum Ch01Timeline { + /// The three messages in Ch01. + static let messages: [String: Ch01Message] = [ + "α": Ch01Message(id: "α", author: .aaron, payload: "step-1-aaron", + parents: [], hashShort: "43f3"), + "β": Ch01Message(id: "β", author: .ben, payload: "step-2-ben", + parents: ["α"], hashShort: "7638"), + "γ": Ch01Message(id: "γ", author: .carl, payload: "step-3-carl", + parents: ["α"], hashShort: "5ce9"), + // Note: γ's parents = [α] only (NOT [α, β]). This is the + // asymmetry beat — Carl wrote γ before β arrived at him. + ] + + /// Build the beat list with cumulative startTimes filled in. Heavy + /// pedagogical pacing: long PoW beats, slow flights, distinct + /// thinking beats, every parent reference resolved one at a time. + static let beats: [Ch01Beat] = { + let raw: [Ch01Beat] = [ + // ────────── Phase 1: Aaron writes α ────────── + .init(id: "intro-aaron", kind: .introduce(.aaron), durationSeconds: 4.0, + narration: "Meet Aaron. He's one of four validators who will eventually share a single ordered history. Right now he's alone on stage."), + .init(id: "aaron-thinks-write", kind: .think(.aaron, label: "Time to write."), durationSeconds: 4.0, + narration: "Aaron decides to write the very first message. There is no global clock — he just chooses to start now."), + .init(id: "aaron-payload", kind: .selectPayload(messageId: "α"), durationSeconds: 4.0, + narration: "First, Aaron picks his payload — the body of the message. He writes 'step-1-aaron'."), + .init(id: "aaron-parents", kind: .selectParents(messageId: "α"), durationSeconds: 3.5, + narration: "Next, Aaron lists parent messages. He references nothing — α is the genesis, the first message ever."), + .init(id: "aaron-pow", kind: .computePoW(messageId: "α"), durationSeconds: 9.0, + narration: "Aaron grinds proof-of-work. He hashes the message header with successive nonces until the hash starts with enough zeros. This is the real cost of producing a message in Crisis."), + .init(id: "aaron-seal", kind: .seal(messageId: "α"), durationSeconds: 3.5, + narration: "Done. The valid hash is 43f3…. From this moment on, the message's name IS its hash. α is sealed."), + .init(id: "aaron-knows-alpha", kind: .acceptIntoView(at: .aaron, messageId: "α"), durationSeconds: 3.0, + narration: "Aaron's local view now contains α — the first vertex on his lifeline."), + + // ────────── Phase 2: Aaron sends α to Ben ────────── + .init(id: "intro-ben", kind: .introduce(.ben), durationSeconds: 3.5, + narration: "Meet Ben — Aaron's first recipient. He fades in on his own lane, ready to listen."), + .init(id: "aaron-decides-ben", kind: .decideSend(from: .aaron, to: .ben, messageId: "α"), durationSeconds: 3.5, + narration: "Aaron decides to send α to Ben first. A choice arrow appears from Aaron's lane toward Ben's."), + .init(id: "alpha-flies-to-ben", kind: .fly(from: .aaron, to: .ben, messageId: "α"), durationSeconds: 11.0, + narration: "α travels through the gossip network. Slow motion: this is the only time-and-distance the protocol cares about."), + .init(id: "alpha-arrives-ben", kind: .arrive(at: .ben, messageId: "α"), durationSeconds: 2.5, + narration: "The envelope reaches Ben's lane. He sees something has arrived — but he hasn't opened it yet."), + .init(id: "ben-opens-alpha", kind: .open(at: .ben, messageId: "α"), durationSeconds: 3.0, + narration: "Ben opens the envelope. The body and metadata become visible to him."), + .init(id: "ben-reads-body-alpha", kind: .readBody(at: .ben, messageId: "α"), durationSeconds: 4.0, + narration: "Ben reads the body line by line: 'step-1-aaron'. This is what Aaron actually said."), + .init(id: "ben-reads-parents-alpha", kind: .readParents(at: .ben, messageId: "α"), durationSeconds: 3.0, + narration: "Ben reads the parents list: empty. So α is a genesis message — no prior context to resolve."), + .init(id: "ben-verifies-alpha", kind: .verifyHash(at: .ben, messageId: "α"), durationSeconds: 4.5, + narration: "Ben hashes the body himself and gets 43f3…. It matches the envelope's claimed hash. The message is authentic. ✓"), + .init(id: "ben-accepts-alpha", kind: .acceptIntoView(at: .ben, messageId: "α"), durationSeconds: 3.0, + narration: "Ben accepts α into his local view. His lifeline now carries α as well: {α}."), + + // ────────── Phase 3: Aaron sends α to Carl ────────── + .init(id: "intro-carl", kind: .introduce(.carl), durationSeconds: 3.5, + narration: "Meet Carl. He fades in on his lane — Aaron is about to send α to him too."), + .init(id: "aaron-decides-carl", kind: .decideSend(from: .aaron, to: .carl, messageId: "α"), durationSeconds: 3.5, + narration: "Aaron now decides to send α to Carl as well. This is the SECOND copy of α — in real Crisis it would fan out simultaneously, but we serialize for clarity."), + .init(id: "alpha-flies-to-carl", kind: .fly(from: .aaron, to: .carl, messageId: "α"), durationSeconds: 11.0, + narration: "α travels to Carl. Different path, possibly different speed — but the same content."), + .init(id: "alpha-arrives-carl", kind: .arrive(at: .carl, messageId: "α"), durationSeconds: 2.5, + narration: "The envelope reaches Carl."), + .init(id: "carl-opens-alpha", kind: .open(at: .carl, messageId: "α"), durationSeconds: 3.0, + narration: "Carl opens it."), + .init(id: "carl-reads-body-alpha", kind: .readBody(at: .carl, messageId: "α"), durationSeconds: 4.0, + narration: "Carl reads the body. Same payload Ben saw: 'step-1-aaron'."), + .init(id: "carl-reads-parents-alpha", kind: .readParents(at: .carl, messageId: "α"), durationSeconds: 3.0, + narration: "Carl reads parents: empty. Same as for Ben."), + .init(id: "carl-verifies-alpha", kind: .verifyHash(at: .carl, messageId: "α"), durationSeconds: 4.5, + narration: "Carl recomputes the hash, matches 43f3…. ✓"), + .init(id: "carl-accepts-alpha", kind: .acceptIntoView(at: .carl, messageId: "α"), durationSeconds: 3.0, + narration: "Carl accepts α. His view: {α}. Three players now share α."), + + // ────────── Phase 4: Ben writes β (referencing α) ────────── + .init(id: "ben-thinks-write", kind: .think(.ben, label: "I should respond."), durationSeconds: 4.0, + narration: "Now Ben decides to write his own message. He has α in his local view, so β can reference it."), + .init(id: "ben-payload", kind: .selectPayload(messageId: "β"), durationSeconds: 4.0, + narration: "Ben picks his payload: 'step-2-ben'."), + .init(id: "ben-parents", kind: .selectParents(messageId: "β"), durationSeconds: 4.5, + narration: "Ben picks parents: α — he saw α before he started writing, so he embeds α's hash in β. This is what 'I saw your message before I spoke' looks like in code."), + .init(id: "ben-pow", kind: .computePoW(messageId: "β"), durationSeconds: 9.0, + narration: "Ben grinds proof-of-work for β. Same cost as Aaron paid for α."), + .init(id: "ben-seal", kind: .seal(messageId: "β"), durationSeconds: 3.5, + narration: "β is sealed. Hash: 7638…."), + .init(id: "ben-knows-beta", kind: .acceptIntoView(at: .ben, messageId: "β"), durationSeconds: 3.0, + narration: "Ben's local view: {α, β}. Two vertices on his lifeline."), + + // ────────── Phase 5: Ben sends β to Aaron — RESOLVE PARENT ────────── + .init(id: "ben-decides-aaron", kind: .decideSend(from: .ben, to: .aaron, messageId: "β"), durationSeconds: 3.5, + narration: "Ben sends β back to Aaron first."), + .init(id: "beta-flies-to-aaron", kind: .fly(from: .ben, to: .aaron, messageId: "β"), durationSeconds: 11.0, + narration: "β travels."), + .init(id: "beta-arrives-aaron", kind: .arrive(at: .aaron, messageId: "β"), durationSeconds: 2.5, + narration: "Aaron receives the envelope."), + .init(id: "aaron-opens-beta", kind: .open(at: .aaron, messageId: "β"), durationSeconds: 3.0, + narration: "Aaron opens β."), + .init(id: "aaron-reads-body-beta", kind: .readBody(at: .aaron, messageId: "β"), durationSeconds: 4.0, + narration: "He reads the body: 'step-2-ben'. So Ben said something."), + .init(id: "aaron-reads-parents-beta", kind: .readParents(at: .aaron, messageId: "β"), durationSeconds: 3.5, + narration: "He reads parents: α. So β refers to a message named α."), + .init(id: "aaron-resolves-alpha-from-beta", kind: .resolveParent(at: .aaron, messageId: "β", parentId: "α"), durationSeconds: 5.0, + narration: "Aaron looks up α in his own local view. Yes — he already has α. (It's his own message. He wrote it.) The reference resolves cleanly. ✓"), + .init(id: "aaron-verifies-beta", kind: .verifyHash(at: .aaron, messageId: "β"), durationSeconds: 4.0, + narration: "Aaron hashes β's body, gets 7638…. Matches. ✓"), + .init(id: "aaron-accepts-beta", kind: .acceptIntoView(at: .aaron, messageId: "β"), durationSeconds: 3.0, + narration: "Aaron accepts β. His view: {α, β}."), + + // ────────── Phase 6: Carl writes γ — THE ASYMMETRY BEAT ────────── + .init(id: "asymmetry-pause", kind: .settle(label: "Crucial moment ahead"), durationSeconds: 3.5, + narration: "PAY ATTENTION. The next beat is the lesson of this chapter. Carl is about to write his own message — but β has not yet reached him."), + .init(id: "carl-thinks-write", kind: .think(.carl, label: "I have α; let me write."), durationSeconds: 4.5, + narration: "Carl decides to write γ. At this moment his local view contains only α — β is still in flight in the background of the story (we'll get to it)."), + .init(id: "carl-payload", kind: .selectPayload(messageId: "γ"), durationSeconds: 4.0, + narration: "Carl picks his payload: 'step-3-carl'."), + .init(id: "carl-parents", kind: .selectParents(messageId: "γ"), durationSeconds: 5.5, + narration: "Carl picks parents from HIS local view. He has only α. So γ references just α — NOT β. This is the asymmetry: γ does not depend on β. Different validators see different worlds at the moment they speak."), + .init(id: "carl-pow", kind: .computePoW(messageId: "γ"), durationSeconds: 9.0, + narration: "Carl grinds proof-of-work for γ."), + .init(id: "carl-seal", kind: .seal(messageId: "γ"), durationSeconds: 3.5, + narration: "γ is sealed. Hash: 5ce9…."), + .init(id: "carl-knows-gamma", kind: .acceptIntoView(at: .carl, messageId: "γ"), durationSeconds: 3.0, + narration: "Carl's local view: {α, γ}. Notice — he STILL doesn't have β."), + + // ────────── Phase 7: Carl sends γ to Aaron ────────── + .init(id: "carl-decides-aaron", kind: .decideSend(from: .carl, to: .aaron, messageId: "γ"), durationSeconds: 3.5, + narration: "Carl sends γ to Aaron."), + .init(id: "gamma-flies-to-aaron", kind: .fly(from: .carl, to: .aaron, messageId: "γ"), durationSeconds: 10.0, + narration: "γ travels to Aaron."), + .init(id: "gamma-arrives-aaron", kind: .arrive(at: .aaron, messageId: "γ"), durationSeconds: 2.5, + narration: "Aaron receives γ."), + .init(id: "aaron-opens-gamma", kind: .open(at: .aaron, messageId: "γ"), durationSeconds: 3.0, + narration: "Aaron opens γ."), + .init(id: "aaron-reads-body-gamma", kind: .readBody(at: .aaron, messageId: "γ"), durationSeconds: 3.5, + narration: "He reads body: 'step-3-carl'."), + .init(id: "aaron-reads-parents-gamma", kind: .readParents(at: .aaron, messageId: "γ"), durationSeconds: 3.5, + narration: "He reads parents: α (only). Notice — γ does NOT mention β. Aaron now sees evidence of the asymmetry: Carl wrote γ before β reached him."), + .init(id: "aaron-resolves-alpha-from-gamma", kind: .resolveParent(at: .aaron, messageId: "γ", parentId: "α"), durationSeconds: 4.5, + narration: "Aaron resolves the α reference in his local view. ✓"), + .init(id: "aaron-verifies-gamma", kind: .verifyHash(at: .aaron, messageId: "γ"), durationSeconds: 3.5, + narration: "Hash check: 5ce9…. Matches. ✓"), + .init(id: "aaron-accepts-gamma", kind: .acceptIntoView(at: .aaron, messageId: "γ"), durationSeconds: 3.0, + narration: "Aaron's local view: {α, β, γ}. He's the first to hold all three."), + + // ────────── Phase 8: Ben sends β to Carl (closes the asymmetry gap) ────────── + .init(id: "ben-decides-carl", kind: .decideSend(from: .ben, to: .carl, messageId: "β"), durationSeconds: 3.5, + narration: "Ben now sends β to Carl. By the time it gets there, Carl has already written γ — but β is still useful to him."), + .init(id: "beta-flies-to-carl", kind: .fly(from: .ben, to: .carl, messageId: "β"), durationSeconds: 11.0, + narration: "β travels to Carl."), + .init(id: "beta-arrives-carl", kind: .arrive(at: .carl, messageId: "β"), durationSeconds: 2.5, + narration: "Carl receives β — finally."), + .init(id: "carl-opens-beta", kind: .open(at: .carl, messageId: "β"), durationSeconds: 3.0, + narration: "Carl opens β."), + .init(id: "carl-reads-body-beta", kind: .readBody(at: .carl, messageId: "β"), durationSeconds: 3.5, + narration: "Reads body: 'step-2-ben'."), + .init(id: "carl-reads-parents-beta", kind: .readParents(at: .carl, messageId: "β"), durationSeconds: 3.5, + narration: "Reads parents: α."), + .init(id: "carl-resolves-alpha-from-beta", kind: .resolveParent(at: .carl, messageId: "β", parentId: "α"), durationSeconds: 4.0, + narration: "Carl resolves α in his local view. ✓"), + .init(id: "carl-verifies-beta", kind: .verifyHash(at: .carl, messageId: "β"), durationSeconds: 3.5, + narration: "Hash: 7638…. Matches. ✓"), + .init(id: "carl-accepts-beta", kind: .acceptIntoView(at: .carl, messageId: "β"), durationSeconds: 3.0, + narration: "Carl's local view: {α, β, γ}. He has all three now — even though γ doesn't reference β. The DAG records what Carl actually KNEW when he spoke, not what was true elsewhere."), + + // ────────── Phase 9: Carl sends γ to Ben ────────── + .init(id: "carl-decides-ben", kind: .decideSend(from: .carl, to: .ben, messageId: "γ"), durationSeconds: 3.5, + narration: "Carl sends γ to Ben."), + .init(id: "gamma-flies-to-ben", kind: .fly(from: .carl, to: .ben, messageId: "γ"), durationSeconds: 10.0, + narration: "γ travels to Ben."), + .init(id: "gamma-arrives-ben", kind: .arrive(at: .ben, messageId: "γ"), durationSeconds: 2.5, + narration: "Ben receives γ."), + .init(id: "ben-opens-gamma", kind: .open(at: .ben, messageId: "γ"), durationSeconds: 3.0, + narration: "Ben opens γ."), + .init(id: "ben-reads-body-gamma", kind: .readBody(at: .ben, messageId: "γ"), durationSeconds: 3.5, + narration: "Reads body."), + .init(id: "ben-reads-parents-gamma", kind: .readParents(at: .ben, messageId: "γ"), durationSeconds: 3.5, + narration: "Reads parents: α (only). Same evidence Aaron saw — Carl wrote γ before he knew about β."), + .init(id: "ben-resolves-alpha-from-gamma", kind: .resolveParent(at: .ben, messageId: "γ", parentId: "α"), durationSeconds: 4.0, + narration: "Ben resolves α. ✓"), + .init(id: "ben-verifies-gamma", kind: .verifyHash(at: .ben, messageId: "γ"), durationSeconds: 3.5, + narration: "Hash check passes. ✓"), + .init(id: "ben-accepts-gamma", kind: .acceptIntoView(at: .ben, messageId: "γ"), durationSeconds: 3.0, + narration: "Ben's local view: {α, β, γ}."), + + // ────────── Phase 10: Convergence ────────── + .init(id: "convergence", kind: .settle(label: "Converged"), durationSeconds: 8.0, + narration: "All three honest validators now hold the SAME set of messages: {α, β, γ}. Their local DAGs have converged. This is local consensus emerging — not from any vote, but from each player observing what the others said and accepting it after verification. Common knowledge of the events has formed."), + ] + + // Fill in cumulative startTimes. + var t: Double = 0 + var assigned: [Ch01Beat] = [] + for var b in raw { + b.startTime = t + assigned.append(b) + t += b.durationSeconds + } + return assigned + }() + + static var totalDuration: Double { + beats.last.map { $0.endTime } ?? 0 + } + + /// Find the active beat at timeline position `t`. Returns nil before + /// the first beat or after the last. + static func activeBeat(at t: Double) -> Ch01Beat? { + let clamped = max(0, min(t, totalDuration)) + // Linear scan is fine for ~75 beats. Could binary-search if it ever matters. + return beats.first { $0.startTime <= clamped && clamped < $0.endTime } + ?? beats.last + } + + /// Pure function: world state at any timeline position. + static func state(at t: Double) -> Ch01WorldState { + var w = Ch01WorldState() + for cast in Ch01Cast.allCases { w.views[cast] = [] } + let clamped = max(0, min(t, totalDuration)) + + for beat in beats { + // Beats fully in the past contribute their permanent effects. + // Beats currently active contribute permanent-up-to-now plus + // their ephemeral animation state. + // Beats in the future contribute nothing. + if clamped < beat.startTime { break } + let isActive = clamped < beat.endTime + let progress = isActive + ? max(0, min(1, (clamped - beat.startTime) / beat.durationSeconds)) + : 1.0 + + applyBeat(beat, progress: progress, isActive: isActive, into: &w) + + if isActive { + w.activeBeat = beat + w.activeProgress = progress + } + } + return w + } + + private static func applyBeat( + _ beat: Ch01Beat, progress: Double, isActive: Bool, + into w: inout Ch01WorldState + ) { + switch beat.kind { + case .introduce(let cast): + // Permanent once started. + w.introduced.insert(cast) + + case .think(let cast, let label): + if isActive { + w.thought = .init(cast: cast, label: label) + } + + case .selectPayload(let mid): + // Permanent: composing exists with payload filled (until seal). + ensureComposing(messageId: mid, into: &w) + w.composing?.payloadFilled = true + if isActive { /* current focus */ } + + case .selectParents(let mid): + ensureComposing(messageId: mid, into: &w) + w.composing?.parentsFilled = true + + case .computePoW(let mid): + ensureComposing(messageId: mid, into: &w) + // PoW progress equals the beat's progress (0..1). + w.composing?.powProgress = progress + + case .seal(let mid): + // Permanent: message is sealed; composing finishes. + w.sealedMessages.insert(mid) + // Author "knows" their own message after sealing. + if let msg = messages[mid] { + w.views[msg.author, default: []].insert(mid) + } + // Once sealed, drop the composing state. + if !isActive { w.composing = nil } + else { + ensureComposing(messageId: mid, into: &w) + w.composing?.sealed = true + } + + case .decideSend(let from, let to, let mid): + if isActive { + w.decideArrow = .init(from: from, to: to, messageId: mid) + } + + case .fly(let from, let to, let mid): + if isActive { + w.inFlight = .init(messageId: mid, from: from, to: to, + progress: progress) + } + + case .arrive(let at, let mid): + if isActive { + // The envelope is settling against the recipient's lane. + w.openEnvelope = .init(recipient: at, messageId: mid) + } + + case .open(let at, let mid): + if isActive { + w.openEnvelope = .init(recipient: at, messageId: mid) + } else { + // Stays "open" until verify completes — handled below. + if w.openEnvelope?.recipient != at || w.openEnvelope?.messageId != mid { + w.openEnvelope = .init(recipient: at, messageId: mid) + } + } + + case .readBody(let at, let mid): + ensureOpenEnvelope(at: at, messageId: mid, into: &w) + w.openEnvelope?.bodyRevealed = true + + case .readParents(let at, let mid): + ensureOpenEnvelope(at: at, messageId: mid, into: &w) + w.openEnvelope?.parentsRevealed = true + + case .resolveParent(let at, let mid, let parentId): + ensureOpenEnvelope(at: at, messageId: mid, into: &w) + w.openEnvelope?.resolvedParents.insert(parentId) + + case .verifyHash(let at, let mid): + ensureOpenEnvelope(at: at, messageId: mid, into: &w) + w.openEnvelope?.verified = true + + case .acceptIntoView(let at, let mid): + // Permanent: recipient now holds the message. + w.views[at, default: []].insert(mid) + // Once accepted, the open envelope is dismissed. + if !isActive { + if w.openEnvelope?.recipient == at && w.openEnvelope?.messageId == mid { + w.openEnvelope = nil + } + } + + case .settle: + break // pure narration / quiet beat + } + + // Clear ephemeral state on transition out: if this beat is in the + // past, its ephemeral things should not bleed forward. + if !isActive { + switch beat.kind { + case .think: + if w.thought != nil && w.activeBeat?.id != beat.id { + // Only clear if a later think doesn't override. + w.thought = nil + } + case .decideSend: + if w.decideArrow != nil && w.activeBeat?.id != beat.id { + w.decideArrow = nil + } + case .fly: + if w.inFlight != nil && w.activeBeat?.id != beat.id { + w.inFlight = nil + } + default: break + } + } + } + + private static func ensureComposing(messageId: String, into w: inout Ch01WorldState) { + if w.composing?.messageId != messageId { + guard let msg = messages[messageId] else { return } + w.composing = .init(messageId: messageId, author: msg.author) + } + } + + private static func ensureOpenEnvelope(at: Ch01Cast, messageId: String, into w: inout Ch01WorldState) { + if w.openEnvelope?.recipient != at || w.openEnvelope?.messageId != messageId { + w.openEnvelope = .init(recipient: at, messageId: messageId) + } + } +} diff --git a/CrisisViz/Sources/CrisisViz/Engine/GossipScript.swift b/CrisisViz/Sources/CrisisViz/Engine/GossipScript.swift deleted file mode 100644 index 7774523..0000000 --- a/CrisisViz/Sources/CrisisViz/Engine/GossipScript.swift +++ /dev/null @@ -1,236 +0,0 @@ -import SwiftUI - -/// Hand-crafted gossip dramatization for Ch01 scene 3. -/// -/// The user's brief: "extreme slow motion, players appearing in random ways, -/// message bodies being created and being filled line by line, and then sent -/// out and then they fly very slowly to some guy in the gossip network and -/// also to some other guy. Once the message arrives at a player, the -/// player's perspective bubble changes slowly during the reading." -/// -/// The simulation snapshots can't deliver this — they're step-aligned and -/// gossip catches up too quickly. So this is a synthetic vignette: a fixed -/// list of `Beat`s that play out over ~30 seconds. At any local time t the -/// rendering computes: -/// - which messages each cast member has CREATED (knows about) -/// - which messages have ARRIVED at each cast member (their local view) -/// - which messages are IN FLIGHT and how far along the path -/// -/// Every beat's timing is explicit so a future edit just changes the script. -struct GossipScript { - - /// Lifecycle of one staged message. - struct ScriptedMessage: Hashable { - let id: String // e.g. "α", "β", "γ" - let author: CastRoleKey // who created it - let parents: [String] // ids of referenced parents - let payload: String // human-readable label - let hashShort: String // 6-char hash to display - } - - /// Reference cast members by stable key so the script doesn't drift if - /// the live `Cast.aaron` etc. instance changes. - enum CastRoleKey: String, Hashable, CaseIterable { - case aaron, ben, carl, dave - var role: CastRole { - switch self { - case .aaron: return Cast.aaron - case .ben: return Cast.ben - case .carl: return Cast.carl - case .dave: return Cast.dave - } - } - } - - /// Discrete event types in the dramatization. - enum BeatKind { - /// Author starts physically writing the message body. Lasts - /// `composeDuration` seconds; bytes/parent-hashes appear line by line. - case compose(durationSeconds: Double) - /// The message hash is finalized (PoW completed instantly — we don't - /// dramatize the work itself, only its outcome). - case sealHash - /// Author dispatches the message to a target. Hop has its own - /// `flightDuration` so simultaneous fan-out can use different - /// arrival times. - case send(to: CastRoleKey, flightDuration: Double) - /// Recipient absorbs the message into their local view. Lasts - /// `readDuration` seconds during which their bubble grows the entry. - case receive(at: CastRoleKey, readDuration: Double) - } - - struct Beat { - let startTime: Double // scene-local seconds - let messageId: String - let kind: BeatKind - } - - let messages: [ScriptedMessage] - let beats: [Beat] - let totalDuration: Double - - // MARK: - Snapshot computation - - /// State of one cast member's local view at time t. - /// `received[id] = absorptionProgress (0..1)`. A message is "fully read" - /// when progress = 1. - struct ViewState { - var received: [String: Double] = [:] - } - - /// State of one in-flight (sent but not yet received) message. - struct InFlightMessage { - let message: ScriptedMessage - let from: CastRoleKey - let to: CastRoleKey - let progress: Double // 0 = just sent, 1 = arrived - } - - /// State of one composition-in-progress message. - struct ComposingMessage { - let message: ScriptedMessage - let author: CastRoleKey - let progress: Double // 0..1, controls how many lines are filled - let sealed: Bool - } - - struct WorldState { - var views: [CastRoleKey: ViewState] = [:] - var inFlight: [InFlightMessage] = [] - var composing: [ComposingMessage] = [] - /// Messages that have been finalized + sealed (so their hash is known - /// and can be referenced by later messages). - var sealedMessages: Set = [] - /// Highlight: the most recently completed beat — used to flash the - /// receiver/composer briefly. - var spotlight: (CastRoleKey, BeatKind)? - } - - func state(at t: Double) -> WorldState { - var w = WorldState() - for key in CastRoleKey.allCases { w.views[key] = ViewState() } - - // For each beat, advance state. - // We must process beats in order; a `receive` beat at time T only - // updates the receiver's view if the corresponding `send` started - // earlier (the script is responsible for that ordering). - for beat in beats { - let elapsed = t - beat.startTime - switch beat.kind { - case .compose(let dur): - guard elapsed >= 0 else { continue } - let progress = min(1.0, elapsed / dur) - if progress < 1.0 { - if let msg = messages.first(where: { $0.id == beat.messageId }) { - w.composing.append(ComposingMessage( - message: msg, author: authorKey(of: beat), - progress: progress, sealed: false - )) - w.spotlight = (authorKey(of: beat), beat.kind) - } - } - // After composition completes, the message is "owned" by the - // author but not yet sealed (sealing is its own beat). - case .sealHash: - if elapsed >= 0 { - w.sealedMessages.insert(beat.messageId) - // The author KNOWS their own message the moment it's - // sealed. Without this the author's view bubble shows - // "empty" while their message is in flight, which - // contradicts the lesson (the author is the first to - // know their own message). - if let msg = messages.first(where: { $0.id == beat.messageId }) { - w.views[msg.author]?.received[msg.id] = 1.0 - } - } - case .send(let to, let dur): - guard elapsed >= 0 else { continue } - let progress = min(1.0, elapsed / dur) - if let msg = messages.first(where: { $0.id == beat.messageId }) { - if progress < 1.0 { - w.inFlight.append(InFlightMessage( - message: msg, from: msg.author, - to: to, progress: progress - )) - } - } - case .receive(let at, let dur): - guard elapsed >= 0 else { continue } - let progress = min(1.0, elapsed / dur) - w.views[at]?.received[beat.messageId] = progress - if progress < 1.0 { - w.spotlight = (at, beat.kind) - } - } - } - return w - } - - private func authorKey(of beat: Beat) -> CastRoleKey { - messages.first(where: { $0.id == beat.messageId })?.author ?? .aaron - } - - // MARK: - Default script - - /// The Ch01 scene-3 dramatization. Total ~28 seconds, designed to fit - /// within an extended scene duration when localTime is unconstrained. - /// Beats: - /// t= 0.5: Aaron starts composing α (3s) - /// t= 3.5: α sealed - /// t= 4.0: Aaron sends α to Ben (4s flight) - /// t= 4.0: Aaron sends α to Carl (5s flight) - /// t= 8.0: Ben receives α (1.5s read) - /// t= 9.0: Carl receives α (1.5s read) - /// t=10.5: Ben starts composing β (referencing α) (3s) - /// t=13.5: β sealed - /// t=14.0: Ben sends β to Aaron (3.5s flight) - /// t=14.0: Ben sends β to Carl (4s flight) - /// t=15.0: Carl starts composing γ (referencing α only — Carl has not - /// received β yet) (3s) - /// t=18.0: γ sealed - /// t=18.0: Aaron receives β (1.5s) - /// t=18.5: Carl sends γ to Aaron (3s flight) - /// t=18.5: Carl sends γ to Ben (3s flight) - /// t=18.0: Carl receives β (1.5s) — by this point Carl already sent γ - /// without referencing β; the asymmetry is the lesson - /// t=21.5: Aaron receives γ - /// t=21.5: Ben receives γ - /// t=23.5: All three views have {α, β, γ} - static let ch01 = GossipScript( - messages: [ - ScriptedMessage(id: "α", author: .aaron, parents: [], - payload: "step-1-aaron", - hashShort: "43f3"), - ScriptedMessage(id: "β", author: .ben, parents: ["α"], - payload: "step-2-ben", - hashShort: "7638"), - ScriptedMessage(id: "γ", author: .carl, parents: ["α"], - payload: "step-3-carl", - hashShort: "5ce9"), - ], - beats: [ - Beat(startTime: 0.5, messageId: "α", kind: .compose(durationSeconds: 3.0)), - Beat(startTime: 3.5, messageId: "α", kind: .sealHash), - Beat(startTime: 4.0, messageId: "α", kind: .send(to: .ben, flightDuration: 4.0)), - Beat(startTime: 4.0, messageId: "α", kind: .send(to: .carl, flightDuration: 5.0)), - Beat(startTime: 8.0, messageId: "α", kind: .receive(at: .ben, readDuration: 1.5)), - Beat(startTime: 9.0, messageId: "α", kind: .receive(at: .carl, readDuration: 1.5)), - Beat(startTime: 10.5, messageId: "β", kind: .compose(durationSeconds: 3.0)), - Beat(startTime: 13.5, messageId: "β", kind: .sealHash), - Beat(startTime: 14.0, messageId: "β", kind: .send(to: .aaron, flightDuration: 3.5)), - Beat(startTime: 14.0, messageId: "β", kind: .send(to: .carl, flightDuration: 4.0)), - // Carl starts composing γ at 15s — only references α because β - // hasn't arrived yet. This is the punch line of the chapter: - // async means simultaneous-yet-different views. - Beat(startTime: 15.0, messageId: "γ", kind: .compose(durationSeconds: 3.0)), - Beat(startTime: 17.5, messageId: "β", kind: .receive(at: .aaron, readDuration: 1.5)), - Beat(startTime: 18.0, messageId: "γ", kind: .sealHash), - Beat(startTime: 18.0, messageId: "β", kind: .receive(at: .carl, readDuration: 1.5)), - Beat(startTime: 18.5, messageId: "γ", kind: .send(to: .aaron, flightDuration: 3.0)), - Beat(startTime: 18.5, messageId: "γ", kind: .send(to: .ben, flightDuration: 3.0)), - Beat(startTime: 21.5, messageId: "γ", kind: .receive(at: .aaron, readDuration: 1.5)), - Beat(startTime: 21.5, messageId: "γ", kind: .receive(at: .ben, readDuration: 1.5)), - ], - totalDuration: 24.0 - ) -} diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index 7e21325..b052e48 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -30,11 +30,19 @@ final class SceneEngine { private var advanceGeneration: Int = 0 let sceneDuration: Double = 8.0 - /// Per-(chapter,scene) duration overrides. Some scenes — notably the - /// Ch01 scene-3 slow-motion gossip dramatization — can't compress into - /// 8 seconds without losing the pedagogy. List them explicitly here. + /// Per-(chapter,scene) duration overrides. Ch01's 7 scenes are now + /// windows of one continuous serial timeline (`Ch01Timeline`), so each + /// scene's duration is the duration of its window. The total Ch01 + /// runtime at 1× ≈ 326 seconds — this is intentional pedagogical + /// slo-mo; speed it up with `adjustSpeed`. private static let durationOverrides: [SceneAddress: Double] = [ - SceneAddress(chapter: 1, scene: 3): 24.0 // gossip dramatization + SceneAddress(chapter: 1, scene: 0): 69.0, // Aaron writes α + sends to Ben + SceneAddress(chapter: 1, scene: 1): 38.0, // α to Carl + SceneAddress(chapter: 1, scene: 2): 67.5, // Ben writes β + sends to Aaron + SceneAddress(chapter: 1, scene: 3): 33.0, // Carl writes γ — asymmetry + SceneAddress(chapter: 1, scene: 4): 37.0, // γ to Aaron + SceneAddress(chapter: 1, scene: 5): 37.5, // β to Carl + SceneAddress(chapter: 1, scene: 6): 44.5, // γ to Ben + convergence ] /// Effective duration for the current scene, honoring overrides. diff --git a/CrisisViz/Sources/CrisisViz/Testbed/SceneVideoCapture.swift b/CrisisViz/Sources/CrisisViz/Testbed/SceneVideoCapture.swift index e6ddb5a..216397f 100644 --- a/CrisisViz/Sources/CrisisViz/Testbed/SceneVideoCapture.swift +++ b/CrisisViz/Sources/CrisisViz/Testbed/SceneVideoCapture.swift @@ -23,11 +23,19 @@ enum SceneVideoCapture { /// auto-advance interval so each clip captures one nominal play-through. static let durationSeconds: Double = 8.0 - /// Per-scene-address duration overrides — clips for these scenes run - /// longer so the scripted dramatization plays in full. Mirror this to + /// Per-scene-address duration overrides. Mirrors /// `SceneEngine.durationOverrides` so live and capture stay in sync. + /// Ch01 scenes carry the full serial timeline; clips for them are + /// long but that's intentional — they're meant for inspection of + /// every micro-beat. static let durationOverrides: [SceneAddress: Double] = [ - SceneAddress(chapter: 1, scene: 3): 24.0 // Ch1.3 gossip script + SceneAddress(chapter: 1, scene: 0): 69.0, + SceneAddress(chapter: 1, scene: 1): 38.0, + SceneAddress(chapter: 1, scene: 2): 67.5, + SceneAddress(chapter: 1, scene: 3): 33.0, + SceneAddress(chapter: 1, scene: 4): 37.0, + SceneAddress(chapter: 1, scene: 5): 37.5, + SceneAddress(chapter: 1, scene: 6): 44.5, ] static func durationFor(_ address: SceneAddress) -> Double { diff --git a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift index 73eb50d..ef23e2b 100644 --- a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift +++ b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift @@ -47,25 +47,31 @@ struct ImmersiveView: View { .background(.black) } - // Narration overlay — bottom left (dimmed while inspecting) - VStack { - Spacer() - HStack { - GlassNarration( - title: sceneTitle, - narration: sceneNarration, - chapterTitle: engine.currentChapter.title, - chapterIndex: engine.address.chapter, - sceneIndex: engine.address.scene, - sceneCount: engine.currentChapter.sceneCount, - globalSceneIndex: engine.address.globalIndex, - totalScenes: AllChapters.totalScenes, - isExpanded: $narrationExpanded - ) + // Narration overlay — bottom left (dimmed while inspecting). + // Wrapped in its OWN TimelineView so the displayed text + // updates at frame rate as the active beat changes (Ch01 is + // beat-bound; other chapters fall back to per-scene text). + TimelineView(.animation(minimumInterval: 1.0 / 30)) { timeline in + let live = engine.localTime(at: timeline.date) + VStack { Spacer() + HStack { + GlassNarration( + title: sceneTitle, + narration: liveNarration(localTime: live), + chapterTitle: engine.currentChapter.title, + chapterIndex: engine.address.chapter, + sceneIndex: engine.address.scene, + sceneCount: engine.currentChapter.sceneCount, + globalSceneIndex: engine.address.globalIndex, + totalScenes: AllChapters.totalScenes, + isExpanded: $narrationExpanded + ) + Spacer() + } + .padding(.leading, 20) + .padding(.bottom, 80) } - .padding(.leading, 20) - .padding(.bottom, 80) } .opacity(inspection.isActive ? 0 : 1) @@ -143,4 +149,15 @@ struct ImmersiveView: View { let addr = engine.address return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene) } + + /// Beat-bound narration for chapters that have a serial timeline + /// (currently only Ch01); falls back to scene-bound narration for + /// every other chapter. + private func liveNarration(localTime: Double) -> String { + let addr = engine.address + if addr.chapter == 1 { + return Ch01Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) + } + return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene) + } }