mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
Ch09 Byzantine: migrate to serial timeline + fork detection
Dave creates two messages with the same author + same parent set but
different bodies (ζ_a "send 50 BTC to Aaron", ζ_b "send 50 BTC to
Charlie"). Sends one to Aaron, the other to Ben. Aaron and Ben gossip;
the conflict is detected; Dave's vertices get banned with a red X;
honest 3 converge.
Beat structure:
Scene 0 (47.5s) — Dave thinks ("I'll send different things"), then
composes ζ_a (5s slo-mo with red-ring slot
chrome to flag this is a fork), seals, repeats
for ζ_b (same identity, different body). Sends
ζ_a only to Aaron, ζ_b only to Ben.
Scene 1 (32s) — Aaron forwards his copy of ζ_a to Ben → Ben already
has ζ_b → fork detected. Threshold bar appears
(f=1, n=4, 3f<n). Both Dave-vertices banned.
AARON·BEN·CARL CONVERGE badge.
`Ch09Timeline.swift` (new) — 18 beats over ~80s. Beat kinds
introduced: `forkCompose` / `forkSeal` / `forkSend` / `forkAccept`
(envelope acceptance into a recipient's view), `gossipExchange`
(Aaron forwarding to Ben that triggers detection), `forkDetected`,
`thresholdBar` (f<n/3 visualization), `banDave` (red X across
Dave's lane forks + dim them in towers).
`Ch10_Byzantine.swift` rewritten end-to-end (269 → ~525 lines) to
render from Ch09Timeline. Two red-ringed fork vertices appear on
Dave's lane past the carry-forward {α..ε}. Composing slot for forks
gets a red border to flag the deception. Towers track each player's
fork-acceptance: Aaron picks up ζ_a, Ben picks up ζ_b then ζ_a from
gossip, Carl never receives either, Dave's own view stays clean of
the lies he wrote. Once banned, the fork blocks dim and a red X
overlays them.
`SceneEngine` gets duration overrides for Ch09's 2 scenes.
`ImmersiveView.liveNarration` extends to chapter 9.
Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs,
36/36 MP4 written.
Known polish item: Dave's thought bubble lands at Carl's lane Y
because the bubble is offset −80pt from the cast circle. Lower-lane
casts need a "below-instead-of-above" placement variant. Tracked
for next pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b33a2cf4a5
commit
c9689ec7e5
4 changed files with 777 additions and 236 deletions
|
|
@ -1,18 +1,12 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch09 (file Ch10_Byzantine, user-facing chapter index 9): "Dave lies. Crisis catches him."
|
||||
/// Ch09 (chapter index 9, file Ch10_Byzantine.swift): "Dave lies. Crisis catches him."
|
||||
///
|
||||
/// Two beats from the narration:
|
||||
///
|
||||
/// - Scene 0 ("Dave forks his message."): on the persistent lane base,
|
||||
/// Dave's `isByzantineSource` vertices are highlighted with red rings,
|
||||
/// multi-parent fork lines, and contrasting payloads. The viewer SEES
|
||||
/// Dave producing two contradictory messages from the same lane.
|
||||
///
|
||||
/// - Scene 1 ("The protocol routes around him."): Aaron, Ben, Carl
|
||||
/// converge on a total order DESPITE Dave's forks. Dave's vertices
|
||||
/// are X'd out; the f<n/3 shield asserts the byzantine resilience
|
||||
/// guarantee. The threshold bar appears below.
|
||||
/// Renders from `Ch09Timeline`. Dave creates two conflicting messages
|
||||
/// under the same identity (ζ_a, ζ_b), sends one to Aaron and the
|
||||
/// other to Ben, and tries to make them disagree. Aaron and Ben
|
||||
/// gossip; the fork is detected; Dave's vertices are banned; the
|
||||
/// honest 3 converge anyway.
|
||||
struct Ch10_Byzantine: View {
|
||||
let sceneIndex: Int
|
||||
let localTime: Double
|
||||
|
|
@ -20,250 +14,518 @@ struct Ch10_Byzantine: View {
|
|||
let dm: DataManager
|
||||
@Environment(AppSettings.self) private var settings
|
||||
|
||||
private let dataStep = 60 // post-convergence
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
render(context: &context, size: size, time: localTime)
|
||||
let t = Ch09Scenes.timelineT(sceneIndex: sceneIndex,
|
||||
localTime: localTime)
|
||||
render(in: &context, size: size, t: t)
|
||||
}
|
||||
}
|
||||
|
||||
private func render(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
guard let sim = dm.sim,
|
||||
let snap = dm.honestData(step: dataStep) else { return }
|
||||
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
|
||||
let world = Ch09Timeline.state(at: t)
|
||||
|
||||
let lanes = dm.castOrderedNodes()
|
||||
let layout = DAGLayout.compute(
|
||||
vertices: snap.vertices, edges: snap.edges, nodes: lanes,
|
||||
canvasSize: CGSize(width: size.width, height: size.height * 0.65),
|
||||
margin: 50
|
||||
)
|
||||
drawLanes(in: &context, size: size)
|
||||
drawCastFigures(in: &context, size: size, t: t)
|
||||
drawAcceptedVertices(in: &context, size: size, world: world)
|
||||
drawDaveForks(in: &context, size: size, world: world, t: t)
|
||||
|
||||
let minRound = snap.vertices.map { $0.round }.min() ?? 0
|
||||
layout.drawNodeLanes(in: &context, nodes: lanes,
|
||||
canvasSize: CGSize(width: size.width, height: size.height * 0.65),
|
||||
dm: dm, textScale: settings.textScale)
|
||||
layout.drawRoundSeparators(
|
||||
in: &context,
|
||||
canvasSize: CGSize(width: size.width, height: size.height * 0.65),
|
||||
minRound: minRound, alpha: 0.20, textScale: settings.textScale
|
||||
)
|
||||
|
||||
// ─── Identify Dave and his byzantine vertices ────────────────────
|
||||
let davePid = dm.castByPid.first(where: { $0.value.id == Cast.dave.id })?.key
|
||||
let daveVertices = davePid.map { pid in
|
||||
snap.vertices.filter { $0.processIdHex == pid }
|
||||
} ?? []
|
||||
let forkedVertices = daveVertices.filter { $0.isByzantineSource }
|
||||
|
||||
// Edges: dim everything; brighten edges that touch a forked vertex.
|
||||
let forkedSet = Set(forkedVertices.map(\.digestHex))
|
||||
for edge in snap.edges {
|
||||
guard let from = layout.positions[edge.from],
|
||||
let to = layout.positions[edge.to] else { continue }
|
||||
let touchesFork = forkedSet.contains(edge.from) || forkedSet.contains(edge.to)
|
||||
let alpha = touchesFork ? 0.55 : 0.18
|
||||
var path = Path()
|
||||
path.move(to: from)
|
||||
path.addLine(to: to)
|
||||
context.stroke(path,
|
||||
with: .color((touchesFork ? Color.red : Color.white).opacity(alpha)),
|
||||
lineWidth: touchesFork ? 1.6 : 0.9)
|
||||
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 {
|
||||
drawForkFlight(in: &context, size: size, flight: flight)
|
||||
}
|
||||
if world.forkDetectedAlpha > 0 {
|
||||
drawForkDetected(in: &context, size: size,
|
||||
alpha: world.forkDetectedAlpha)
|
||||
}
|
||||
if world.thresholdBarAlpha > 0 {
|
||||
drawThresholdBar(in: &context, size: size,
|
||||
alpha: world.thresholdBarAlpha)
|
||||
}
|
||||
if world.convergedAlpha > 0 {
|
||||
drawConvergedBadge(in: &context, size: size,
|
||||
alpha: world.convergedAlpha)
|
||||
}
|
||||
|
||||
// Vertices.
|
||||
for vertex in snap.vertices {
|
||||
guard let pos = layout.positions[vertex.digestHex] else { continue }
|
||||
let role = dm.castRole(for: vertex.processIdHex)
|
||||
let isForked = forkedSet.contains(vertex.digestHex)
|
||||
drawPerceptionTowers(in: &context, size: size, world: world)
|
||||
drawBeatTag(in: &context, size: size, world: world)
|
||||
}
|
||||
|
||||
let r: CGFloat = 7 + CGFloat(min(vertex.weight, 8)) * 0.5
|
||||
let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2)
|
||||
let baseColor = isForked ? Color.red : role.color
|
||||
let alpha: Double = sceneIndex == 1 && isForked ? 0.4 : 0.85
|
||||
context.fill(Circle().path(in: rect), with: .color(baseColor.opacity(alpha)))
|
||||
// MARK: - Geometry / lookups
|
||||
|
||||
if isForked {
|
||||
// Pulsing red halo around forked vertices.
|
||||
let pulse = 0.5 + 0.5 * sin(time * 3 + Double(vertex.weight))
|
||||
let haloR = r * 1.9 * pulse
|
||||
let haloRect = CGRect(x: pos.x - haloR, y: pos.y - haloR,
|
||||
width: haloR * 2, height: haloR * 2)
|
||||
context.stroke(Circle().path(in: haloRect),
|
||||
with: .color(.red.opacity(0.4 * pulse)), lineWidth: 1.5)
|
||||
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
|
||||
}
|
||||
|
||||
// Scene 1: X out the forked vertex (banned).
|
||||
if sceneIndex == 1 {
|
||||
let banAppear = min(1, time / 1.5)
|
||||
let xLen: CGFloat = r * 1.3 * CGFloat(banAppear)
|
||||
var x1 = Path()
|
||||
x1.move(to: CGPoint(x: pos.x - xLen, y: pos.y - xLen))
|
||||
x1.addLine(to: CGPoint(x: pos.x + xLen, y: pos.y + xLen))
|
||||
var x2 = Path()
|
||||
x2.move(to: CGPoint(x: pos.x + xLen, y: pos.y - xLen))
|
||||
x2.addLine(to: CGPoint(x: pos.x - xLen, y: pos.y + xLen))
|
||||
context.stroke(x1, with: .color(.red.opacity(0.95)), lineWidth: 2.5)
|
||||
context.stroke(x2, with: .color(.red.opacity(0.95)), lineWidth: 2.5)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func authorOf(_ mid: String) -> Ch01Cast {
|
||||
if mid.hasPrefix("ζ") { return .dave }
|
||||
if let m = Ch01Timeline.messages[mid] { return m.author }
|
||||
if let m = Ch02Timeline.messages[mid] { return m.author }
|
||||
return .aaron
|
||||
}
|
||||
|
||||
private func hashOf(_ mid: String) -> String {
|
||||
if let info = Ch09Timeline.forkVersions[mid] { return info.hashShort }
|
||||
if let m = Ch01Timeline.messages[mid] { return m.hashShort }
|
||||
if let m = Ch02Timeline.messages[mid] { return m.hashShort }
|
||||
return "????"
|
||||
}
|
||||
|
||||
private static let initialMessages: [String] = ["α", "β", "γ", "δ", "ε"]
|
||||
|
||||
// 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, t: Double
|
||||
) {
|
||||
for cast in Ch01Cast.allCases {
|
||||
let pos = castPosition(cast: cast, size: size)
|
||||
let r: CGFloat = 26
|
||||
let color = castColor(cast)
|
||||
let haloR = r * 1.5
|
||||
context.fill(
|
||||
Circle().path(in: CGRect(x: pos.x - haloR, y: pos.y - haloR,
|
||||
width: haloR * 2, height: haloR * 2)),
|
||||
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(color.opacity(0.95)),
|
||||
at: CGPoint(x: pos.x, y: pos.y + r + 12)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accepted (carry-forward) vertices on each lane
|
||||
|
||||
private func drawAcceptedVertices(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch09WorldState
|
||||
) {
|
||||
let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
|
||||
for (cast, laneIdx) in casts {
|
||||
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 Self.initialMessages.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 = 13
|
||||
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.0)
|
||||
context.draw(
|
||||
Text(messageId)
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: pos
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Dave's forks
|
||||
|
||||
private func drawDaveForks(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
world: Ch09WorldState, t: Double
|
||||
) {
|
||||
let lane = castLaneY(3, size: size)
|
||||
let castX = castPosition(cast: .dave, size: size).x
|
||||
// Forks sit at the right end of Dave's accepted-vertex row,
|
||||
// visually past ε.
|
||||
let baseX = castX + 70 + CGFloat(Self.initialMessages.count) * 56
|
||||
let forkGap: CGFloat = 56
|
||||
|
||||
for (i, vid) in world.forksOnDaveLane.enumerated() {
|
||||
let pos = CGPoint(x: baseX + CGFloat(i) * forkGap, y: lane)
|
||||
let r: CGFloat = 16
|
||||
let pulse: CGFloat = world.daveBanned ? 1.0 : 1.0 + 0.05 * CGFloat(sin(t * 4))
|
||||
let rr = r * pulse
|
||||
|
||||
// Outer red fork ring
|
||||
let ringR: CGFloat = rr + 4
|
||||
context.stroke(
|
||||
Circle().path(in: CGRect(x: pos.x - ringR, y: pos.y - ringR,
|
||||
width: ringR * 2, height: ringR * 2)),
|
||||
with: .color(.red.opacity(0.85)), lineWidth: 2.4
|
||||
)
|
||||
// Inner Dave-violet fill
|
||||
context.fill(
|
||||
Circle().path(in: CGRect(x: pos.x - rr, y: pos.y - rr,
|
||||
width: rr * 2, height: rr * 2)),
|
||||
with: .color(Cast.violet.opacity(world.daveBanned ? 0.45 : 0.95))
|
||||
)
|
||||
context.stroke(
|
||||
Circle().path(in: CGRect(x: pos.x - rr, y: pos.y - rr,
|
||||
width: rr * 2, height: rr * 2)),
|
||||
with: .color(.white.opacity(0.55)), lineWidth: 1.2
|
||||
)
|
||||
context.draw(
|
||||
Text(vid)
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(world.daveBanned ? 0.6 : 1.0)),
|
||||
at: pos
|
||||
)
|
||||
context.draw(
|
||||
Text(hashOf(vid))
|
||||
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.5)),
|
||||
at: CGPoint(x: pos.x, y: pos.y + rr + 8)
|
||||
)
|
||||
// Big red X if Dave is banned
|
||||
if world.daveBanned {
|
||||
let xr: CGFloat = ringR + 2
|
||||
var xPath = Path()
|
||||
xPath.move(to: CGPoint(x: pos.x - xr, y: pos.y - xr))
|
||||
xPath.addLine(to: CGPoint(x: pos.x + xr, y: pos.y + xr))
|
||||
xPath.move(to: CGPoint(x: pos.x - xr, y: pos.y + xr))
|
||||
xPath.addLine(to: CGPoint(x: pos.x + xr, y: pos.y - xr))
|
||||
context.stroke(xPath,
|
||||
with: .color(.red.opacity(0.95)), lineWidth: 3.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thought / composing / flight
|
||||
|
||||
private func drawThoughtBubble(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
thought: Ch09WorldState.Ch09Thought
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawComposingSlot(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
composing: Ch09WorldState.Ch09Composing
|
||||
) {
|
||||
guard let info = Ch09Timeline.forkVersions[composing.versionId] else { return }
|
||||
let authorPos = castPosition(cast: .dave, 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)
|
||||
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(Cast.violet.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)))
|
||||
// Red ring on the box to flag this is a fork
|
||||
context.stroke(RoundedRectangle(cornerRadius: 10).path(in: boxRect),
|
||||
with: .color(.red.opacity(0.95)), lineWidth: 1.8)
|
||||
context.draw(
|
||||
Text("✎ DAVE WRITING \(info.label) (FORK)")
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.red.opacity(0.95)),
|
||||
at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 14),
|
||||
anchor: .leading
|
||||
)
|
||||
context.draw(
|
||||
Text(info.claim)
|
||||
.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
|
||||
)
|
||||
context.draw(
|
||||
Text("parents: ε")
|
||||
.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: \(info.hashShort)… ✓")
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(Cast.violet.opacity(0.95)),
|
||||
at: CGPoint(x: boxRect.minX + 14, y: boxRect.minY + 72),
|
||||
anchor: .leading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawForkFlight(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
flight: Ch09WorldState.Ch09Flight
|
||||
) {
|
||||
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(.red.opacity(0.25)),
|
||||
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(Cast.violet.opacity(0.95)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(.red.opacity(0.85)), lineWidth: 1.4)
|
||||
context.draw(
|
||||
Text("\(flight.versionId) · \(hashOf(flight.versionId))")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: pos
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Fork detected / threshold / converged
|
||||
|
||||
private func drawForkDetected(
|
||||
in context: inout GraphicsContext, size: CGSize, alpha: Double
|
||||
) {
|
||||
let cy: CGFloat = 60
|
||||
let label = "⚠ FORK DETECTED — same Dave identity, two different bodies"
|
||||
context.draw(
|
||||
Text(label)
|
||||
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.red.opacity(0.95 * alpha)),
|
||||
at: CGPoint(x: size.width / 2, y: cy)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawThresholdBar(
|
||||
in context: inout GraphicsContext, size: CGSize, alpha: Double
|
||||
) {
|
||||
// f<n/3 visual: a small bar showing 1 byzantine / 4 total.
|
||||
let cy: CGFloat = 92
|
||||
let barW: CGFloat = 280
|
||||
let barH: CGFloat = 18
|
||||
let barX = size.width / 2 - barW / 2
|
||||
let rect = CGRect(x: barX, y: cy - barH / 2, width: barW, height: barH)
|
||||
context.stroke(RoundedRectangle(cornerRadius: 4).path(in: rect),
|
||||
with: .color(.white.opacity(0.45 * alpha)), lineWidth: 1.0)
|
||||
// Threshold: 1/3 of bar marked at 33% line
|
||||
let threshFrac = CGFloat(1.0 / 3.0)
|
||||
let threshX = barX + threshFrac * barW
|
||||
var threshLine = Path()
|
||||
threshLine.move(to: CGPoint(x: threshX, y: rect.minY - 4))
|
||||
threshLine.addLine(to: CGPoint(x: threshX, y: rect.maxY + 4))
|
||||
context.stroke(threshLine,
|
||||
with: .color(.yellow.opacity(0.85 * alpha)),
|
||||
style: StrokeStyle(lineWidth: 1.4, dash: [3, 3]))
|
||||
// Filled portion: 1/4 = 25% (one byzantine of four)
|
||||
let fillFrac = CGFloat(1.0 / 4.0)
|
||||
let fillRect = CGRect(x: barX, y: rect.minY,
|
||||
width: fillFrac * barW, height: barH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 4).path(in: fillRect),
|
||||
with: .color(.green.opacity(0.7 * alpha)))
|
||||
context.draw(
|
||||
Text("f = 1, n = 4 · 3f = 3 < n = 4 · safety holds")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.9 * alpha)),
|
||||
at: CGPoint(x: rect.midX, y: rect.maxY + 14)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawConvergedBadge(
|
||||
in context: inout GraphicsContext, size: CGSize, alpha: Double
|
||||
) {
|
||||
context.draw(
|
||||
Text("✓ AARON · BEN · CARL CONVERGE — Dave's weight wasted")
|
||||
.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 - 50)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Perception towers
|
||||
|
||||
private func drawPerceptionTowers(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch09WorldState
|
||||
) {
|
||||
let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave]
|
||||
let blockH: CGFloat = 18
|
||||
let blockGap: CGFloat = 3
|
||||
let maxBlocks = 7 // up to 5 honest + 2 fork versions
|
||||
let towerH: CGFloat = CGFloat(maxBlocks) * (blockH + blockGap) + 28
|
||||
let baseY: CGFloat = size.height - 70
|
||||
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.views[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 isFork = mid.hasPrefix("ζ")
|
||||
let blockColor = castColor(authorOf(mid))
|
||||
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(blockColor.opacity(world.daveBanned && isFork ? 0.30 : 0.88)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(isFork ? .red.opacity(0.85) : .white.opacity(0.45)),
|
||||
lineWidth: isFork ? 1.6 : 1.0)
|
||||
context.draw(
|
||||
Text(mid)
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(world.daveBanned && isFork ? 0.55 : 1.0)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||
)
|
||||
if world.daveBanned && isFork {
|
||||
var x = Path()
|
||||
x.move(to: CGPoint(x: rect.minX + 4, y: rect.minY + 4))
|
||||
x.addLine(to: CGPoint(x: rect.maxX - 4, y: rect.maxY - 4))
|
||||
x.move(to: CGPoint(x: rect.minX + 4, y: rect.maxY - 4))
|
||||
x.addLine(to: CGPoint(x: rect.maxX - 4, y: rect.minY + 4))
|
||||
context.stroke(x, with: .color(.red.opacity(0.9)),
|
||||
lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
if vertex.isLast && !isForked && sceneIndex == 1 {
|
||||
context.stroke(Circle().path(in: rect.insetBy(dx: -2, dy: -2)),
|
||||
with: .color(.green.opacity(0.6)), lineWidth: 1.6)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scene-specific bottom panel ─────────────────────────────────
|
||||
switch sceneIndex {
|
||||
case 0:
|
||||
renderScene0Bottom(in: &context, size: size, time: time,
|
||||
forkedCount: forkedVertices.count, daveTotal: daveVertices.count)
|
||||
case 1:
|
||||
renderScene1Bottom(in: &context, size: size, time: time, sim: sim,
|
||||
forkedCount: forkedVertices.count)
|
||||
default: break
|
||||
}
|
||||
|
||||
// Top header reads the same in both scenes — anchors the chapter.
|
||||
context.draw(
|
||||
Text("DAVE LIES — CRISIS CATCHES HIM")
|
||||
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.red.opacity(0.55))
|
||||
.kerning(2),
|
||||
at: CGPoint(x: size.width / 2, y: 28)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Scene 0: forks revealed
|
||||
// MARK: - Beat tag
|
||||
|
||||
private func renderScene0Bottom(
|
||||
in context: inout GraphicsContext, size: CGSize, time: Double,
|
||||
forkedCount: Int, daveTotal: Int
|
||||
private func drawBeatTag(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch09WorldState
|
||||
) {
|
||||
let bandY = size.height * 0.7
|
||||
let appear = min(1.0, time * 0.4)
|
||||
|
||||
guard let beatId = world.activeBeat?.id else { return }
|
||||
context.draw(
|
||||
Text("DAVE'S FORKS — VIOLET LANE, RED RINGS")
|
||||
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.red.opacity(0.7 * appear)),
|
||||
at: CGPoint(x: size.width / 2, y: bandY)
|
||||
)
|
||||
|
||||
// Stats row
|
||||
let stats: [(String, String, Color)] = [
|
||||
("DAVE TOTAL", "\(daveTotal) MSGS", Cast.violet),
|
||||
("CONFLICTS", "\(forkedCount)", .red),
|
||||
("STRATEGY", "FORK SAME ID, DIFFERENT PARENTS", .orange)
|
||||
]
|
||||
let pillW: CGFloat = 220
|
||||
let pillH: CGFloat = 46
|
||||
let totalW = pillW * CGFloat(stats.count) + 24 * CGFloat(stats.count - 1)
|
||||
let startX = (size.width - totalW) / 2
|
||||
for (i, stat) in stats.enumerated() {
|
||||
let appearI = max(0, min(1, time * 0.4 - Double(i) * 0.5))
|
||||
if appearI < 0.05 { continue }
|
||||
let x = startX + CGFloat(i) * (pillW + 24)
|
||||
let rect = CGRect(x: x, y: bandY + 26, width: pillW, height: pillH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 8).path(in: rect),
|
||||
with: .color(.black.opacity(0.55 * appearI)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rect),
|
||||
with: .color(stat.2.opacity(0.7 * appearI)),
|
||||
lineWidth: 1.5)
|
||||
context.draw(
|
||||
Text(stat.0)
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(stat.2.opacity(0.95 * appearI)),
|
||||
at: CGPoint(x: rect.midX, y: rect.minY + 14)
|
||||
)
|
||||
context.draw(
|
||||
Text(stat.1)
|
||||
.font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.95 * appearI)),
|
||||
at: CGPoint(x: rect.midX, y: rect.minY + 32)
|
||||
)
|
||||
}
|
||||
|
||||
let hintAppear = max(0, min(1, time * 0.3 - 1.5))
|
||||
context.draw(
|
||||
Text("EACH FORKED VERTEX SHARES DAVE'S ID BUT POINTS AT DIFFERENT PARENTS — TRYING TO TRICK AARON & BEN INTO DISAGREEING")
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.55 * hintAppear)),
|
||||
at: CGPoint(x: size.width / 2, y: size.height - 36)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Scene 1: protocol routes around
|
||||
|
||||
private func renderScene1Bottom(
|
||||
in context: inout GraphicsContext, size: CGSize, time: Double,
|
||||
sim: SimulationData, forkedCount: Int
|
||||
) {
|
||||
let bandY = size.height * 0.7
|
||||
|
||||
// Threshold bar.
|
||||
let n = sim.nodes.count
|
||||
let f = sim.nodes.filter { $0.isByzantine }.count
|
||||
let appear = min(1.0, time * 0.3)
|
||||
|
||||
context.draw(
|
||||
Text("BYZANTINE RESILIENCE — f < n/3")
|
||||
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.7 * appear))
|
||||
.kerning(1.5),
|
||||
at: CGPoint(x: size.width / 2, y: bandY)
|
||||
)
|
||||
|
||||
let barW: CGFloat = size.width * 0.55
|
||||
let barH: CGFloat = 14
|
||||
let barX = (size.width - barW) / 2
|
||||
let barY = bandY + 32
|
||||
|
||||
// Track
|
||||
let bgRect = CGRect(x: barX, y: barY, width: barW, height: barH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 7).path(in: bgRect),
|
||||
with: .color(.white.opacity(0.06 * appear)))
|
||||
|
||||
// Byzantine fill.
|
||||
let byzFrac = Double(f) / Double(n)
|
||||
let fillRect = CGRect(x: barX, y: barY, width: barW * byzFrac, height: barH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 7).path(in: fillRect),
|
||||
with: .color(.red.opacity(0.55 * appear)))
|
||||
|
||||
// Threshold marker at 1/3.
|
||||
let threshX = barX + barW * (1.0 / 3.0)
|
||||
var threshLine = Path()
|
||||
threshLine.move(to: CGPoint(x: threshX, y: barY - 5))
|
||||
threshLine.addLine(to: CGPoint(x: threshX, y: barY + barH + 5))
|
||||
context.stroke(threshLine, with: .color(.green.opacity(0.85 * appear)), lineWidth: 2.5)
|
||||
context.draw(
|
||||
Text("1/3 THRESHOLD")
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.7 * appear)),
|
||||
at: CGPoint(x: threshX, y: barY + barH + 16)
|
||||
)
|
||||
|
||||
context.draw(
|
||||
Text("\(f)/\(n) BYZANTINE = \(String(format: "%.1f", byzFrac * 100))%")
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.85 * appear)),
|
||||
at: CGPoint(x: barX + barW / 2, y: barY - 14)
|
||||
)
|
||||
|
||||
// Defense line.
|
||||
let defenseAppear = max(0, min(1, time * 0.3 - 1.5))
|
||||
context.draw(
|
||||
Text("✓ \(forkedCount) FORKS DETECTED · ✓ DAVE'S VERTICES BANNED · ✓ AARON·BEN·CARL CONVERGE")
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.7 * defenseAppear)),
|
||||
at: CGPoint(x: size.width / 2, y: barY + barH + 44)
|
||||
)
|
||||
|
||||
let footerAppear = max(0, min(1, time * 0.3 - 2.5))
|
||||
context.draw(
|
||||
Text("CRISIS GUARANTEES TOTAL ORDER WHENEVER FEWER THAN ONE-THIRD OF VALIDATORS LIE.")
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.55 * footerAppear)),
|
||||
at: CGPoint(x: size.width / 2, y: size.height - 36)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
274
CrisisViz/Sources/CrisisViz/Engine/Ch09Timeline.swift
Normal file
274
CrisisViz/Sources/CrisisViz/Engine/Ch09Timeline.swift
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch09 — "Dave lies. Crisis catches him."
|
||||
///
|
||||
/// Dave produces two conflicting messages under the same identity,
|
||||
/// sends one to Aaron and a different one to Ben, and tries to make
|
||||
/// the honest validators disagree about what they saw. The protocol
|
||||
/// catches him: as soon as Aaron and Ben gossip with each other, a
|
||||
/// fork is detected, Dave's vertices are banned, and the honest 3
|
||||
/// converge without him.
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Ch09BeatKind {
|
||||
case settle(label: String)
|
||||
case carryForward
|
||||
case think(Ch01Cast, label: String)
|
||||
case forkCompose(versionId: String) // composing one of the two conflicting messages
|
||||
case forkSeal(versionId: String) // both forks now visible on Dave's lane
|
||||
case forkSend(versionId: String, to: Ch01Cast)
|
||||
case forkAccept(at: Ch01Cast, versionId: String)
|
||||
case gossipExchange(from: Ch01Cast, to: Ch01Cast) // Aaron sends his ζ_a to Ben
|
||||
case forkDetected // "FORK DETECTED" badge
|
||||
case banDave // red X across Dave's forks + lane
|
||||
case convergeWithoutDave // honest 3 converge, Dave's weight wasted
|
||||
case thresholdBar // f<n/3 visualization
|
||||
}
|
||||
|
||||
struct Ch09Beat: Identifiable {
|
||||
let id: String
|
||||
let kind: Ch09BeatKind
|
||||
let durationSeconds: Double
|
||||
let narration: String
|
||||
var startTime: Double = 0
|
||||
var endTime: Double { startTime + durationSeconds }
|
||||
}
|
||||
|
||||
struct Ch09WorldState {
|
||||
/// Honest-cast tower contents — carry-forward from Ch03's converged
|
||||
/// state {α, β, γ, δ, ε}, plus whatever Dave-fork they accepted.
|
||||
var views: [Ch01Cast: [String]] = [:]
|
||||
/// Both fork versions: ζ_a and ζ_b. Once a `forkSeal` beat fires,
|
||||
/// the corresponding version is in `forksOnDaveLane`.
|
||||
var forksOnDaveLane: [String] = []
|
||||
/// Composing animation for the current fork being written.
|
||||
var composing: Ch09Composing? = nil
|
||||
/// In-flight fork envelope, if active.
|
||||
var inFlight: Ch09Flight? = nil
|
||||
/// "FORK DETECTED" overlay opacity 0..1.
|
||||
var forkDetectedAlpha: Double = 0
|
||||
/// Once Dave's vertices are banned, this flag flips.
|
||||
var daveBanned: Bool = false
|
||||
/// The threshold bar (f<n/3) appears in scene 1.
|
||||
var thresholdBarAlpha: Double = 0
|
||||
/// Convergence flag — show "AARON · BEN · CARL CONVERGE" badge.
|
||||
var convergedAlpha: Double = 0
|
||||
/// Thought bubble.
|
||||
var thought: Ch09Thought? = nil
|
||||
var activeBeat: Ch09Beat? = nil
|
||||
var activeProgress: Double = 0
|
||||
|
||||
struct Ch09Composing {
|
||||
let versionId: String
|
||||
var sealed: Bool
|
||||
}
|
||||
struct Ch09Flight {
|
||||
let versionId: String
|
||||
let from: Ch01Cast
|
||||
let to: Ch01Cast
|
||||
let progress: Double
|
||||
}
|
||||
struct Ch09Thought {
|
||||
let cast: Ch01Cast
|
||||
let label: String
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
enum Ch09Timeline {
|
||||
/// Two conflicting fork versions Dave writes.
|
||||
static let forkVersions: [String: (label: String, claim: String, hashShort: String)] = [
|
||||
"ζ_a": ("ζ_a", "claims: send 50 BTC to Aaron", "f1aa"),
|
||||
"ζ_b": ("ζ_b", "claims: send 50 BTC to Charlie", "f1bb"),
|
||||
]
|
||||
|
||||
static let beats: [Ch09Beat] = {
|
||||
let raw: [Ch09Beat] = [
|
||||
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
|
||||
narration: "By now Aaron, Ben, Carl and Dave all hold the same converged set of messages from earlier chapters. The graph has been clean. But what if a player tries to lie?"),
|
||||
|
||||
.init(id: "dave-thinks", kind: .think(.dave, label: "I'll send different things to different people."),
|
||||
durationSeconds: 4.5,
|
||||
narration: "Dave decides to attack. He'll write TWO messages with the SAME identity — same author, same parent set — but different content. He'll send one to Aaron and the other to Ben. If they trust him, they'll disagree about what Dave actually said."),
|
||||
|
||||
// Compose ζ_a
|
||||
.init(id: "compose-zeta-a", kind: .forkCompose(versionId: "ζ_a"),
|
||||
durationSeconds: 5.0,
|
||||
narration: "First fork: ζ_a. Body: 'send 50 BTC to Aaron'. Parents: ε. Hash: f1aa."),
|
||||
.init(id: "seal-zeta-a", kind: .forkSeal(versionId: "ζ_a"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "ζ_a is sealed. A red-ringed vertex appears on Dave's lane — the ring is the visual cue that this vertex is part of a fork."),
|
||||
|
||||
// Compose ζ_b
|
||||
.init(id: "compose-zeta-b", kind: .forkCompose(versionId: "ζ_b"),
|
||||
durationSeconds: 5.0,
|
||||
narration: "Second fork: ζ_b. SAME identity (Dave), SAME parent set (ε), but DIFFERENT body: 'send 50 BTC to Charlie'. Hash: f1bb. This is the lie."),
|
||||
.init(id: "seal-zeta-b", kind: .forkSeal(versionId: "ζ_b"),
|
||||
durationSeconds: 3.5,
|
||||
narration: "ζ_b is sealed. A SECOND red-ringed vertex appears on Dave's lane, right next to ζ_a. Now Dave has two contradictory messages with the same identity. Watch what he does next."),
|
||||
|
||||
// Send ζ_a to Aaron, ζ_b to Ben
|
||||
.init(id: "send-zeta-a-aaron", kind: .forkSend(versionId: "ζ_a", to: .aaron),
|
||||
durationSeconds: 6.0,
|
||||
narration: "Dave sends ζ_a to Aaron — and only to Aaron."),
|
||||
.init(id: "aaron-accepts-zeta-a", kind: .forkAccept(at: .aaron, versionId: "ζ_a"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Aaron has no reason yet to suspect anything. ζ_a is signed by Dave, references a parent he knows, hashes correctly. Aaron accepts. His tower grows by ζ_a."),
|
||||
|
||||
.init(id: "send-zeta-b-ben", kind: .forkSend(versionId: "ζ_b", to: .ben),
|
||||
durationSeconds: 6.0,
|
||||
narration: "Dave sends ζ_b to Ben — and only to Ben."),
|
||||
.init(id: "ben-accepts-zeta-b", kind: .forkAccept(at: .ben, versionId: "ζ_b"),
|
||||
durationSeconds: 3.5,
|
||||
narration: "Ben also has no reason to suspect. ζ_b is signed, parent matches, hash matches. Ben accepts ζ_b. Now Aaron and Ben hold DIFFERENT Dave-versions."),
|
||||
|
||||
.init(id: "scene-0-end", kind: .settle(label: "Two stories from one liar"),
|
||||
durationSeconds: 4.0,
|
||||
narration: "Look at Aaron's tower — it has ζ_a. Ben's tower has ζ_b. Same author, different content. Dave has succeeded in splitting their views. For exactly this moment."),
|
||||
|
||||
// Scene 1: gossip exchange → fork detected → ban → converge
|
||||
.init(id: "gossip-aaron-ben", kind: .gossipExchange(from: .aaron, to: .ben),
|
||||
durationSeconds: 6.0,
|
||||
narration: "Now Aaron and Ben gossip. Aaron forwards his copy of Dave's message — ζ_a — to Ben."),
|
||||
|
||||
.init(id: "fork-detected", kind: .forkDetected,
|
||||
durationSeconds: 5.5,
|
||||
narration: "Ben already has ζ_b from Dave. Now ζ_a arrives from Aaron. SAME author, SAME parent, DIFFERENT content. Ben's verifier flags this immediately: a fork. No vote, no committee — just arithmetic on two signed messages."),
|
||||
|
||||
.init(id: "threshold-bar", kind: .thresholdBar,
|
||||
durationSeconds: 5.0,
|
||||
narration: "How does Crisis tolerate this? f < n/3. One byzantine out of four is f=1, n=4. 3f = 3 < 4 = n. The protocol's safety threshold holds — Dave alone cannot break consensus."),
|
||||
|
||||
.init(id: "ban-dave", kind: .banDave,
|
||||
durationSeconds: 5.5,
|
||||
narration: "Both Dave-vertices get banned — a red X across each. Total order routes around them. Dave's PoW weight is wasted. The honest validators continue without him."),
|
||||
|
||||
.init(id: "converge", kind: .convergeWithoutDave,
|
||||
durationSeconds: 6.0,
|
||||
narration: "Aaron, Ben and Carl now agree. They're missing none of each other's messages. Dave's two forks are explicitly excluded. Convergence holds — even with a liar in the room."),
|
||||
|
||||
.init(id: "outro", kind: .settle(label: "Crisis catches the liar"),
|
||||
durationSeconds: 4.0,
|
||||
narration: "That is Byzantine resilience under f < n/3. The system tolerates one liar; if more than a third of validators were byzantine, all bets are off. With one — exactly one — Crisis routes around them and converges anyway. Done."),
|
||||
]
|
||||
|
||||
var t: Double = 0
|
||||
var assigned: [Ch09Beat] = []
|
||||
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) -> Ch09Beat? {
|
||||
let clamped = max(0, min(t, totalDuration))
|
||||
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
|
||||
?? beats.last
|
||||
}
|
||||
|
||||
static func state(at t: Double) -> Ch09WorldState {
|
||||
var w = Ch09WorldState()
|
||||
// Carry-forward: every cast starts with {α, β, γ, δ, ε}.
|
||||
let initial = ["α", "β", "γ", "δ", "ε"]
|
||||
for cast in Ch01Cast.allCases {
|
||||
w.views[cast] = initial
|
||||
}
|
||||
|
||||
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: Ch09Beat, progress: Double, isActive: Bool,
|
||||
into w: inout Ch09WorldState
|
||||
) {
|
||||
switch beat.kind {
|
||||
case .settle, .carryForward:
|
||||
break
|
||||
case .think(let cast, let label):
|
||||
if isActive { w.thought = .init(cast: cast, label: label) }
|
||||
case .forkCompose(let vid):
|
||||
if w.composing?.versionId != vid {
|
||||
w.composing = .init(versionId: vid, sealed: false)
|
||||
}
|
||||
case .forkSeal(let vid):
|
||||
if !w.forksOnDaveLane.contains(vid) {
|
||||
w.forksOnDaveLane.append(vid)
|
||||
}
|
||||
if !isActive { w.composing = nil }
|
||||
else { w.composing = .init(versionId: vid, sealed: true) }
|
||||
case .forkSend(let vid, let to):
|
||||
if isActive {
|
||||
w.inFlight = .init(versionId: vid, from: .dave,
|
||||
to: to, progress: progress)
|
||||
}
|
||||
case .forkAccept(let at, let vid):
|
||||
if !w.views[at, default: []].contains(vid) {
|
||||
w.views[at, default: []].append(vid)
|
||||
}
|
||||
case .gossipExchange(let from, let to):
|
||||
// Animate Aaron sending his ζ_a to Ben.
|
||||
if isActive {
|
||||
w.inFlight = .init(versionId: "ζ_a", from: from,
|
||||
to: to, progress: progress)
|
||||
} else {
|
||||
// Permanent: Ben now also has ζ_a (in addition to ζ_b).
|
||||
if !w.views[to, default: []].contains("ζ_a") {
|
||||
w.views[to, default: []].append("ζ_a")
|
||||
}
|
||||
}
|
||||
case .forkDetected:
|
||||
w.forkDetectedAlpha = isActive ? progress : 1.0
|
||||
case .thresholdBar:
|
||||
w.thresholdBarAlpha = isActive ? progress : 1.0
|
||||
case .banDave:
|
||||
w.daveBanned = true
|
||||
case .convergeWithoutDave:
|
||||
w.convergedAlpha = isActive ? progress : 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scene mapping
|
||||
|
||||
enum Ch09Scenes {
|
||||
/// 2 scenes, total ~79.5s. Scene 0 = Dave creates the fork; Scene 1
|
||||
/// = detection + threshold + ban + convergence.
|
||||
static let sceneStarts: [Double] = [0, 47.5]
|
||||
static let sceneDurations: [Double] = [47.5, 32.0]
|
||||
|
||||
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 Ch09Timeline.activeBeat(at: t)?.narration ?? ""
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,9 @@ final class SceneEngine {
|
|||
SceneAddress(chapter: 3, scene: 0): 23.5,
|
||||
SceneAddress(chapter: 3, scene: 1): 20.5,
|
||||
SceneAddress(chapter: 3, scene: 2): 28.0,
|
||||
// Ch09 — Byzantine (2 scenes mapping to Ch09Timeline windows)
|
||||
SceneAddress(chapter: 9, scene: 0): 47.5,
|
||||
SceneAddress(chapter: 9, scene: 1): 32.0,
|
||||
]
|
||||
|
||||
/// Effective duration for the current scene, honoring overrides.
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ struct ImmersiveView: View {
|
|||
return Ch02Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 3:
|
||||
return Ch03Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 9:
|
||||
return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
default:
|
||||
return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue