diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch03_Partition.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch03_Partition.swift index e2ba2c9..076b779 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch03_Partition.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch03_Partition.swift @@ -1,12 +1,13 @@ import SwiftUI -/// Ch03 (file Ch03_Partition.swift, user-facing Ch2): "Dave can't hear Aaron. The graph splits." +/// Ch02 (chapter index 2): "Dave can't hear Aaron. The graph splits." /// -/// Redesign: stays on the persistent lane base from Ch01/Ch02. The partition -/// is shown as a horizontal red dashed cut between Carl's lane (2) and Dave's -/// lane (3). Dave is the only node who goes silent — Aaron/Ben/Carl keep -/// building. This matches the narration in `SceneNarrations.swift` and the -/// cast assignment in `Cast.swift`. +/// Renders from `Ch02Timeline`. Picks up Ch01's final state (all four +/// hold {α, β, γ}), then dramatizes the partition: Dave's link breaks, +/// Aaron writes δ that reaches the honest 3 but not Dave, Dave writes +/// ε that gets stuck on his side, the partition heals, and the +/// missing messages flood through. Perception towers at the bottom +/// make the divergence + reunion visible at a glance. struct Ch03_Partition: View { let sceneIndex: Int let localTime: Double @@ -14,330 +15,516 @@ struct Ch03_Partition: View { let dm: DataManager @Environment(AppSettings.self) private var settings - private let majorityStep = 3 - private let fullStep = 5 // post-reconnection - var body: some View { Canvas { context, size in - render(context: &context, size: size, time: localTime) + let t = Ch02Scenes.timelineT(sceneIndex: sceneIndex, + localTime: localTime) + render(in: &context, size: size, t: t) } } - /// PID of the cast member playing Dave, if assigned. Used to filter the - /// partition victim's vertices out of the "majority" view. - private var davePid: String? { - dm.castByPid.first(where: { $0.value.id == Cast.dave.id })?.key - } + private func render(in context: inout GraphicsContext, size: CGSize, t: Double) { + let world = Ch02Timeline.state(at: t) - private func render(context: inout GraphicsContext, size: CGSize, time: Double) { - guard dm.sim != nil, - let snap = dm.honestData(step: majorityStep) else { - context.draw(Text("Loading...").foregroundColor(.white), - at: CGPoint(x: size.width / 2, y: size.height / 2)) - return + drawLanes(in: &context, size: size) + drawCastFigures(in: &context, size: size, world: world, t: t) + drawAcceptedVertices(in: &context, size: size, world: world) + drawAcceptedEdges(in: &context, size: size, world: world) + + if world.linkHealth < 0.999 { + drawPartitionBarrier(in: &context, size: size, health: world.linkHealth) + } + if let thought = world.thought { + drawThoughtBubble(in: &context, size: size, thought: thought) + } + if let composing = world.composing { + drawComposingSlot(in: &context, size: size, composing: composing) + } + if let flight = world.inFlight { + drawInFlight(in: &context, size: size, flight: flight) + } + if let failed = world.failedFlight { + drawFailedFlight(in: &context, size: size, failed: failed) } - switch sceneIndex { - case 0: renderDaveGoesSilent(context: &context, size: size, time: time, snap: snap) - case 1: renderWorldBuildsOn(context: &context, size: size, time: time, snap: snap) - case 2: renderTwoStories(context: &context, size: size, time: time, snap: snap) - case 3: renderReconnection(context: &context, size: size, time: time) - default: break + drawPerceptionTowers(in: &context, size: size, world: world) + drawBeatTag(in: &context, size: size, world: world) + } + + // MARK: - Lane geometry / colors / lookups + + private func castLaneY(_ laneIdx: Int, size: CGSize) -> CGFloat { + let margin: CGFloat = 60 + let nodeCount: CGFloat = 7 + let laneHeight = (size.height - 2 * margin) / nodeCount + return margin + (CGFloat(laneIdx) + 0.5) * laneHeight + } + + private func castPosition(cast: Ch01Cast, size: CGSize) -> CGPoint { + let laneIdx: Int + switch cast { + case .aaron: laneIdx = 0 + case .ben: laneIdx = 1 + case .carl: laneIdx = 2 + case .dave: laneIdx = 3 + } + return CGPoint(x: size.width * 0.20, 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: - Shared lane-base layout + private func authorOf(_ mid: String) -> Ch01Cast { + if let m = Ch01Timeline.messages[mid] { return m.author } + if let m = Ch02Timeline.messages[mid] { return m.author } + return .aaron + } - /// Builds a layout over the full vertex set so positions never jump as - /// scenes progress. The partition line goes between Carl's lane (2) and - /// Dave's lane (3) — three lanes above, one below, peers below that. - private func laneLayout(snap: NodeSnapshot, size: CGSize) -> DAGLayout { - DAGLayout.compute( - vertices: snap.vertices, - edges: snap.edges, - nodes: dm.castOrderedNodes(), - canvasSize: size, - margin: 60 + private func hashOf(_ mid: String) -> String { + if let m = Ch01Timeline.messages[mid] { return m.hashShort } + if let m = Ch02Timeline.messages[mid] { return m.hashShort } + return "????" + } + + private func parentsOf(_ mid: String) -> [String] { + if let m = Ch01Timeline.messages[mid] { return m.parents } + if let m = Ch02Timeline.messages[mid] { return m.parents } + return [] + } + + // MARK: - Lanes + + private func drawLanes(in context: inout GraphicsContext, size: CGSize) { + let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)] + for (cast, idx) in casts { + let y = castLaneY(idx, size: size) + 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])) + context.draw( + Text(cast.role.displayName.capitalized) + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(castColor(cast).opacity(0.75)), + at: CGPoint(x: 24, y: y), + anchor: .leading + ) + } + } + + // MARK: - Cast figures + + private func drawCastFigures( + in context: inout GraphicsContext, size: CGSize, + world: Ch02WorldState, t: Double + ) { + for cast in Ch01Cast.allCases { + let pos = castPosition(cast: cast, size: size) + let isActive: Bool = { + switch world.activeBeat?.kind { + case .think(let c, _): return c == cast + case .compose(let mid), .seal(let mid): + return Ch02Timeline.messages[mid]?.author == cast + case .fly(let from, _, _), .flyFailed(let from, _, _): + return from == cast + case .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 color = castColor(cast) + 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: CGRect(x: pos.x - r, y: pos.y - r, + width: r * 2, height: r * 2)), + with: .color(color.opacity(0.95)) + ) + context.stroke( + Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r, + width: r * 2, height: r * 2)), + 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 + ) + 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) + ) + } + } + + // MARK: - Accepted vertices on cast lanes + + private func drawAcceptedVertices( + in context: inout GraphicsContext, size: CGSize, world: Ch02WorldState + ) { + let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)] + for (cast, laneIdx) in casts { + let order = world.viewOrder[cast] ?? [] + let lane = castLaneY(laneIdx, size: size) + let castX = castPosition(cast: cast, size: size).x + let firstX = castX + 70 + let gap: CGFloat = 56 + for (i, mid) in order.enumerated() { + 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 + ) { + let r: CGFloat = 14 + let color = castColor(authorOf(messageId)) + 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(12), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: pos + ) + context.draw( + Text(hashOf(messageId)) + .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.5)), + at: CGPoint(x: pos.x, y: pos.y + r + 8) ) } - /// Y coordinate of the dashed partition line ABOVE Dave's lane (between - /// Carl on lane 2 and Dave on lane 3). - private func partitionLineY(size: CGSize, margin: CGFloat = 60) -> CGFloat { - let nodes = dm.castOrderedNodes() - let usableHeight = size.height - margin * 2 - let laneHeight = usableHeight / CGFloat(max(nodes.count, 1)) - return margin + 3.0 * laneHeight - } - - /// Y coordinate of the dashed partition line BELOW Dave's lane (between - /// Dave on lane 3 and the first peer on lane 4). Drawn alongside the top - /// line to visually fence Dave OFF from both Carl above AND the peers - /// below — otherwise the muted peers look partitioned with him, which - /// contradicts the narration ("only Dave is isolated"). - private func partitionLineYBottom(size: CGSize, margin: CGFloat = 60) -> CGFloat { - let nodes = dm.castOrderedNodes() - let usableHeight = size.height - margin * 2 - let laneHeight = usableHeight / CGFloat(max(nodes.count, 1)) - return margin + 4.0 * laneHeight - } - - /// Faint red wash painted over Dave's lane band so the eye reads "this - /// stripe is the partition" instead of "everything below the line". - /// Drawn at the same `cut` strength as the dashed lines. - private func drawDaveIsolationBand( - in context: inout GraphicsContext, size: CGSize, cut: Double + private func drawAcceptedEdges( + in context: inout GraphicsContext, size: CGSize, world: Ch02WorldState ) { - let yTop = partitionLineY(size: size) - let yBot = partitionLineYBottom(size: size) - let band = CGRect(x: 50, y: yTop, - width: size.width - 80, height: yBot - yTop) - context.fill(RoundedRectangle(cornerRadius: 4).path(in: band), - with: .color(.red.opacity(0.10 * cut))) + let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)] + for (cast, laneIdx) in casts { + let order = world.viewOrder[cast] ?? [] + let lane = castLaneY(laneIdx, size: size) + let castX = castPosition(cast: cast, size: size).x + let firstX = castX + 70 + let gap: CGFloat = 56 + var positions: [String: CGPoint] = [:] + for (i, mid) in order.enumerated() { + positions[mid] = CGPoint(x: firstX + CGFloat(i) * gap, y: lane) + } + for (mid, childPos) in positions { + for parentId in parentsOf(mid) { + guard let parentPos = positions[parentId] else { continue } + var path = Path() + let from = CGPoint(x: childPos.x - 14, y: childPos.y) + let to = CGPoint(x: parentPos.x + 14, y: parentPos.y) + path.move(to: from) + path.addLine(to: to) + context.stroke(path, + with: .color(castColor(authorOf(mid)).opacity(0.65)), + lineWidth: 1.2) + } + } + } } - /// Draw both partition cuts (top + bottom of Dave's lane) plus the - /// breakage ✕ marks and the "DAVE — PARTITIONED" label. - private func drawPartitionCuts( - in context: inout GraphicsContext, size: CGSize, cut: Double + // MARK: - Partition barrier + + private func drawPartitionBarrier( + in context: inout GraphicsContext, size: CGSize, health: Double ) { - guard cut > 0.05 else { return } - let yTop = partitionLineY(size: size) - let yBot = partitionLineYBottom(size: size) - let dash: [CGFloat] = [10, 8] - for y in [yTop, yBot] { - var line = Path() - line.move(to: CGPoint(x: 50, y: y)) - line.addLine(to: CGPoint(x: size.width - 30, y: y)) - context.stroke(line, with: .color(.red.opacity(0.55 * cut)), - style: StrokeStyle(lineWidth: 2, dash: dash)) - for fx in [0.20, 0.45, 0.70] { - let x = size.width * fx + let carlY = castLaneY(2, size: size) + let daveY = castLaneY(3, size: size) + let barrierY = (carlY + daveY) / 2 + let intensity = 1.0 - health + var path = Path() + path.move(to: CGPoint(x: 36, y: barrierY)) + path.addLine(to: CGPoint(x: size.width - 24, y: barrierY)) + let dashLen = CGFloat(8 + 6 * intensity) + let gapLen = CGFloat(4 + 4 * intensity) + context.stroke(path, + with: .color(.red.opacity(0.65 * intensity)), + style: StrokeStyle(lineWidth: 2.0 + 1.5 * intensity, + dash: [dashLen, gapLen])) + if intensity > 0.6 { + let badgeAlpha = (intensity - 0.6) / 0.4 + context.draw( + Text("⚠ DAVE PARTITIONED") + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(.red.opacity(0.95 * badgeAlpha)), + at: CGPoint(x: size.width / 2, y: barrierY - 14) + ) + } + } + + // MARK: - Thought bubble + + private func drawThoughtBubble( + in context: inout GraphicsContext, size: CGSize, + thought: Ch02WorldState.Thought + ) { + let pos = castPosition(cast: thought.cast, size: size) + let bubbleW: CGFloat = max(140, CGFloat(thought.label.count) * 7 + 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) + ) + } + + // MARK: - Composing slot + + private func drawComposingSlot( + in context: inout GraphicsContext, size: CGSize, + composing: Ch02WorldState.Composing + ) { + guard let msg = Ch02Timeline.messages[composing.messageId] else { return } + let authorPos = castPosition(cast: composing.author, size: size) + let boxW: CGFloat = min(540, size.width - 80) + let boxRect = CGRect(x: size.width / 2 - boxW / 2, y: 16, + width: boxW, height: 110) + let color = castColor(composing.author) + var connector = Path() + connector.move(to: CGPoint(x: boxRect.midX, y: boxRect.maxY)) + connector.addLine(to: CGPoint(x: authorPos.x, y: authorPos.y - 36)) + context.stroke(connector, + with: .color(color.opacity(0.45)), + style: StrokeStyle(lineWidth: 1.4, dash: [3, 4])) + context.fill(RoundedRectangle(cornerRadius: 10).path(in: boxRect), + with: .color(.black.opacity(0.88))) + 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 \(composing.messageId)") + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(color), + at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 14), + anchor: .leading + ) + context.draw( + Text("payload: \(msg.payload)") + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.88)), + at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 36), + anchor: .leading + ) + let parentsText = msg.parents.isEmpty ? "(genesis)" : msg.parents.joined(separator: ", ") + context.draw( + Text("parents: \(parentsText)") + .font(.system(size: settings.scaled(11), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.88)), + at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 54), + anchor: .leading + ) + if composing.sealed { + context.draw( + Text("hash: \(msg.hashShort)… ✓") + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(color.opacity(0.95)), + at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 72), + anchor: .leading + ) + } + } + + // MARK: - In-flight (success) + + private func drawInFlight( + in context: inout GraphicsContext, size: CGSize, + flight: Ch02WorldState.InFlight + ) { + let lift: CGFloat = 36 + let from = castPosition(cast: flight.from, size: size) + let to = castPosition(cast: flight.to, size: size) + let fromTrack = CGPoint(x: from.x, y: from.y - lift) + let toTrack = CGPoint(x: to.x, y: to.y - lift) + var path = Path() + path.move(to: fromTrack) + path.addLine(to: toTrack) + context.stroke(path, + with: .color(castColor(flight.from).opacity(0.22)), + style: StrokeStyle(lineWidth: 1.0, dash: [3, 5])) + let p = CGFloat(flight.progress) + let pos = CGPoint(x: fromTrack.x + (toTrack.x - fromTrack.x) * p, + y: fromTrack.y + (toTrack.y - fromTrack.y) * p) + let envW: CGFloat = 80 + let envH: CGFloat = 30 + let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2, + width: envW, height: envH) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(castColor(flight.from).opacity(0.95))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.7)), lineWidth: 1.0) + context.draw( + Text("\(flight.messageId) · \(hashOf(flight.messageId))") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: pos + ) + } + + // MARK: - Failed flight (envelope hits the partition barrier) + + private func drawFailedFlight( + in context: inout GraphicsContext, size: CGSize, + failed: Ch02WorldState.FailedFlight + ) { + let lift: CGFloat = 36 + let from = castPosition(cast: failed.from, size: size) + let to = castPosition(cast: failed.to, size: size) + let fromTrack = CGPoint(x: from.x, y: from.y - lift) + let toTrack = CGPoint(x: to.x, y: to.y - lift) + let raw = CGFloat(failed.progress) + let traveled = min(raw, 0.55) + let fade: Double = raw <= 0.55 ? 1.0 : Double(max(0, 1 - (raw - 0.55) / 0.45)) + let pos = CGPoint(x: fromTrack.x + (toTrack.x - fromTrack.x) * traveled, + y: fromTrack.y + (toTrack.y - fromTrack.y) * traveled) + + var path = Path() + path.move(to: fromTrack) + path.addLine(to: toTrack) + context.stroke(path, + with: .color(.red.opacity(0.18)), + style: StrokeStyle(lineWidth: 1.0, dash: [3, 5])) + + let envW: CGFloat = 80 + let envH: CGFloat = 30 + let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2, + width: envW, height: envH) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(castColor(failed.from).opacity(0.85 * fade))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.6 * fade)), lineWidth: 1.0) + context.draw( + Text("\(failed.messageId) · \(hashOf(failed.messageId))") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.white.opacity(fade)), + at: pos + ) + + if raw >= 0.55 { + let impactPos = CGPoint( + x: fromTrack.x + (toTrack.x - fromTrack.x) * 0.55, + y: fromTrack.y + (toTrack.y - fromTrack.y) * 0.55 + ) + context.draw( + Text("✗") + .font(.system(size: settings.scaled(24), weight: .heavy, design: .monospaced)) + .foregroundColor(.red.opacity(0.9)), + at: impactPos + ) + } + } + + // MARK: - Perception towers (5-block height to fit α/β/γ/δ/ε) + + private func drawPerceptionTowers( + in context: inout GraphicsContext, size: CGSize, world: Ch02WorldState + ) { + let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave] + let blockH: CGFloat = 22 + let blockGap: CGFloat = 3 + let maxBlocks = 5 + let towerH: CGFloat = CGFloat(maxBlocks) * (blockH + blockGap) + 28 + let baseY: CGFloat = size.height - 90 + let towerW: CGFloat = 110 + let totalW = CGFloat(casts.count) * towerW + CGFloat(casts.count - 1) * 24 + let startX = (size.width - totalW) / 2 + + for (i, cast) in casts.enumerated() { + let towerX = startX + CGFloat(i) * (towerW + 24) + let towerCenter = towerX + towerW / 2 + let color = castColor(cast) + + context.draw( + Text(cast.role.displayName.uppercased()) + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(color.opacity(0.85)), + at: CGPoint(x: towerCenter, y: baseY - towerH + 4) + ) + context.draw( + Text("VIEW") + .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.35)), + at: CGPoint(x: towerCenter, y: baseY - towerH + 18) + ) + var baseline = Path() + baseline.move(to: CGPoint(x: towerX, y: baseY)) + baseline.addLine(to: CGPoint(x: towerX + towerW, y: baseY)) + context.stroke(baseline, with: .color(color.opacity(0.45)), + lineWidth: 1.2) + for railX in [towerX, towerX + towerW] { + var rail = Path() + rail.move(to: CGPoint(x: railX, y: baseY)) + rail.addLine(to: CGPoint(x: railX, y: baseY - towerH + 26)) + context.stroke(rail, with: .color(color.opacity(0.18)), + style: StrokeStyle(lineWidth: 0.8, dash: [3, 4])) + } + + let order = world.viewOrder[cast] ?? [] + for (j, mid) in order.enumerated() { + let blockY = baseY - CGFloat(j + 1) * (blockH + blockGap) + let rect = CGRect(x: towerX + 6, y: blockY, + width: towerW - 12, height: blockH) + let blockColor = castColor(authorOf(mid)) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(blockColor.opacity(0.88))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.45)), lineWidth: 1.0) context.draw( - Text("✕") - .font(.system(size: settings.scaled(13), weight: .heavy)) - .foregroundColor(.red.opacity(0.7 * cut)), - at: CGPoint(x: x, y: y) + Text("\(mid) \(hashOf(mid))") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: CGPoint(x: rect.midX, y: rect.midY) ) } } - context.draw( - Text("DAVE — PARTITIONED") - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(.red.opacity(0.65 * cut)) - .kerning(1.5), - at: CGPoint(x: size.width / 2, y: (yTop + yBot) / 2) - ) } - // MARK: - Scene 0: Dave goes silent + // MARK: - Beat tag - private func renderDaveGoesSilent(context: inout GraphicsContext, size: CGSize, time: Double, snap: NodeSnapshot) { - let layout = laneLayout(snap: snap, size: size) - let minRound = snap.vertices.map { $0.round }.min() ?? 0 - let lanes = dm.castOrderedNodes() - - // Lane chrome — same as previous chapters - layout.drawNodeLanes(in: &context, nodes: lanes, canvasSize: size, dm: dm, textScale: settings.textScale) - layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: minRound, alpha: 0.25, textScale: settings.textScale) - - // Partition strength fades in over ~4s - let cut = min(1.0, time * 0.25) - - // Edges: any edge that crosses Dave's lane fades; same-side edges stay bright. - let dave = davePid - let pidByDigest = Dictionary(uniqueKeysWithValues: snap.vertices.map { ($0.digestHex, $0.processIdHex) }) - for edge in snap.edges { - guard let from = layout.positions[edge.from], - let to = layout.positions[edge.to] else { continue } - let touchesDave = pidByDigest[edge.from] == dave || pidByDigest[edge.to] == dave - let alpha = touchesDave ? max(0.05, 0.3 * (1 - cut)) : 0.3 - var path = Path() - path.move(to: from) - path.addLine(to: to) - context.stroke(path, with: .color(.white.opacity(alpha)), lineWidth: 1.0) - } - - // Vertices — Dave's dim as the cut tightens, others stay bright. - for vertex in snap.vertices { - guard let pos = layout.positions[vertex.digestHex] else { continue } - let isDave = vertex.processIdHex == dave - let appear = isDave ? max(0.2, 1.0 - cut * 0.7) : 1.0 - let baseColor = dm.castColor(for: vertex.processIdHex) - let r: CGFloat = 8 + CGFloat(min(vertex.weight, 10)) - 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(baseColor.opacity(0.85 * appear))) - if vertex.isLast { - context.stroke(Circle().path(in: rect.insetBy(dx: -2, dy: -2)), - with: .color(.white.opacity(0.5 * appear)), lineWidth: 1.5) - } - } - - // Faint band over Dave's lane + dashed cuts above and below it. - drawDaveIsolationBand(in: &context, size: size, cut: cut) - drawPartitionCuts(in: &context, size: size, cut: cut) - } - - // MARK: - Scene 1: world keeps building without him - - private func renderWorldBuildsOn(context: inout GraphicsContext, size: CGSize, time: Double, snap: NodeSnapshot) { - let layout = laneLayout(snap: snap, size: size) - let minRound = snap.vertices.map { $0.round }.min() ?? 0 - let lanes = dm.castOrderedNodes() - - layout.drawNodeLanes(in: &context, nodes: lanes, canvasSize: size, dm: dm, textScale: settings.textScale) - layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: minRound, alpha: 0.25, textScale: settings.textScale) - - // Aaron/Ben/Carl edges stay bright; Dave's edges (none reach him) faded. - let dave = davePid - let pidByDigest = Dictionary(uniqueKeysWithValues: snap.vertices.map { ($0.digestHex, $0.processIdHex) }) - for edge in snap.edges { - guard let from = layout.positions[edge.from], - let to = layout.positions[edge.to] else { continue } - let inMajority = pidByDigest[edge.from] != dave && pidByDigest[edge.to] != dave - let alpha = inMajority ? 0.32 : 0.06 - var path = Path() - path.move(to: from) - path.addLine(to: to) - context.stroke(path, with: .color(.white.opacity(alpha)), lineWidth: 1.0) - } - - // Vertices: majority bright; Dave's dim and visibly sparser. - for vertex in snap.vertices { - guard let pos = layout.positions[vertex.digestHex] else { continue } - let isDave = vertex.processIdHex == dave - let baseColor = dm.castColor(for: vertex.processIdHex) - let alpha = isDave ? 0.3 : 0.85 - let r: CGFloat = 8 + CGFloat(min(vertex.weight, 10)) - 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(baseColor.opacity(alpha))) - if vertex.isLast && !isDave { - context.stroke(Circle().path(in: rect.insetBy(dx: -2, dy: -2)), - with: .color(.white.opacity(0.5)), lineWidth: 1.5) - } - } - - // Persistent isolation band + cuts above and below Dave. - drawDaveIsolationBand(in: &context, size: size, cut: 1.0) - drawPartitionCuts(in: &context, size: size, cut: 1.0) - - // Counts: rich up top, sparse below. - let majorityCount = snap.vertices.filter { $0.processIdHex != dave }.count - let daveCount = snap.vertices.filter { $0.processIdHex == dave }.count - let pulse = 0.5 + 0.2 * sin(time * 1.2) - let yTop = partitionLineY(size: size) - let yBot = partitionLineYBottom(size: size) + private func drawBeatTag( + in context: inout GraphicsContext, size: CGSize, world: Ch02WorldState + ) { + guard let beatId = world.activeBeat?.id else { return } context.draw( - Text("AARON · BEN · CARL — \(majorityCount) VERTICES, GROWING") - .font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced)) - .foregroundColor(.cyan.opacity(0.55)), - at: CGPoint(x: size.width / 2, y: yTop - 14) - ) - context.draw( - Text("DAVE — \(daveCount) VERTICES, NONE REFERENCED · PEERS BELOW UNAFFECTED") - .font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced)) - .foregroundColor(.red.opacity(0.45 * pulse + 0.2)), - at: CGPoint(x: size.width / 2, y: yBot + 14) - ) - } - - // MARK: - Scene 2: two graphs, two stories - - /// Stay on the lane base; show that the two sides compute different round - /// boundaries by overlaying weight pills above and below the cut. - private func renderTwoStories(context: inout GraphicsContext, size: CGSize, time: Double, snap: NodeSnapshot) { - // Render scene 1's lane scene as the backdrop, then add divergence pills. - renderWorldBuildsOn(context: &context, size: size, time: time, snap: snap) - - let dave = davePid - let majorityVerts = snap.vertices.filter { $0.processIdHex != dave } - let daveVerts = snap.vertices.filter { $0.processIdHex == dave } - - let majWeight = majorityVerts.reduce(0) { $0 + $1.weight } - let daveWeight = daveVerts.reduce(0) { $0 + $1.weight } - - let pulse = 0.5 + 0.5 * sin(time * 2) - let pillW: CGFloat = 220 - let pillH: CGFloat = 28 - - let majPill = CGRect(x: size.width - pillW - 24, y: 60, width: pillW, height: pillH) - context.fill(RoundedRectangle(cornerRadius: 6).path(in: majPill), - with: .color(.black.opacity(0.55))) - context.stroke(RoundedRectangle(cornerRadius: 6).path(in: majPill), - with: .color(Cast.coral.opacity(0.7 * pulse)), lineWidth: 1.2) - context.draw( - Text("MAJORITY ROUND-WEIGHT: \(majWeight)") - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(.white.opacity(0.85)), - at: CGPoint(x: majPill.midX, y: majPill.midY) - ) - - let davePillY = size.height - 60 - pillH - let davePill = CGRect(x: size.width - pillW - 24, y: davePillY, width: pillW, height: pillH) - context.fill(RoundedRectangle(cornerRadius: 6).path(in: davePill), - with: .color(.black.opacity(0.55))) - context.stroke(RoundedRectangle(cornerRadius: 6).path(in: davePill), - with: .color(Cast.violet.opacity(0.7 * pulse)), lineWidth: 1.2) - context.draw( - Text("DAVE'S ROUND-WEIGHT: \(daveWeight)") - .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) - .foregroundColor(.white.opacity(0.85)), - at: CGPoint(x: davePill.midX, y: davePill.midY) - ) - - context.draw( - Text("DIFFERENT GRAPHS → DIFFERENT ROUND BOUNDARIES") - .font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced)) - .foregroundColor(.white.opacity(0.4)), - at: CGPoint(x: size.width / 2, y: size.height - 30) - ) - } - - // MARK: - Scene 3: Dave reconnects, stories reconcile - - private func renderReconnection(context: inout GraphicsContext, size: CGSize, time: Double) { - guard let snap = dm.honestData(step: fullStep) else { return } - let layout = laneLayout(snap: snap, size: size) - let minRound = snap.vertices.map { $0.round }.min() ?? 0 - let lanes = dm.castOrderedNodes() - - layout.drawNodeLanes(in: &context, nodes: lanes, canvasSize: size, dm: dm, textScale: settings.textScale) - layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: minRound, alpha: 0.25, textScale: settings.textScale) - layout.drawEdges(in: &context, edges: snap.edges, alpha: 0.32) - layout.drawVertices(in: &context, vertices: snap.vertices, nodes: lanes, dm: dm, - showLabels: true, animationTime: time, textScale: settings.textScale) - - // Partition line breaks open: dashes thin and fade. - let healed = min(1.0, time * 0.3) - let y = partitionLineY(size: size) - if healed < 0.95 { - var line = Path() - line.move(to: CGPoint(x: 50, y: y)) - line.addLine(to: CGPoint(x: size.width - 30, y: y)) - let dash: [CGFloat] = [max(2, 10 - 8 * healed), 6 + 18 * healed] - context.stroke(line, with: .color(.red.opacity(0.45 * (1 - healed))), - style: StrokeStyle(lineWidth: 2 * (1 - healed * 0.7), dash: dash)) - } - - // Gossip particles: green dots flowing across the cut. - let particleCount = Int(40 * healed) - for p in 0.. = [] + var views: [Ch01Cast: Set] = [:] + var viewOrder: [Ch01Cast: [String]] = [:] + /// Network status from Dave's perspective. 1 = healthy, 0 = fully + /// broken. Drives the "broken link" rendering between Dave's lane + /// and the rest. + var linkHealth: Double = 1.0 + /// Active animations (mutually exclusive on the timeline) + var inFlight: InFlight? = nil + var failedFlight: FailedFlight? = nil + var composing: Composing? = nil + var thought: Thought? = nil + var activeBeat: Ch02Beat? = nil + var activeProgress: Double = 0 + + struct InFlight { + let messageId: String + let from: Ch01Cast + let to: Ch01Cast + let progress: Double + } + struct FailedFlight { + let messageId: String + let from: Ch01Cast + let to: Ch01Cast + /// 0..1 along the path; the failure happens at ~0.55, where the + /// envelope hits the barrier. After that the envelope fades. + let progress: Double + } + struct Composing { + let messageId: String + let author: Ch01Cast + var sealed: Bool + } + struct Thought { + let cast: Ch01Cast + let label: String + } +} + +// MARK: - Timeline + +enum Ch02Timeline { + /// Two new messages introduced in this chapter. + static let messages: [String: Ch02Message] = [ + "δ": Ch02Message(id: "δ", author: .aaron, + payload: "step-4-aaron", + parents: ["γ"], hashShort: "be1c"), + "ε": Ch02Message(id: "ε", author: .dave, + payload: "step-5-dave", + parents: ["γ"], hashShort: "9a02"), + ] + + /// All five messages used during Ch02 rendering (α, β, γ from Ch01 + /// already in towers; δ, ε arrive in this chapter). + static let initialMessages: [String] = ["α", "β", "γ"] + + static let beats: [Ch02Beat] = { + let raw: [Ch02Beat] = [ + // Phase 1: carry-forward state from Ch01 + .init(id: "carry-forward", kind: .carryForward, + durationSeconds: 4.0, + narration: "Picking up from the gossip story: Aaron, Ben, Carl and Dave all hold the same three messages — α, β, γ. Their towers are aligned. The graph is stable."), + + // Phase 2: Dave's link cracks + .init(id: "link-degrade", kind: .linkDegrade, + durationSeconds: 5.0, + narration: "Now something changes. The network connection between Dave and the rest begins to fail. The dashed link to him cracks visibly."), + .init(id: "link-broken", kind: .linkBroken, + durationSeconds: 5.0, + narration: "The link breaks fully. Dave can no longer send to Aaron/Ben/Carl, and theirs can no longer reach him. He is partitioned."), + + // Phase 3: Aaron writes δ; reaches honest-3 but not Dave + .init(id: "aaron-thinks-delta", kind: .think(.aaron, label: "Time for δ."), + durationSeconds: 3.5, + narration: "Aaron decides to write a new message — δ. He has no way to know that Dave is partitioned; he just writes."), + .init(id: "aaron-compose-delta", kind: .compose(messageId: "δ"), + durationSeconds: 5.0, + narration: "Aaron composes δ. Payload: step-4-aaron. Parents: γ — the latest he saw. Then PoW."), + .init(id: "aaron-seal-delta", kind: .seal(messageId: "δ"), + durationSeconds: 3.0, + narration: "δ is sealed. Hash: be1c. Aaron's tower grows by one block."), + .init(id: "delta-flies-to-ben", kind: .fly(from: .aaron, to: .ben, messageId: "δ"), + durationSeconds: 6.0, + narration: "Aaron sends δ to Ben. The link to Ben is healthy — the envelope reaches him."), + .init(id: "ben-accepts-delta", kind: .acceptIntoView(at: .ben, messageId: "δ"), + durationSeconds: 3.0, + narration: "Ben verifies δ (parent γ resolves in his view, hash matches) and accepts. Ben's tower now includes δ."), + .init(id: "delta-flies-to-carl", kind: .fly(from: .aaron, to: .carl, messageId: "δ"), + durationSeconds: 6.0, + narration: "Aaron sends δ to Carl too. Link healthy — δ arrives."), + .init(id: "carl-accepts-delta", kind: .acceptIntoView(at: .carl, messageId: "δ"), + durationSeconds: 3.0, + narration: "Carl accepts δ. Three towers now hold {α, β, γ, δ}."), + .init(id: "delta-tries-dave", kind: .flyFailed(from: .aaron, to: .dave, messageId: "δ"), + durationSeconds: 5.5, + narration: "Aaron tries to send δ to Dave too — but the envelope hits the broken link. It cannot get through. Dave's tower is unchanged."), + + // Phase 4: Dave writes ε locally — only references what HE has + .init(id: "dave-thinks-eps", kind: .think(.dave, label: "I'll write something."), + durationSeconds: 4.0, + narration: "Meanwhile, Dave is unaware of δ. From his side of the partition, the world is still {α, β, γ}. He decides to write his own message — ε."), + .init(id: "dave-compose-eps", kind: .compose(messageId: "ε"), + durationSeconds: 5.0, + narration: "Dave composes ε. Parents: γ — the latest he saw. He doesn't know about δ; it isn't in his local view."), + .init(id: "dave-seal-eps", kind: .seal(messageId: "ε"), + durationSeconds: 3.0, + narration: "ε is sealed. Hash: 9a02. Dave's tower grows — but with a DIFFERENT next block than Aaron/Ben/Carl have."), + .init(id: "eps-tries-aaron", kind: .flyFailed(from: .dave, to: .aaron, messageId: "ε"), + durationSeconds: 5.5, + narration: "Dave tries to send ε out — also hits the broken link. ε stays trapped on Dave's side. Two stories now live on the canvas."), + .init(id: "divergence-settle", kind: .settle(label: "Two stories"), + durationSeconds: 5.0, + narration: "Look at the towers. Aaron, Ben and Carl share {α, β, γ, δ}. Dave has {α, β, γ, ε}. Both internally consistent — that's exactly the danger of partitions."), + + // Phase 5: heal + .init(id: "link-restored", kind: .linkRestored, + durationSeconds: 5.0, + narration: "The connection comes back. The barrier dissolves; the dashed link to Dave is restored."), + .init(id: "delta-finally-dave", kind: .fly(from: .aaron, to: .dave, messageId: "δ"), + durationSeconds: 6.0, + narration: "δ floods through the gap. Aaron's pending message reaches Dave at last."), + .init(id: "dave-accepts-delta", kind: .acceptIntoView(at: .dave, messageId: "δ"), + durationSeconds: 3.0, + narration: "Dave verifies δ (parent γ is in his view, hash matches) and accepts. His tower now contains {α, β, γ, ε, δ}. Notice ε comes BEFORE δ in his stack — that's his local history."), + .init(id: "eps-finally-aaron", kind: .fly(from: .dave, to: .aaron, messageId: "ε"), + durationSeconds: 6.0, + narration: "ε floods the other way. Dave's pending message reaches Aaron."), + .init(id: "aaron-accepts-eps", kind: .acceptIntoView(at: .aaron, messageId: "ε"), + durationSeconds: 3.0, + narration: "Aaron accepts ε. His tower: {α, β, γ, δ, ε}."), + .init(id: "eps-flies-ben", kind: .fly(from: .aaron, to: .ben, messageId: "ε"), + durationSeconds: 5.0, + narration: "Aaron forwards ε to Ben."), + .init(id: "ben-accepts-eps", kind: .acceptIntoView(at: .ben, messageId: "ε"), + durationSeconds: 3.0, + narration: "Ben accepts ε."), + .init(id: "eps-flies-carl", kind: .fly(from: .aaron, to: .carl, messageId: "ε"), + durationSeconds: 5.0, + narration: "Aaron forwards ε to Carl."), + .init(id: "carl-accepts-eps", kind: .acceptIntoView(at: .carl, messageId: "ε"), + durationSeconds: 3.0, + narration: "Carl accepts ε. Four towers now hold the same set {α, β, γ, δ, ε}, but in slightly different ORDERS — that's still local order, not yet total order."), + + .init(id: "convergence", kind: .settle(label: "Reunited"), + durationSeconds: 5.0, + narration: "The partition is over. The graph reunifies. Different validators recorded events in different orders during the split — but the SET of events is the same. Total order, in a later chapter, will give us the canonical sequence."), + ] + + var t: Double = 0 + var assigned: [Ch02Beat] = [] + 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 + } + + static func activeBeat(at t: Double) -> Ch02Beat? { + let clamped = max(0, min(t, totalDuration)) + return beats.first { $0.startTime <= clamped && clamped < $0.endTime } + ?? beats.last + } + + static func state(at t: Double) -> Ch02WorldState { + var w = Ch02WorldState() + // Carry-forward: every cast starts the chapter with {α, β, γ}. + for cast in Ch01Cast.allCases { + w.views[cast] = Set(initialMessages) + w.viewOrder[cast] = initialMessages + } + for mid in initialMessages { w.sealedMessages.insert(mid) } + + let clamped = max(0, min(t, totalDuration)) + for beat in beats { + if clamped < beat.startTime { break } + let isActive = clamped < beat.endTime + let progress = isActive + ? max(0, min(1, (clamped - beat.startTime) / beat.durationSeconds)) + : 1.0 + + apply(beat, progress: progress, isActive: isActive, into: &w) + + if isActive { + w.activeBeat = beat + w.activeProgress = progress + } + } + return w + } + + private static func apply( + _ beat: Ch02Beat, progress: Double, isActive: Bool, + into w: inout Ch02WorldState + ) { + switch beat.kind { + case .settle, .carryForward: + break + case .linkDegrade: + // Health goes 1 → 0.4 over the beat + let target = 0.4 + w.linkHealth = isActive ? (1 - (1 - target) * progress) : target + case .linkBroken: + w.linkHealth = 0.0 + case .linkRestored: + // Health goes 0 → 1 over the beat + w.linkHealth = isActive ? progress : 1.0 + case .think(let cast, let label): + if isActive { + w.thought = .init(cast: cast, label: label) + } + case .compose(let mid): + if w.composing?.messageId != mid, + let msg = messages[mid] { + w.composing = .init(messageId: mid, author: msg.author, sealed: false) + } + case .seal(let mid): + w.sealedMessages.insert(mid) + if let msg = messages[mid] { + w.views[msg.author, default: []].insert(mid) + if !w.viewOrder[msg.author, default: []].contains(mid) { + w.viewOrder[msg.author, default: []].append(mid) + } + } + if !isActive { w.composing = nil } + else if let msg = messages[mid] { + w.composing = .init(messageId: mid, author: msg.author, sealed: true) + } + case .fly(let from, let to, let mid): + if isActive { + w.inFlight = .init(messageId: mid, from: from, to: to, progress: progress) + } + case .flyFailed(let from, let to, let mid): + if isActive { + w.failedFlight = .init(messageId: mid, from: from, to: to, progress: progress) + } + case .acceptIntoView(let at, let mid): + w.views[at, default: []].insert(mid) + if !w.viewOrder[at, default: []].contains(mid) { + w.viewOrder[at, default: []].append(mid) + } + } + } +} + +// MARK: - Scene mapping + +enum Ch02Scenes { + /// 4 scenes, durations matched to the beat-group cumulative times. + /// Total Ch02 ≈ 115.5s at 1×. + static let sceneStarts: [Double] = [0, 14, 49, 71.5] + static let sceneDurations: [Double] = [14, 35, 22.5, 44] + + 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] + } + + static func narrationAt(sceneIndex: Int, localTime: Double) -> String { + let t = timelineT(sceneIndex: sceneIndex, localTime: localTime) + return Ch02Timeline.activeBeat(at: t)?.narration ?? "" + } +} diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index 423ff90..c03f79c 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -60,6 +60,11 @@ final class SceneEngine { SceneAddress(chapter: 1, scene: 4): 37.0, SceneAddress(chapter: 1, scene: 5): 37.5, SceneAddress(chapter: 1, scene: 6): 44.5, + // Ch02 — partition (4 scenes mapping to Ch02Timeline windows) + SceneAddress(chapter: 2, scene: 0): 14.0, + SceneAddress(chapter: 2, scene: 1): 35.0, + SceneAddress(chapter: 2, scene: 2): 22.5, + SceneAddress(chapter: 2, scene: 3): 44.0, ] /// Effective duration for the current scene, honoring overrides. diff --git a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift index 617bca4..0edd82c 100644 --- a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift +++ b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift @@ -160,6 +160,8 @@ struct ImmersiveView: View { return Ch00Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) case 1: return Ch01Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) + case 2: + return Ch02Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) default: return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene) }