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