Ch05 leader election: migrate to serial timeline

Per-round leader picked by heaviest-vertex (all w=1 here, so the
tiebreaker fires) plus lexicographic-hash. Visualization:
candidates get yellow rings, weights show as 'w=1 · {hash}' subscript,
the tiebreaker pops a comparator strip showing 43f3 < 5ce9 < 7638 <
be1c, the winner gets a gold crown ring + "♛ LEADER · r0" label on
every cast lane.

Round 0 leader: α. Round 1 leader: ε.

The chapter punches the determinism property: every honest
validator runs the same arithmetic on the same DAG and crowns the
same leaders without exchanging any messages. Determinism badge
fires at scene end.

`Ch05Timeline.swift` (new) — 10 beats over ~47.5s. Beat kinds:
`showCandidates`, `showWeights`, `tiebreakerCompare`, `crownLeader`,
`determinismBadge`.

`Ch06_Leader.swift` rewritten end-to-end (108 → ~340 lines) with
candidate / leader rings on lane vertices, comparator strip, crown
markers in towers (♛ prefix on leader blocks).

`SceneEngine` and `ImmersiveView` extended for chapter 5.

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 11:43:37 +02:00
parent 6222755f92
commit 598bee5b81
4 changed files with 497 additions and 71 deletions

View file

@ -1,6 +1,7 @@
import SwiftUI
/// Ch06: "Leader Election" candidates ranked by PoW weight, highest wins.
/// Ch05 (chapter index 5, file Ch06_Leader.swift):
/// "One vertex per round becomes the spokesperson."
struct Ch06_Leader: View {
let sceneIndex: Int
let localTime: Double
@ -8,101 +9,335 @@ struct Ch06_Leader: View {
let dm: DataManager
@Environment(AppSettings.self) private var settings
// Convergence happens at step 40 in the regenerated 80-step simulation.
// We pick a step shortly after so the elected leader is recorded and
// the candidate set is rich enough to make weight comparisons meaningful.
private let dataStep = 45
var body: some View {
Canvas { context, size in
render(context: &context, size: size, time: localTime)
let t = Ch05Scenes.timelineT(sceneIndex: sceneIndex,
localTime: localTime)
render(in: &context, size: size, t: t)
}
}
private func render(context: inout GraphicsContext, size: CGSize, time: Double) {
guard dm.sim != nil,
let snap = dm.honestData(step: dataStep) else { return }
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
let world = Ch05Timeline.state(at: t)
drawLanes(in: &context, size: size)
drawCastFigures(in: &context, size: size)
drawAcceptedVertices(in: &context, size: size, world: world, t: t)
if world.tiebreakerActive != nil {
drawTiebreaker(in: &context, size: size, world: world)
}
if world.determinismAlpha > 0 {
drawDeterminismBadge(in: &context, size: size,
alpha: world.determinismAlpha)
}
drawPerceptionTowers(in: &context, size: size, world: world)
drawBeatTag(in: &context, size: size, world: world)
}
let vertices = snap.vertices
let edges = snap.edges
// MARK: - Geometry
// Show the DAG in the top half
let dagSize = CGSize(width: size.width, height: size.height * 0.55)
let layout = DAGLayout.compute(vertices: vertices, edges: edges, nodes: dm.castOrderedNodes(),
canvasSize: dagSize, margin: 50)
let minRound = vertices.map { $0.round }.min() ?? 0
layout.drawRoundSeparators(in: &context, canvasSize: dagSize, minRound: minRound, alpha: 0.2, textScale: settings.textScale)
layout.drawEdges(in: &context, edges: edges, alpha: 0.3)
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
}
// Find candidates: isLast vertices in a specific round (e.g., round 3)
let targetRound = min(3, snap.maxRound)
let candidates = vertices.filter { $0.isLast && $0.round == targetRound }
.sorted { $0.weight > $1.weight }
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))
}
let candidateSet = Set(candidates.map { $0.digestHex })
let winner = candidates.first
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
}
}
layout.drawVertices(in: &context, vertices: vertices, nodes: dm.castOrderedNodes(), dm: dm,
showLabels: true, showWeight: true, highlightSet: candidateSet, textScale: settings.textScale)
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
}
// Winner crown
if let winner, let pos = layout.positions[winner.digestHex] {
let crown = 0.6 + 0.4 * sin(time * 3)
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 static let allMessages: [String] = ["α", "β", "γ", "δ", "ε"]
private static let castLanes: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
private func vertexPosition(cast: Ch01Cast, mid: String, size: CGSize) -> CGPoint? {
guard let i = Self.allMessages.firstIndex(of: mid) else { return nil }
let laneIdx: Int
switch cast {
case .aaron: laneIdx = 0
case .ben: laneIdx = 1
case .carl: laneIdx = 2
case .dave: laneIdx = 3
}
let lane = castLaneY(laneIdx, size: size)
let castX = castPosition(cast: cast, size: size).x
return CGPoint(x: castX + 70 + CGFloat(i) * 56, y: lane)
}
// MARK: - Lanes / cast
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 - 24, y: y))
context.stroke(path, with: .color(castColor(cast).opacity(0.18)),
style: StrokeStyle(lineWidth: 0.8, dash: [4, 6]))
context.draw(
Text("★ LEADER")
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
)
}
}
private func drawCastFigures(in context: inout GraphicsContext, size: CGSize) {
for cast in Ch01Cast.allCases {
let pos = castPosition(cast: cast, size: size)
let r: CGFloat = 26
let color = castColor(cast)
context.fill(
Circle().path(in: CGRect(x: pos.x - r * 1.5, y: pos.y - r * 1.5,
width: r * 3, height: r * 3)),
with: .color(color.opacity(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(18), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: pos
)
context.draw(
Text(cast.role.displayName.uppercased())
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.yellow.opacity(crown)),
at: CGPoint(x: pos.x, y: pos.y - 22)
.foregroundColor(color.opacity(0.95)),
at: CGPoint(x: pos.x, y: pos.y + r + 12)
)
}
}
// Bottom half: candidate ranking bar chart
let chartY = size.height * 0.6
let chartH = size.height * 0.3
let maxWeight = Double(candidates.map { $0.weight }.max() ?? 1)
let barSpacing = min(100.0, (size.width - 100) / CGFloat(max(candidates.count, 1)))
let chartStartX = (size.width - barSpacing * CGFloat(candidates.count)) / 2
// MARK: - Vertices with candidate rings + leader crown
for (i, candidate) in candidates.enumerated() {
let x = chartStartX + CGFloat(i) * barSpacing + barSpacing * 0.15
let barW = barSpacing * 0.7
let barH = chartH * (Double(candidate.weight) / maxWeight)
let barRect = CGRect(x: x, y: chartY + chartH - barH, width: barW, height: barH)
let isWinner = candidate.digestHex == winner?.digestHex
let color = dm.castColor(for: candidate.processIdHex)
context.fill(RoundedRectangle(cornerRadius: 4).path(in: barRect),
with: .color(color.opacity(isWinner ? 0.9 : 0.5)))
if isWinner {
context.stroke(RoundedRectangle(cornerRadius: 4).path(in: barRect.insetBy(dx: -2, dy: -2)),
with: .color(.yellow.opacity(0.7)), lineWidth: 2)
private func drawAcceptedVertices(
in context: inout GraphicsContext, size: CGSize,
world: Ch05WorldState, t: Double
) {
for (cast, _) in Self.castLanes {
for mid in Self.allMessages {
guard let pos = vertexPosition(cast: cast, mid: mid, size: size) else { continue }
drawVertex(in: &context, at: pos, messageId: mid,
cast: cast, world: world, t: t)
}
}
}
// Labels
context.draw(
Text("w=\(candidate.weight)")
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.7)),
at: CGPoint(x: x + barW / 2, y: chartY + chartH + 14)
private func drawVertex(
in context: inout GraphicsContext, at pos: CGPoint,
messageId: String, cast: Ch01Cast,
world: Ch05WorldState, t: Double
) {
let r: CGFloat = 13
let color = castColor(authorOf(messageId))
let round = Ch05Timeline.roundOf[messageId] ?? 0
if let cands = world.candidates[round], cands.contains(messageId) {
let candR: CGFloat = 19
context.stroke(
Circle().path(in: CGRect(x: pos.x - candR, y: pos.y - candR,
width: candR * 2, height: candR * 2)),
with: .color(.yellow.opacity(0.85)), lineWidth: 1.6
)
}
if world.leaders[round] == messageId {
let pulse = 0.85 + 0.15 * sin(t * 2.5)
let crownR: CGFloat = 24
context.stroke(
Circle().path(in: CGRect(x: pos.x - crownR, y: pos.y - crownR,
width: crownR * 2, height: crownR * 2)),
with: .color(.yellow.opacity(0.98 * pulse)), lineWidth: 3.0
)
context.draw(
Text(String(candidate.digestHex.prefix(6)))
.font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.4)),
at: CGPoint(x: x + barW / 2, y: chartY + chartH + 26)
Text("♛ LEADER · r\(round)")
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
.foregroundColor(.yellow.opacity(0.95)),
at: CGPoint(x: pos.x, y: pos.y - crownR - 10)
)
}
let subtitle: String = sceneIndex == 0
? "ROUND \(targetRound) CANDIDATES — RANKED BY POW WEIGHT"
: "HIGHEST WEIGHT WINS — UNPREDICTABLE HASH LOTTERY"
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.0)
context.draw(
Text(subtitle)
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
.foregroundColor(.yellow.opacity(0.4)),
at: CGPoint(x: size.width / 2, y: chartY - 14)
Text(messageId)
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: pos
)
if world.weightsVisible[round] == true {
context.draw(
Text("w=1 · \(hashOf(messageId))")
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
.foregroundColor(.white.opacity(0.55)),
at: CGPoint(x: pos.x, y: pos.y + r + 8)
)
}
}
// MARK: - Tiebreaker
private func drawTiebreaker(
in context: inout GraphicsContext, size: CGSize, world: Ch05WorldState
) {
guard let round = world.tiebreakerActive else { return }
let cands = world.candidates[round] ?? []
guard !cands.isEmpty else { return }
let lane = castLaneY(0, size: size) - 50
let sorted = cands.sorted { hashOf($0) < hashOf($1) }
let textW: CGFloat = 70
let totalW = CGFloat(sorted.count) * textW
let startX = size.width / 2 - totalW / 2
for (i, mid) in sorted.enumerated() {
let x = startX + CGFloat(i) * textW
let isWinner = i == 0
context.draw(
Text(hashOf(mid))
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(isWinner ? .yellow.opacity(0.95) : .white.opacity(0.7)),
at: CGPoint(x: x + textW / 2, y: lane)
)
if i < sorted.count - 1 {
context.draw(
Text("<")
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
.foregroundColor(.white.opacity(0.6)),
at: CGPoint(x: x + textW, y: lane)
)
}
}
context.draw(
Text("LEXICOGRAPHIC HASH COMPARE → smallest wins")
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.yellow.opacity(0.85)),
at: CGPoint(x: size.width / 2, y: lane - 18)
)
}
private func drawDeterminismBadge(
in context: inout GraphicsContext, size: CGSize, alpha: Double
) {
context.draw(
Text("DETERMINISTIC · same DAG → same leaders · no comms required")
.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 - 40)
)
}
// MARK: - Perception towers (with crown indicator on leader blocks)
private func drawPerceptionTowers(
in context: inout GraphicsContext, size: CGSize, world: Ch05WorldState
) {
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]))
}
for (j, mid) in Self.allMessages.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)))
let round = Ch05Timeline.roundOf[mid] ?? 0
let isLeader = world.leaders[round] == mid
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
with: .color(isLeader ? .yellow.opacity(0.95)
: .white.opacity(0.45)),
lineWidth: isLeader ? 2.2 : 1.0)
context.draw(
Text(isLeader ? "\(mid)" : mid)
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
.foregroundColor(.white),
at: CGPoint(x: rect.midX, y: rect.midY)
)
}
}
}
private func drawBeatTag(
in context: inout GraphicsContext, size: CGSize, world: Ch05WorldState
) {
guard let beatId = world.activeBeat?.id else { return }
context.draw(
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,186 @@
import SwiftUI
/// Ch05 "One vertex per round becomes the spokesperson." (Leader election.)
///
/// In each round, every validator's heaviest in-round vertex competes
/// for round leadership. The protocol picks the heaviest; ties are
/// broken by lexicographic hash. The result is a deterministic leader
/// per round: every honest player who has the same DAG picks the same
/// leader without exchanging any messages.
// MARK: - Types
enum Ch05BeatKind {
case settle(label: String)
case carryForward
/// Highlight the candidates in round `r` (yellow rings on each).
case showCandidates(round: Int, candidates: [String])
/// "Weight = 1" tally appears next to each candidate.
case showWeights(round: Int)
/// Animate a tiebreaker arrow comparing two candidates by hash.
case tiebreakerCompare(round: Int)
/// Crown the winner gold ring + "LEADER" label.
case crownLeader(round: Int, messageId: String)
/// "Determinism" emphasis: same DAG, same leaders, no comms.
case determinismBadge
}
struct Ch05Beat: Identifiable {
let id: String
let kind: Ch05BeatKind
let durationSeconds: Double
let narration: String
var startTime: Double = 0
var endTime: Double { startTime + durationSeconds }
}
struct Ch05WorldState {
/// Round-N candidates highlighted with yellow rings.
var candidates: [Int: [String]] = [:]
var weightsVisible: [Int: Bool] = [:]
/// Tiebreaker comparison flash.
var tiebreakerActive: Int? = nil
var tiebreakerProgress: Double = 0
/// Leader per round. Crown is permanent once set.
var leaders: [Int: String] = [:]
var determinismAlpha: Double = 0
var activeBeat: Ch05Beat? = nil
var activeProgress: Double = 0
}
// MARK: - Timeline
enum Ch05Timeline {
/// Round assignments come from Ch03's derivation: α/β/γ/δ are
/// round 0; ε is round 1.
static let roundOf: [String: Int] = [
"α": 0, "β": 0, "γ": 0, "δ": 0, "ε": 1
]
static let beats: [Ch05Beat] = {
let raw: [Ch05Beat] = [
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
narration: "Picking up after Ch04's strongly-seeing observation: every honest player has the same five messages and the same round assignments. Now we ask: who SPEAKS for each round? Crisis calls this the round leader."),
.init(id: "intro-leader", kind: .settle(label: "Round leader = round spokesperson"),
durationSeconds: 4.5,
narration: "In each round, every validator's heaviest in-round vertex competes for round leadership. Heaviest wins. Ties break by lexicographic hash. There is no ballot. There is no announcement. The leader is whichever vertex the arithmetic picks."),
.init(id: "candidates-round-0", kind: .showCandidates(round: 0, candidates: ["α", "β", "γ", "δ"]),
durationSeconds: 4.5,
narration: "Round 0 candidates: α, β, γ, δ. Yellow rings highlight each on every cast lane. They are the four messages assigned to round 0 by the weight thermometer in Ch03."),
.init(id: "weights-round-0", kind: .showWeights(round: 0),
durationSeconds: 5.0,
narration: "Weight tally: w=1 for every candidate. All four tied. We need a tiebreaker — Crisis uses lexicographic comparison of the hashes."),
.init(id: "tiebreak-0", kind: .tiebreakerCompare(round: 0),
durationSeconds: 6.0,
narration: "Compare hashes pairwise: 43f3 < 5ce9 < 7638 < be1c — α has the smallest hash. The tiebreaker resolves cleanly. Same comparison, same answer, on every honest player."),
.init(id: "crown-alpha", kind: .crownLeader(round: 0, messageId: "α"),
durationSeconds: 5.0,
narration: "α wins round 0. A gold crown ring + 'LEADER · r0' label appears on α on every lane. α is now the round-0 spokesperson — the message that represents that round in the chapters that follow."),
// Round 1
.init(id: "candidates-round-1", kind: .showCandidates(round: 1, candidates: ["ε"]),
durationSeconds: 4.0,
narration: "Round 1 candidates: just ε. With only one candidate, no tiebreaker is needed."),
.init(id: "crown-eps", kind: .crownLeader(round: 1, messageId: "ε"),
durationSeconds: 4.5,
narration: "ε wins round 1 unopposed. Gold crown + 'LEADER · r1' label. ε is now the round-1 spokesperson."),
.init(id: "determinism", kind: .determinismBadge,
durationSeconds: 6.0,
narration: "Determinism: every honest validator who holds the same five messages computes the same weight, runs the same tiebreaker, and crowns the same leaders. No vote was sent. No coordination occurred. Yet they all agree."),
.init(id: "outro", kind: .settle(label: "Leaders chosen"),
durationSeconds: 4.0,
narration: "Round 0 leader: α. Round 1 leader: ε. Next chapter — Ch06 — chains these round leaders into the total order that everyone needs."),
]
var t: Double = 0
var assigned: [Ch05Beat] = []
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) -> Ch05Beat? {
let clamped = max(0, min(t, totalDuration))
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
?? beats.last
}
static func state(at t: Double) -> Ch05WorldState {
var w = Ch05WorldState()
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: Ch05Beat, progress: Double, isActive: Bool,
into w: inout Ch05WorldState
) {
switch beat.kind {
case .settle, .carryForward:
break
case .showCandidates(let r, let cands):
w.candidates[r] = cands
case .showWeights(let r):
w.weightsVisible[r] = true
case .tiebreakerCompare(let r):
if isActive {
w.tiebreakerActive = r
w.tiebreakerProgress = progress
}
case .crownLeader(let r, let mid):
w.leaders[r] = mid
case .determinismBadge:
w.determinismAlpha = isActive ? progress : 1.0
}
}
}
// MARK: - Scene mapping
enum Ch05Scenes {
/// 2 scenes mapping to ~47.5s of timeline at 1×.
static let sceneStarts: [Double] = [0, 29]
static let sceneDurations: [Double] = [29, 18.5]
static func timelineT(sceneIndex: Int, localTime: Double) -> Double {
let idx = max(0, min(sceneIndex, sceneStarts.count - 1))
return sceneStarts[idx] + localTime
}
static func durationFor(scene: Int) -> Double {
let idx = max(0, min(scene, sceneDurations.count - 1))
return sceneDurations[idx]
}
static func narrationAt(sceneIndex: Int, localTime: Double) -> String {
let t = timelineT(sceneIndex: sceneIndex, localTime: localTime)
return Ch05Timeline.activeBeat(at: t)?.narration ?? ""
}
}

View file

@ -73,6 +73,9 @@ final class SceneEngine {
SceneAddress(chapter: 4, scene: 0): 16.0,
SceneAddress(chapter: 4, scene: 1): 23.5,
SceneAddress(chapter: 4, scene: 2): 23.0,
// Ch05 leader (2 scenes)
SceneAddress(chapter: 5, scene: 0): 29.0,
SceneAddress(chapter: 5, scene: 1): 18.5,
// Ch09 Byzantine (2 scenes mapping to Ch09Timeline windows)
SceneAddress(chapter: 9, scene: 0): 47.5,
SceneAddress(chapter: 9, scene: 1): 32.0,

View file

@ -166,6 +166,8 @@ struct ImmersiveView: View {
return Ch03Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 4:
return Ch04Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 5:
return Ch05Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
case 9:
return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
default: