Ch02 partition: migrate to serial timeline + perception towers

`Ch02Timeline.swift` (new) holds 27 beats over ~115s at 1×, mapped to
Ch02's 4 scenes:

  Scene 0 (14s) — carry-forward state from Ch01, then Dave's link
                  cracks (linkDegrade) and breaks (linkBroken).
  Scene 1 (35s) — Aaron writes δ, sends successfully to Ben and
                  Carl, fails to send to Dave (envelope hits the
                  partition barrier and dissolves with a red ✗).
  Scene 2 (22.5s) — Dave writes ε locally, references only γ
                    (he doesn't know about δ). His attempt to send
                    ε to Aaron also fails. Two stories now visible.
  Scene 3 (44s) — Link restored, missing messages flood through,
                  all four towers reunite at {α, β, γ, δ, ε} —
                  but Dave's stack ORDER is different (ε before δ
                  in his tower) because that's what he locally
                  observed during the split.

The chapter introduces two new beat kinds: `linkDegrade`/`linkBroken`/
`linkRestored` (drives a red dashed barrier between Carl's and Dave's
lanes that thickens with intensity) and `flyFailed` (the envelope
animates partway, hits the barrier, and fades with a big red ✗ at the
impact point).

`Ch03_Partition.swift` rewritten end-to-end (343 → ~470 lines) to
render from Ch02Timeline. Five-block-tall perception towers at the
bottom show all messages α through ε. The asymmetric Dave-stack vs
honest-3-stack is permanently visible at a glance.

`SceneEngine` gets duration overrides for Ch02's 4 scenes.
`ImmersiveView.liveNarration` extends to chapter 2.

Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs,
36/36 MP4 written.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-07 10:56:05 +02:00
parent 35470aadf0
commit 08f6e6ff8b
4 changed files with 827 additions and 303 deletions

View file

@ -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..<particleCount {
let seed = Double(p * 7919)
let lifecycle = ((time * 0.5 + seed * 0.01).truncatingRemainder(dividingBy: 1.0))
let direction: CGFloat = (Int(seed) % 2 == 0) ? -1 : 1
let px = size.width * 0.15 + (size.width * 0.7) * lifecycle
let py = y + direction * (10 + CGFloat((seed * 11).truncatingRemainder(dividingBy: 60)))
let alpha = (1 - lifecycle) * 0.7
let particleRect = CGRect(x: px - 3, y: py - 3, width: 6, height: 6)
context.fill(Circle().path(in: particleRect),
with: .color(.green.opacity(alpha)))
}
context.draw(
Text("RECONNECTED — \(snap.vertices.count) VERTICES MERGED")
.font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced))
.foregroundColor(.green.opacity(0.5 + 0.2 * healed)),
at: CGPoint(x: size.width / 2, y: size.height - 30)
Text(beatId)
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.20)),
at: CGPoint(x: size.width - 14, y: 10),
anchor: .trailing
)
}
}

View file

@ -0,0 +1,330 @@
import SwiftUI
/// Ch02 "Dave can't hear Aaron. The graph splits."
///
/// Picks up where Ch01 left off: all four cast on lanes, three messages
/// (α, β, γ) accepted into every honest player's view. Then Dave's link
/// to the gossip network drops, two new messages (δ from Aaron, ε from
/// Dave) get written, and the resulting divergence is visible
/// physically in the perception towers Aaron/Ben/Carl have one stack,
/// Dave has another. Then the partition heals and the towers reunite.
///
/// Same architectural pattern as Ch00/Ch01: serial beats, beat-bound
/// narration, pure `state(at: t)` function.
// MARK: - Types
struct Ch02Message: Hashable {
let id: String
let author: Ch01Cast
let payload: String
let parents: [String]
let hashShort: String
}
enum Ch02BeatKind {
case settle(label: String)
/// Carry-forward of the Ch01 state: Aaron/Ben/Carl/Dave all hold {α, β, γ}.
case carryForward
/// The link from Dave to the gossip network starts to crack
/// visualized as a degrading dashed line with a small .
case linkDegrade
/// Link fully broken solid red on the line, and Dave's lane
/// gets a "PARTITIONED" badge.
case linkBroken
case think(Ch01Cast, label: String)
case compose(messageId: String)
case seal(messageId: String)
/// Fly that succeeds: same animation as Ch01.
case fly(from: Ch01Cast, to: Ch01Cast, messageId: String)
/// Fly that fails the envelope animates partway, then hits a
/// barrier and dissolves. Used while Dave is partitioned.
case flyFailed(from: Ch01Cast, to: Ch01Cast, messageId: String)
case acceptIntoView(at: Ch01Cast, messageId: String)
/// The link is restored barrier dissolves, dashed line returns.
case linkRestored
}
struct Ch02Beat: Identifiable {
let id: String
let kind: Ch02BeatKind
let durationSeconds: Double
let narration: String
var startTime: Double = 0
var endTime: Double { startTime + durationSeconds }
}
struct Ch02WorldState {
var sealedMessages: Set<String> = []
var views: [Ch01Cast: Set<String>] = [:]
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 ?? ""
}
}

View file

@ -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.

View file

@ -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)
}