Ch08 erasure shards: migrate to serial timeline + per-cast vaults

The closing chapter. Aaron splits ξ's body into 4 erasure-coded
shards (s1, s2, s3, s4) where any 2 reconstruct the body. Aaron's
vault visually morphs from "1 MB body grid" to "4 labeled shards
on a row". One shard each is then sent to Ben (s1), Carl (s2),
Dave (s3); s4 stays with Aaron.

Each non-Aaron cast gets a small vault next to their cast circle
that shows whichever shards they currently hold. Empty vaults read
"(empty)"; once shards arrive they appear as small filled chips
labeled s1/s2/s3.

Then Aaron goes offline (dimmed cast circle + red ⚠ OFFLINE label).
Carl asks Ben for s1; Ben sends; Carl now holds s1+s2 — k=2 reached.
A green halo pulses on Carl's vault as he runs the decoder, then a
"✓ ξ body reconstructed" caption pins under his vault and a big
banner reads "✓ ξ BODY RECONSTRUCTED — k=2 of 4 shards was enough
— Aaron NOT NEEDED".

Closes with a final-summary banner: "CRISIS COMPLETE · consensus +
DA + Byzantine resilience" and an outro inviting the viewer to
scrub.

`Ch08Timeline.swift` (new) — 19 beats over ~85s. Beat kinds:
`splitIntoShards`, `sendShard`, `stowShard`, `aaronOffline`,
`askForShard`, `shardArrives`, `reconstructBody`, `reconstructed`,
`finalSummary`.

`Ch09_DA_Design.swift` rewritten end-to-end to render from
Ch08Timeline. Aaron's vault adapts to "shards" mode after the
split beat. Per-cast vaults sit just to the right of each cast
circle (small, dashed, expand to fit chips). Reconstruction halo
pulses while running, then settles into a steady green border once
the body is back.

`SceneEngine` and `ImmersiveView` extended for chapter 8 — every
chapter (0–9) now has a timeline-backed renderer + beat-bound
narration.

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

All 10 chapters migrated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-07 22:30:29 +02:00
parent 32f01e3466
commit 5b4fe4ebaa
4 changed files with 520 additions and 572 deletions

View file

@ -1,6 +1,7 @@
import SwiftUI
/// Ch09: "Data Availability A Design" erasure coding, Merkle tree, on-demand retrieval, fee market, full stack.
/// Ch08 (chapter index 8, file Ch09_DA_Design.swift):
/// "Erasure shards make the data un-loseable." DA fix.
struct Ch09_DA_Design: View {
let sceneIndex: Int
let localTime: Double
@ -10,615 +11,323 @@ struct Ch09_DA_Design: View {
var body: some View {
Canvas { context, size in
render(context: &context, size: size, time: localTime)
let t = Ch08Scenes.timelineT(sceneIndex: sceneIndex,
localTime: localTime)
render(in: &context, size: size, t: t)
}
}
private func render(context: inout GraphicsContext, size: CGSize, time: Double) {
switch sceneIndex {
case 0: renderErasureCoding(context: &context, size: size, time: time)
case 1: renderMerkleTree(context: &context, size: size, time: time)
case 2: renderOnDemand(context: &context, size: size, time: time)
case 3: renderFeeMarket(context: &context, size: size, time: time)
case 4: renderFullStack(context: &context, size: size, time: time)
default: break
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
let world = Ch08Timeline.state(at: t)
drawLanes(in: &context, size: size)
drawCastFigures(in: &context, size: size, world: world)
drawAaronVault(in: &context, size: size, world: world)
drawCastVaults(in: &context, size: size, world: world, t: t)
if let flight = world.shardFlight {
drawShardFlight(in: &context, size: size, flight: flight)
}
if world.aaronOffline {
drawAaronOfflineBadge(in: &context, size: size)
}
if world.reconstructedAlpha > 0 {
drawReconstructedBadge(in: &context, size: size,
alpha: world.reconstructedAlpha)
}
if world.finalAlpha > 0 {
drawFinalSummary(in: &context, size: size,
alpha: world.finalAlpha)
}
drawBeatTag(in: &context, size: size, world: world)
}
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.18, 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: - Scene 0: Erasure Coding
private static let castLanes: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
private func renderErasureCoding(context: inout GraphicsContext, size: CGSize, time: Double) {
let cx = size.width / 2
let cy = size.height / 2
let k = 4
let n = 8
let splitProgress = min(1.0, time * 0.2)
let distributeProgress = min(1.0, max(0, time * 0.15 - 0.8))
private func drawLanes(in context: inout GraphicsContext, size: CGSize) {
for (cast, idx) in Self.castLanes {
let y = castLaneY(idx, size: size)
var path = Path()
path.move(to: CGPoint(x: 36, y: y))
path.addLine(to: CGPoint(x: size.width - 220, 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
)
}
}
// Original message block (top center)
let origW: CGFloat = 200
let origH: CGFloat = 60
let origRect = CGRect(x: cx - origW / 2, y: cy - 160, width: origW, height: origH)
let origAlpha = max(0, 1.0 - splitProgress * 0.5)
context.fill(RoundedRectangle(cornerRadius: 10).path(in: origRect),
with: .color(.white.opacity(0.15 * origAlpha)))
context.stroke(RoundedRectangle(cornerRadius: 10).path(in: origRect),
with: .color(.white.opacity(0.3 * origAlpha)), lineWidth: 1.5)
private func drawCastFigures(
in context: inout GraphicsContext, size: CGSize, world: Ch08WorldState
) {
for cast in Ch01Cast.allCases {
let pos = castPosition(cast: cast, size: size)
let r: CGFloat = 22
let color = castColor(cast)
let dim = (cast == .aaron && world.aaronOffline) ? 0.35 : 0.95
context.fill(
Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r,
width: r * 2, height: r * 2)),
with: .color(color.opacity(dim))
)
context.draw(
Text(String(cast.role.displayName.prefix(1)))
.font(.system(size: settings.scaled(15), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(dim)),
at: pos
)
}
}
/// Aaron's vault on the right showing ξ body + 4 shards once split.
private func drawAaronVault(
in context: inout GraphicsContext, size: CGSize, world: Ch08WorldState
) {
let vaultW: CGFloat = 180
let vaultH: CGFloat = 130
let vaultX = size.width - vaultW - 24
let vaultY = castLaneY(0, size: size) - vaultH / 2
let rect = CGRect(x: vaultX, y: vaultY, width: vaultW, height: vaultH)
context.fill(RoundedRectangle(cornerRadius: 8).path(in: rect),
with: .color(.black.opacity(0.6)))
let aaronOff = world.aaronOffline
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rect),
with: .color(Cast.coral.opacity(aaronOff ? 0.3 : 0.7)),
style: StrokeStyle(lineWidth: 1.2, dash: [4, 4]))
context.draw(
Text("MESSAGE PAYLOAD")
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.7 * origAlpha)),
at: CGPoint(x: origRect.midX, y: origRect.midY)
Text("AARON'S VAULT")
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(Cast.coral.opacity(aaronOff ? 0.4 : 0.9)),
at: CGPoint(x: rect.midX, y: rect.minY + 12)
)
// Chunks splitting out
let chunkW: CGFloat = 60
let chunkH: CGFloat = 40
let totalChunkW = CGFloat(n) * chunkW + CGFloat(n - 1) * 12
let chunksStartX = cx - totalChunkW / 2
let chunksY = cy - 30.0
let chunkColors: [Color] = (0..<n).map { i in
let hue = Double(i) / Double(n) * 0.75
let sat = i < k ? 0.7 : 0.35
return Color(hue: hue, saturation: sat, brightness: 0.85)
}
for i in 0..<n {
let targetX = chunksStartX + CGFloat(i) * (chunkW + 12)
let startX = cx - chunkW / 2
let startY = origRect.maxY
let x = startX + (targetX - startX) * splitProgress
let y = startY + (chunksY - startY) * splitProgress
let rect = CGRect(x: x, y: y, width: chunkW, height: chunkH)
context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect),
with: .color(chunkColors[i].opacity(0.7 * splitProgress)))
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
with: .color(chunkColors[i].opacity(0.4 * splitProgress)), lineWidth: 1)
let label = i < k ? "D\(i + 1)" : "P\(i - k + 1)"
let typeLabel = i < k ? "data" : "parity"
// Show 4 shards or full body.
if world.split {
// 4 shards stacked horizontally.
let aaronShards = world.shardsAt[.aaron] ?? []
let shardW: CGFloat = 32
let shardH: CGFloat = 56
let gap: CGFloat = 6
let totalW = CGFloat(Ch08Timeline.shardIds.count) * (shardW + gap) - gap
let startX = rect.midX - totalW / 2
let shardY = rect.minY + 30
for (i, sid) in Ch08Timeline.shardIds.enumerated() {
let x = startX + CGFloat(i) * (shardW + gap)
let chunk = CGRect(x: x, y: shardY, width: shardW, height: shardH)
let inAaron = aaronShards.contains(sid)
context.fill(RoundedRectangle(cornerRadius: 3).path(in: chunk),
with: .color(Cast.coral.opacity(inAaron ? 0.85
: (aaronOff ? 0.18 : 0.30))))
context.stroke(RoundedRectangle(cornerRadius: 3).path(in: chunk),
with: .color(.white.opacity(0.4)), lineWidth: 0.8)
context.draw(
Text(sid)
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(inAaron ? 0.95 : 0.45)),
at: CGPoint(x: chunk.midX, y: chunk.midY)
)
}
context.draw(
Text(label)
.font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.8 * splitProgress)),
at: CGPoint(x: rect.midX, y: rect.midY - 6)
Text("split: 4 shards · k=2 of 4")
.font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.55)),
at: CGPoint(x: rect.midX, y: rect.maxY - 12)
)
} else {
// Full body grid (4×6 chunks).
let chunksRows = 4
let chunksCols = 6
let chunkW: CGFloat = 18
let chunkH: CGFloat = 12
let gridX = rect.minX + (rect.width - CGFloat(chunksCols) * (chunkW + 2)) / 2
let gridY = rect.minY + 30
for row in 0..<chunksRows {
for col in 0..<chunksCols {
let x = gridX + CGFloat(col) * (chunkW + 2)
let y = gridY + CGFloat(row) * (chunkH + 2)
let chunkRect = CGRect(x: x, y: y, width: chunkW, height: chunkH)
context.fill(RoundedRectangle(cornerRadius: 2).path(in: chunkRect),
with: .color(Cast.coral.opacity(0.85)))
}
}
context.draw(
Text(typeLabel)
.font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.4 * splitProgress)),
at: CGPoint(x: rect.midX, y: rect.midY + 10)
Text("ξ body · 1 MB")
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.85)),
at: CGPoint(x: rect.midX, y: rect.maxY - 12)
)
}
}
// Distribution to storage nodes (phase 2)
if distributeProgress > 0 {
let storageY = cy + 100.0
let storageR: CGFloat = 20
for i in 0..<n {
let chunkX = chunksStartX + CGFloat(i) * (chunkW + 12) + chunkW / 2
let storageX = chunkX
let storePos = CGPoint(x: storageX, y: storageY + 50)
// Arrow from chunk to storage
var arrow = Path()
arrow.move(to: CGPoint(x: chunkX, y: chunksY + chunkH))
let arrowEndY = storageY + 50 - storageR
arrow.addLine(to: CGPoint(x: storageX, y: chunksY + chunkH + (arrowEndY - chunksY - chunkH) * distributeProgress))
context.stroke(arrow, with: .color(chunkColors[i].opacity(0.3 * distributeProgress)), lineWidth: 1)
// Storage node
if distributeProgress > 0.3 {
let nodeRect = CGRect(x: storePos.x - storageR, y: storePos.y - storageR,
width: storageR * 2, height: storageR * 2)
context.fill(RoundedRectangle(cornerRadius: 6).path(in: nodeRect),
with: .color(chunkColors[i].opacity(0.3 * distributeProgress)))
/// Each non-Aaron cast gets a small vault next to their cast circle
/// showing whichever shard(s) they currently hold.
private func drawCastVaults(
in context: inout GraphicsContext, size: CGSize,
world: Ch08WorldState, t: Double
) {
for (cast, idx) in Self.castLanes where cast != .aaron {
let lane = castLaneY(idx, size: size)
let castX = castPosition(cast: cast, size: size).x
let vaultX = castX + 50
let vaultW: CGFloat = 70
let vaultH: CGFloat = 36
let rect = CGRect(x: vaultX, y: lane - vaultH / 2,
width: vaultW, height: vaultH)
context.stroke(RoundedRectangle(cornerRadius: 4).path(in: rect),
with: .color(castColor(cast).opacity(0.4)),
style: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
let shards = (world.shardsAt[cast] ?? []).sorted()
if shards.isEmpty {
context.draw(
Text("(empty)")
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.30)),
at: CGPoint(x: rect.midX, y: rect.midY)
)
} else {
// Render shards as small filled chips.
let chipW: CGFloat = 18
let chipGap: CGFloat = 4
let totalW = CGFloat(shards.count) * (chipW + chipGap) - chipGap
let startX = rect.midX - totalW / 2
for (i, sid) in shards.enumerated() {
let x = startX + CGFloat(i) * (chipW + chipGap)
let chip = CGRect(x: x, y: rect.midY - 10,
width: chipW, height: 20)
context.fill(RoundedRectangle(cornerRadius: 3).path(in: chip),
with: .color(Cast.coral.opacity(0.85)))
context.draw(
Text("S\(i + 1)")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.6 * distributeProgress)),
at: storePos
Text(sid)
.font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: CGPoint(x: chip.midX, y: chip.midY)
)
}
// Reconstruction halo: pulsing if Carl just reconstructed.
let isReconstructing = world.reconstructFlash == cast
let hasFullBody = world.reconstructedAt.contains(cast)
|| (world.reconstructedAlpha > 0 && cast == .carl)
if isReconstructing || hasFullBody {
let pulse = isReconstructing ? 0.6 + 0.4 * sin(t * 4) : 0.9
context.stroke(RoundedRectangle(cornerRadius: 4).path(in: rect),
with: .color(.green.opacity(pulse)),
lineWidth: 2.0)
if hasFullBody {
context.draw(
Text("✓ ξ body reconstructed")
.font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.95)),
at: CGPoint(x: rect.midX, y: rect.maxY + 10)
)
}
}
}
}
}
// Labels
private func drawShardFlight(
in context: inout GraphicsContext, size: CGSize,
flight: Ch08WorldState.ShardFlight
) {
let lift: CGFloat = 30
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(Cast.coral.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 = 44
let envH: CGFloat = 22
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(Cast.coral.opacity(0.95)))
context.draw(
Text("ERASURE CODING: \(k)-of-\(n) REDUNDANCY")
.font(.system(size: settings.scaled(14), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.4)),
at: CGPoint(x: cx, y: 30)
)
context.draw(
Text("ANY \(k) CHUNKS SUFFICE TO RECONSTRUCT — NO FULL REPLICATION NEEDED")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.cyan.opacity(0.4)),
at: CGPoint(x: cx, y: size.height - 40)
Text(flight.id)
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: pos
)
}
// MARK: - Scene 1: Merkle Tree
private func renderMerkleTree(context: inout GraphicsContext, size: CGSize, time: Double) {
let cx = size.width / 2
let levels = 4
let nodeR: CGFloat = 16
let topY: CGFloat = 80
let levelSpacing: CGFloat = (size.height - 200) / CGFloat(levels)
// Highlighted proof path
let proofPath: [Int] = [0, 1, 2, 5] // indices along the tree path
func drawNode(level: Int, index: Int, parentPos: CGPoint?) {
let nodesAtLevel = 1 << level
let spacing = (size.width - 100) / CGFloat(nodesAtLevel)
let x = 50 + spacing * (CGFloat(index) + 0.5)
let y = topY + CGFloat(level) * levelSpacing
let pos = CGPoint(x: x, y: y)
let appear = min(1.0, max(0, time * 0.4 - Double(level) * 0.15))
if appear < 0.02 { return }
// Edge to parent
if let parent = parentPos {
var edge = Path()
edge.move(to: parent)
edge.addLine(to: pos)
context.stroke(edge, with: .color(.white.opacity(0.15 * appear)), lineWidth: 1)
}
let isLeaf = level == levels - 1
let isOnProofPath = proofPath.contains(level * 10 + index) || (level == 0 && index == 0)
let color: Color = isOnProofPath ? .yellow : (isLeaf ? .cyan : .purple)
let rect = CGRect(x: pos.x - nodeR, y: pos.y - nodeR, width: nodeR * 2, height: nodeR * 2)
context.fill(Circle().path(in: rect), with: .color(color.opacity(0.6 * appear)))
if isOnProofPath {
context.stroke(Circle().path(in: rect.insetBy(dx: -3, dy: -3)),
with: .color(.yellow.opacity(0.5 * appear)), lineWidth: 2)
}
// Hash label
let hashLabel = isLeaf ? "chunk\(index)" : "h(\(index))"
context.draw(
Text(hashLabel)
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.5 * appear)),
at: CGPoint(x: pos.x, y: pos.y + nodeR + 8)
)
if level < levels - 1 {
drawNode(level: level + 1, index: index * 2, parentPos: pos)
drawNode(level: level + 1, index: index * 2 + 1, parentPos: pos)
}
}
drawNode(level: 0, index: 0, parentPos: nil)
private func drawAaronOfflineBadge(
in context: inout GraphicsContext, size: CGSize
) {
let pos = castPosition(cast: .aaron, size: size)
context.draw(
Text("MERKLE TREE OF CHUNKS")
.font(.system(size: settings.scaled(14), weight: .heavy, design: .monospaced))
.foregroundColor(.purple.opacity(0.5)),
at: CGPoint(x: cx, y: 30)
)
context.draw(
Text("YELLOW PATH = PROOF — VERIFY ANY CHUNK WITHOUT DOWNLOADING ALL")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.yellow.opacity(0.4)),
at: CGPoint(x: cx, y: size.height - 40)
)
}
// MARK: - Scene 2: On-Demand Retrieval
private func renderOnDemand(context: inout GraphicsContext, size: CGSize, time: Double) {
let cx = size.width / 2
let cy = size.height / 2
// Requester Z (left)
let zPos = CGPoint(x: size.width * 0.15, y: cy)
let zR: CGFloat = 30
let zRect = CGRect(x: zPos.x - zR, y: zPos.y - zR, width: zR * 2, height: zR * 2)
context.fill(Circle().path(in: zRect), with: .color(.yellow.opacity(0.8)))
context.draw(Text("Z").font(.system(size: settings.scaled(16), weight: .heavy, design: .monospaced)).foregroundColor(.black), at: zPos)
context.draw(
Text("REQUESTER")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.yellow.opacity(0.5)),
at: CGPoint(x: zPos.x, y: zPos.y + zR + 14)
)
// Storage node S (right)
let sPos = CGPoint(x: size.width * 0.85, y: cy)
let sR: CGFloat = 30
let sRect = CGRect(x: sPos.x - sR, y: sPos.y - sR, width: sR * 2, height: sR * 2)
context.fill(Circle().path(in: sRect), with: .color(.blue.opacity(0.8)))
context.draw(Text("S").font(.system(size: settings.scaled(16), weight: .heavy, design: .monospaced)).foregroundColor(.white), at: sPos)
context.draw(
Text("STORAGE NODE")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.blue.opacity(0.5)),
at: CGPoint(x: sPos.x, y: sPos.y + sR + 14)
)
// PoW fee attached to request
let feeBox = CGRect(x: cx - 80, y: cy - 80, width: 160, height: 30)
context.fill(RoundedRectangle(cornerRadius: 6).path(in: feeBox),
with: .color(.yellow.opacity(0.1)))
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: feeBox),
with: .color(.yellow.opacity(0.3)), lineWidth: 1)
context.draw(
Text("REQUEST + FEE (anti-sybil)")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.yellow.opacity(0.6)),
at: CGPoint(x: feeBox.midX, y: feeBox.midY)
)
// Request arrow (Z S, top lane)
let reqProgress = min(1.0, time * 0.2)
let reqY = cy - 25.0
if reqProgress > 0 {
var arrow = Path()
arrow.move(to: CGPoint(x: zPos.x + zR, y: reqY))
arrow.addLine(to: CGPoint(x: zPos.x + zR + (sPos.x - zPos.x - 2 * sR) * reqProgress, y: reqY))
context.stroke(arrow, with: .color(.yellow.opacity(0.6)), lineWidth: 2.5)
// Animated packet
let packetX = zPos.x + zR + (sPos.x - zPos.x - 2 * sR) * ((time * 0.3).truncatingRemainder(dividingBy: 1.0))
let packetRect = CGRect(x: packetX - 6, y: reqY - 6, width: 12, height: 12)
context.fill(RoundedRectangle(cornerRadius: 3).path(in: packetRect),
with: .color(.yellow.opacity(0.7)))
}
// Response arrow (S Z, bottom lane)
let respProgress = min(1.0, max(0, time * 0.2 - 0.7))
let respY = cy + 25.0
if respProgress > 0 {
var arrow = Path()
arrow.move(to: CGPoint(x: sPos.x - sR, y: respY))
arrow.addLine(to: CGPoint(x: sPos.x - sR - (sPos.x - zPos.x - 2 * sR) * respProgress, y: respY))
context.stroke(arrow, with: .color(.blue.opacity(0.6)), lineWidth: 2.5)
// Response box
let respBox = CGRect(x: cx - 80, y: cy + 50, width: 160, height: 30)
context.fill(RoundedRectangle(cornerRadius: 6).path(in: respBox),
with: .color(.blue.opacity(0.1 * respProgress)))
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: respBox),
with: .color(.blue.opacity(0.3 * respProgress)), lineWidth: 1)
context.draw(
Text("CHUNK + MERKLE PROOF")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.blue.opacity(0.6 * respProgress)),
at: CGPoint(x: respBox.midX, y: respBox.midY)
)
}
context.draw(
Text("POINT-TO-POINT RETRIEVAL — NOT BROADCAST")
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.3)),
at: CGPoint(x: cx, y: 40)
)
context.draw(
Text("REQUESTING COSTS SOMETHING → SYBIL ATTACK BECOMES EXPENSIVE")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.green.opacity(0.4)),
at: CGPoint(x: cx, y: size.height - 40)
)
}
// MARK: - Scene 3: Fee Market
private func renderFeeMarket(context: inout GraphicsContext, size: CGSize, time: Double) {
let cx = size.width / 2
let graphW: CGFloat = min(550, size.width * 0.5)
let graphH: CGFloat = min(320, size.height * 0.42)
let graphCenterY = size.height * 0.38
let origin = CGPoint(x: cx - graphW / 2, y: graphCenterY + graphH / 2)
// Grid lines
for tick in stride(from: 0.2, through: 0.8, by: 0.2) {
var hGrid = Path()
hGrid.move(to: CGPoint(x: origin.x, y: origin.y - graphH * tick))
hGrid.addLine(to: CGPoint(x: origin.x + graphW, y: origin.y - graphH * tick))
context.stroke(hGrid, with: .color(.white.opacity(0.03)), lineWidth: 0.5)
var vGrid = Path()
vGrid.move(to: CGPoint(x: origin.x + graphW * tick, y: origin.y))
vGrid.addLine(to: CGPoint(x: origin.x + graphW * tick, y: origin.y - graphH))
context.stroke(vGrid, with: .color(.white.opacity(0.03)), lineWidth: 0.5)
}
// Axes
var xAxis = Path()
xAxis.move(to: origin)
xAxis.addLine(to: CGPoint(x: origin.x + graphW, y: origin.y))
context.stroke(xAxis, with: .color(.white.opacity(0.3)), lineWidth: 1.5)
var yAxis = Path()
yAxis.move(to: origin)
yAxis.addLine(to: CGPoint(x: origin.x, y: origin.y - graphH))
context.stroke(yAxis, with: .color(.white.opacity(0.3)), lineWidth: 1.5)
// Supply curve (upward, green) animated
let supplyAppear = min(1.0, time * 0.2)
if supplyAppear > 0 {
// Fill under supply curve
var supplyFill = Path()
supplyFill.move(to: origin)
let steps = 40
for s in 0...steps {
let t = Double(s) / Double(steps) * supplyAppear
let x = origin.x + graphW * t
let y = origin.y - graphH * 0.05 - graphH * 0.8 * t * t
supplyFill.addLine(to: CGPoint(x: x, y: y))
}
supplyFill.addLine(to: CGPoint(x: origin.x + graphW * supplyAppear, y: origin.y))
supplyFill.closeSubpath()
context.fill(supplyFill, with: .color(.green.opacity(0.04 * supplyAppear)))
var supply = Path()
for s in 0...steps {
let t = Double(s) / Double(steps) * supplyAppear
let x = origin.x + graphW * t
let y = origin.y - graphH * 0.05 - graphH * 0.8 * t * t
if s == 0 { supply.move(to: CGPoint(x: x, y: y)) }
else { supply.addLine(to: CGPoint(x: x, y: y)) }
}
context.stroke(supply, with: .color(.green.opacity(0.7)), lineWidth: 2.5)
context.draw(
Text("SUPPLY")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.green.opacity(0.6)),
at: CGPoint(x: origin.x + graphW * supplyAppear + 30, y: origin.y - graphH * 0.8 * supplyAppear * supplyAppear)
)
}
// Demand curve (downward, orange) animated
let demandAppear = min(1.0, max(0, time * 0.2 - 0.4))
if demandAppear > 0 {
// Fill under demand curve
var demandFill = Path()
demandFill.move(to: CGPoint(x: origin.x, y: origin.y - graphH * 0.9))
let steps = 40
for s in 0...steps {
let t = Double(s) / Double(steps) * demandAppear
let x = origin.x + graphW * t
let y = origin.y - graphH * 0.9 + graphH * 0.8 * t * t
demandFill.addLine(to: CGPoint(x: x, y: y))
}
demandFill.addLine(to: CGPoint(x: origin.x + graphW * demandAppear, y: origin.y))
demandFill.addLine(to: origin)
demandFill.closeSubpath()
context.fill(demandFill, with: .color(.orange.opacity(0.03 * demandAppear)))
var demand = Path()
for s in 0...steps {
let t = Double(s) / Double(steps) * demandAppear
let x = origin.x + graphW * t
let y = origin.y - graphH * 0.9 + graphH * 0.8 * t * t
if s == 0 { demand.move(to: CGPoint(x: x, y: y)) }
else { demand.addLine(to: CGPoint(x: x, y: y)) }
}
context.stroke(demand, with: .color(.orange.opacity(0.7)), lineWidth: 2.5)
context.draw(
Text("DEMAND")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.orange.opacity(0.6)),
at: CGPoint(x: origin.x + graphW * demandAppear + 30,
y: origin.y - graphH * 0.9 + graphH * 0.8 * demandAppear * demandAppear)
)
}
// Equilibrium point + shading
if supplyAppear > 0.7 && demandAppear > 0.7 {
let eqX = origin.x + graphW * 0.42
let eqY = origin.y - graphH * 0.42
let eqR: CGFloat = 8
let flash = 0.5 + 0.5 * sin(time * 3)
let eqRect = CGRect(x: eqX - eqR, y: eqY - eqR, width: eqR * 2, height: eqR * 2)
context.fill(Circle().path(in: eqRect), with: .color(.white.opacity(0.8 * flash)))
// Glow around equilibrium
for ring in 1...3 {
let ringR = eqR + CGFloat(ring) * 6
let ringRect = CGRect(x: eqX - ringR, y: eqY - ringR, width: ringR * 2, height: ringR * 2)
context.stroke(Circle().path(in: ringRect),
with: .color(.white.opacity(0.1 * flash / Double(ring))), lineWidth: 1)
}
// Dashed lines to axes
let dashStyle = StrokeStyle(lineWidth: 1, dash: [4, 4])
var hLine = Path()
hLine.move(to: CGPoint(x: origin.x, y: eqY))
hLine.addLine(to: CGPoint(x: eqX, y: eqY))
context.stroke(hLine, with: .color(.white.opacity(0.2)), style: dashStyle)
var vLine = Path()
vLine.move(to: CGPoint(x: eqX, y: origin.y))
vLine.addLine(to: CGPoint(x: eqX, y: eqY))
context.stroke(vLine, with: .color(.white.opacity(0.2)), style: dashStyle)
context.draw(
Text("P*")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.5)),
at: CGPoint(x: origin.x - 14, y: eqY)
)
context.draw(
Text("Q*")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.5)),
at: CGPoint(x: eqX, y: origin.y + 14)
)
// Equilibrium label
context.draw(
Text("EQUILIBRIUM PRICE")
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.4)),
at: CGPoint(x: eqX + 60, y: eqY - 14)
)
}
// Axis labels
context.draw(
Text("PRICE (fee per chunk)")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.35)),
at: CGPoint(x: origin.x - 10, y: origin.y - graphH / 2)
)
context.draw(
Text("QUANTITY (storage served)")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.35)),
at: CGPoint(x: origin.x + graphW / 2, y: origin.y + 30)
)
// Bottom: comparative data cards showing popular vs rare pricing
let cardsY = origin.y + 70
let cardW: CGFloat = 200
let cardH: CGFloat = 55
// Popular data card (left)
let popRect = CGRect(x: cx - cardW - 30, y: cardsY, width: cardW, height: cardH)
context.fill(RoundedRectangle(cornerRadius: 8).path(in: popRect),
with: .color(.green.opacity(0.06)))
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: popRect),
with: .color(.green.opacity(0.2)), lineWidth: 1)
context.draw(
Text("POPULAR DATA")
Text("⚠ OFFLINE")
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.6)),
at: CGPoint(x: popRect.midX, y: popRect.midY - 10)
)
context.draw(
Text("many providers → low fee")
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.green.opacity(0.35)),
at: CGPoint(x: popRect.midX, y: popRect.midY + 8)
)
// Rare data card (right)
let rareRect = CGRect(x: cx + 30, y: cardsY, width: cardW, height: cardH)
context.fill(RoundedRectangle(cornerRadius: 8).path(in: rareRect),
with: .color(.orange.opacity(0.06)))
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rareRect),
with: .color(.orange.opacity(0.2)), lineWidth: 1)
context.draw(
Text("RARE DATA")
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.orange.opacity(0.6)),
at: CGPoint(x: rareRect.midX, y: rareRect.midY - 10)
)
context.draw(
Text("few providers → premium fee")
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.orange.opacity(0.35)),
at: CGPoint(x: rareRect.midX, y: rareRect.midY + 8)
)
context.draw(
Text("INCENTIVIZED STORAGE — FEE MARKET DISCOVERY")
.font(.system(size: settings.scaled(14), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.3)),
at: CGPoint(x: cx, y: 30)
)
context.draw(
Text("STORAGE NODES EARN FEES — PRICE DISCOVERY EMERGES NATURALLY")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.orange.opacity(0.4)),
at: CGPoint(x: cx, y: size.height - 30)
.foregroundColor(.red.opacity(0.95)),
at: CGPoint(x: pos.x, y: pos.y - 38)
)
}
// MARK: - Scene 4: Full Stack
private func renderFullStack(context: inout GraphicsContext, size: CGSize, time: Double) {
let cx = size.width / 2
let cy = size.height / 2
let layerW: CGFloat = min(600, size.width * 0.6)
let layerH: CGFloat = 100
let gap: CGFloat = 50
let appear = min(1.0, time * 0.2)
// Top: Crisis consensus
let topRect = CGRect(x: cx - layerW / 2, y: cy - layerH - gap / 2, width: layerW, height: layerH)
context.fill(RoundedRectangle(cornerRadius: 14).path(in: topRect),
with: .color(.green.opacity(0.08 * appear)))
context.stroke(RoundedRectangle(cornerRadius: 14).path(in: topRect),
with: .color(.green.opacity(0.5 * appear)), lineWidth: 2)
private func drawReconstructedBadge(
in context: inout GraphicsContext, size: CGSize, alpha: Double
) {
context.draw(
Text("CRISIS CONSENSUS")
.font(.system(size: settings.scaled(18), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.7 * appear)),
at: CGPoint(x: topRect.midX, y: topRect.midY - 12)
Text("✓ ξ BODY RECONSTRUCTED — k=2 of 4 shards was enough — Aaron NOT NEEDED")
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
.foregroundColor(.green.opacity(0.95 * alpha)),
at: CGPoint(x: size.width / 2, y: size.height - 80)
)
}
private func drawFinalSummary(
in context: inout GraphicsContext, size: CGSize, alpha: Double
) {
context.draw(
Text("DAG · PoW · Virtual Voting · Total Order")
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.green.opacity(0.4 * appear)),
at: CGPoint(x: topRect.midX, y: topRect.midY + 12)
Text("CRISIS COMPLETE · consensus + DA + Byzantine resilience")
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
.foregroundColor(.yellow.opacity(0.95 * alpha)),
at: CGPoint(x: size.width / 2, y: size.height - 50)
)
}
// Bottom: DA Layer
let botRect = CGRect(x: cx - layerW / 2, y: cy + gap / 2, width: layerW, height: layerH)
context.fill(RoundedRectangle(cornerRadius: 14).path(in: botRect),
with: .color(.blue.opacity(0.08 * appear)))
context.stroke(RoundedRectangle(cornerRadius: 14).path(in: botRect),
with: .color(.blue.opacity(0.5 * appear)), lineWidth: 2)
private func drawBeatTag(
in context: inout GraphicsContext, size: CGSize, world: Ch08WorldState
) {
guard let beatId = world.activeBeat?.id else { return }
context.draw(
Text("DATA AVAILABILITY LAYER")
.font(.system(size: settings.scaled(18), weight: .heavy, design: .monospaced))
.foregroundColor(.blue.opacity(0.7 * appear)),
at: CGPoint(x: botRect.midX, y: botRect.midY - 12)
)
context.draw(
Text("Erasure Coding · Merkle Proofs · Fee Market")
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
.foregroundColor(.blue.opacity(0.4 * appear)),
at: CGPoint(x: botRect.midX, y: botRect.midY + 12)
)
// Connecting arrows
let arrowAppear = min(1.0, max(0, time * 0.2 - 0.5))
if arrowAppear > 0.05 {
for xOff in stride(from: -layerW * 0.3, through: layerW * 0.3, by: layerW * 0.3) {
var arrow = Path()
arrow.move(to: CGPoint(x: cx + xOff, y: topRect.maxY + 3))
arrow.addLine(to: CGPoint(x: cx + xOff, y: botRect.minY - 3))
context.stroke(arrow, with: .color(.white.opacity(0.3 * arrowAppear)), lineWidth: 1.5)
// Arrowhead
let aY = botRect.minY - 3
var head = Path()
head.move(to: CGPoint(x: cx + xOff, y: aY))
head.addLine(to: CGPoint(x: cx + xOff - 4, y: aY - 8))
head.addLine(to: CGPoint(x: cx + xOff + 4, y: aY - 8))
head.closeSubpath()
context.fill(head, with: .color(.white.opacity(0.3 * arrowAppear)))
}
context.draw(
Text("HASH COMMITMENTS")
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.35 * arrowAppear)),
at: CGPoint(x: cx, y: cy)
)
}
context.draw(
Text("NODES PAY FOR WHAT THEY NEED — TWO LAYERS, ONE PROTOCOL")
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.3 * appear)),
at: CGPoint(x: cx, y: size.height - 40)
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,231 @@
import SwiftUI
/// Ch08 "Erasure shards make the data un-loseable." (DA fix.)
///
/// Aaron splits ξ's body into 4 erasure-coded shards (s1, s2, s3, s4)
/// where any 2 are enough to reconstruct (k=2 of n=4). One shard each
/// flies to Ben, Carl, Dave; one stays with Aaron. Then Aaron goes
/// silent. Carl needs ξ's body, asks Ben for s1, combines with his
/// own s2 k=2 reached, body reconstructed. The DA problem is fixed.
enum Ch08BeatKind {
case settle(label: String)
case carryForward
case splitIntoShards // ξ body split into s1..s4
case sendShard(id: String, to: Ch01Cast)
case stowShard(at: Ch01Cast, id: String)
case aaronOffline
case askForShard(asker: Ch01Cast, target: Ch01Cast, id: String)
case shardArrives(at: Ch01Cast, id: String)
case reconstructBody(at: Ch01Cast) // collected k shards reform body
case reconstructed // green badge
case finalSummary // wrap-up
}
struct Ch08Beat: Identifiable {
let id: String
let kind: Ch08BeatKind
let durationSeconds: Double
let narration: String
var startTime: Double = 0
var endTime: Double { startTime + durationSeconds }
}
struct Ch08WorldState {
/// Whether ξ has been split into shards.
var split: Bool = false
/// Shards each cast holds (at most one per cast in this demo).
var shardsAt: [Ch01Cast: Set<String>] = [:]
/// In-flight shard envelope (success path).
var shardFlight: ShardFlight? = nil
/// Aaron has gone silent.
var aaronOffline: Bool = false
/// Reconstruction state: who has reconstructed ξ's body.
var reconstructedAt: Set<Ch01Cast> = []
var reconstructFlash: Ch01Cast? = nil
var reconstructProgress: Double = 0
var reconstructedAlpha: Double = 0
var finalAlpha: Double = 0
var activeBeat: Ch08Beat? = nil
var activeProgress: Double = 0
struct ShardFlight {
let id: String
let from: Ch01Cast
let to: Ch01Cast
let progress: Double
}
}
enum Ch08Timeline {
/// k=2 of n=4. Any 2 of {s1, s2, s3, s4} reconstructs ξ.
static let shardIds: [String] = ["s1", "s2", "s3", "s4"]
static let k: Int = 2
static let beats: [Ch08Beat] = {
let raw: [Ch08Beat] = [
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
narration: "Coming out of Ch07's DA problem: ξ's body lives only in Aaron's vault. Time for the fix — erasure coding."),
.init(id: "intro-ec", kind: .settle(label: "Erasure coding"),
durationSeconds: 5.0,
narration: "Aaron will split ξ's body into 4 shards using Reed-Solomon-like erasure coding. The trick: any 2 of the 4 shards is enough to reconstruct the whole body. So data survives even if half the storage nodes are offline."),
.init(id: "split", kind: .splitIntoShards, durationSeconds: 8.0,
narration: "ξ's body is sliced and erasure-encoded into s1, s2, s3, s4. Each shard is roughly half the size of the original body — but together, even 2 of them suffice."),
// Distribute one shard to each cast.
.init(id: "send-s1-ben", kind: .sendShard(id: "s1", to: .ben), durationSeconds: 4.5,
narration: "Aaron sends s1 to Ben."),
.init(id: "stow-s1-ben", kind: .stowShard(at: .ben, id: "s1"),
durationSeconds: 3.0,
narration: "Ben stows s1 in his vault."),
.init(id: "send-s2-carl", kind: .sendShard(id: "s2", to: .carl), durationSeconds: 4.5,
narration: "Aaron sends s2 to Carl."),
.init(id: "stow-s2-carl", kind: .stowShard(at: .carl, id: "s2"),
durationSeconds: 3.0,
narration: "Carl stows s2."),
.init(id: "send-s3-dave", kind: .sendShard(id: "s3", to: .dave), durationSeconds: 4.5,
narration: "Aaron sends s3 to Dave. Yes — even Dave the byzantine can be a storage node. Storing a shard is harmless; tampering with it would just be detected via the shard's commitment."),
.init(id: "stow-s3-dave", kind: .stowShard(at: .dave, id: "s3"),
durationSeconds: 3.0,
narration: "Dave stows s3."),
.init(id: "stow-s4-aaron", kind: .stowShard(at: .aaron, id: "s4"),
durationSeconds: 3.0,
narration: "s4 stays in Aaron's own vault. So now: each of the four nodes holds exactly one shard."),
.init(id: "distributed-settle", kind: .settle(label: "Distributed"),
durationSeconds: 4.5,
narration: "All four shards are out in the world. The body of ξ no longer lives in any single place. This is the structural shift."),
// Aaron goes silent.
.init(id: "aaron-offline", kind: .aaronOffline, durationSeconds: 5.0,
narration: "Now suppose Aaron goes offline. Or refuses to share. Or vanishes. In Ch07 this would have been game over. Watch what happens now."),
// Reconstruction: Carl needs ξ's body, has s2, asks Ben for s1.
.init(id: "ask-ben-s1", kind: .askForShard(asker: .carl, target: .ben, id: "s1"),
durationSeconds: 4.5,
narration: "Carl needs ξ's body. He already holds s2. He asks Ben for s1."),
.init(id: "ben-sends-s1", kind: .sendShard(id: "s1", to: .carl), durationSeconds: 4.5,
narration: "Ben has no reason to refuse — sharing a shard is a tiny operation. s1 flies to Carl."),
.init(id: "carl-receives-s1", kind: .shardArrives(at: .carl, id: "s1"),
durationSeconds: 3.0,
narration: "Carl now holds s1 and s2 — that's k=2 of 4 shards. The threshold is reached."),
.init(id: "reconstruct", kind: .reconstructBody(at: .carl),
durationSeconds: 6.0,
narration: "Carl runs the erasure-coding decoder on s1 + s2. The original body of ξ pops out — bit-for-bit identical to what Aaron originally wrote."),
.init(id: "reconstructed", kind: .reconstructed, durationSeconds: 5.0,
narration: "ξ's body is reconstructed in Carl's vault — even though Aaron is silent, even without ever talking to Dave. k-of-n is enough."),
.init(id: "final-summary", kind: .finalSummary, durationSeconds: 6.0,
narration: "And that closes the curriculum: consensus on the DAG, derived round numbers, virtual voting, leader election, total order, byzantine resilience under f<n/3, and now data availability via erasure coding. Crisis works. Thanks for watching."),
.init(id: "outro", kind: .settle(label: "End"),
durationSeconds: 4.0,
narration: "End of the curriculum. Pull the speed slider in either direction to scrub through any chapter as a movie editor would."),
]
var t: Double = 0
var assigned: [Ch08Beat] = []
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) -> Ch08Beat? {
let clamped = max(0, min(t, totalDuration))
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
?? beats.last
}
static func state(at t: Double) -> Ch08WorldState {
var w = Ch08WorldState()
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: Ch08Beat, progress: Double, isActive: Bool,
into w: inout Ch08WorldState
) {
switch beat.kind {
case .settle, .carryForward:
break
case .splitIntoShards:
w.split = true
case .sendShard(let id, let to):
if isActive {
// Determine sender from the beat id naming convention or
// the world state. For simplicity: Aaron is sender for
// initial distribution, Ben is sender when carl-asks-ben
// beat preceded.
let from: Ch01Cast = (w.shardsAt[.ben]?.contains(id) == true && to == .carl)
? .ben
: .aaron
w.shardFlight = .init(id: id, from: from, to: to, progress: progress)
}
case .stowShard(let at, let id):
w.shardsAt[at, default: []].insert(id)
case .aaronOffline:
w.aaronOffline = true
case .askForShard:
// Visual could draw an arrow; for now just narration drives.
break
case .shardArrives(let at, let id):
// Permanent: recipient now holds the shard.
w.shardsAt[at, default: []].insert(id)
case .reconstructBody(let at):
if isActive {
w.reconstructFlash = at
w.reconstructProgress = progress
} else {
w.reconstructedAt.insert(at)
}
case .reconstructed:
w.reconstructedAlpha = isActive ? progress : 1.0
case .finalSummary:
w.finalAlpha = isActive ? progress : 1.0
}
}
}
enum Ch08Scenes {
/// 5 scenes mapping to ~85s of timeline at 1×.
static let sceneStarts: [Double] = [0, 17, 47, 64, 75]
static let sceneDurations: [Double] = [17, 30, 17, 11, 10]
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 Ch08Timeline.activeBeat(at: t)?.narration ?? ""
}
}

View file

@ -85,6 +85,12 @@ final class SceneEngine {
SceneAddress(chapter: 7, scene: 1): 20.0,
SceneAddress(chapter: 7, scene: 2): 21.0,
SceneAddress(chapter: 7, scene: 3): 10.0,
// Ch08 erasure shards (5 scenes)
SceneAddress(chapter: 8, scene: 0): 17.0,
SceneAddress(chapter: 8, scene: 1): 30.0,
SceneAddress(chapter: 8, scene: 2): 17.0,
SceneAddress(chapter: 8, scene: 3): 11.0,
SceneAddress(chapter: 8, scene: 4): 10.0,
// Ch09 Byzantine (2 scenes mapping to Ch09Timeline windows)
SceneAddress(chapter: 9, scene: 0): 47.5,
SceneAddress(chapter: 9, scene: 1): 32.0,

View file

@ -172,6 +172,8 @@ struct ImmersiveView: View {
return Ch06Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 7:
return Ch07Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 8:
return Ch08Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 9:
return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
default: