mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-06-26 02:40:33 +00:00
Ch03 rounds: migrate to serial timeline + weight thermometer
Ch03 walks through the five messages from Ch01/Ch02 and shows how
round numbers are derived — not declared — from accumulated PoW
weight. A horizontal thermometer at the top of the canvas fills
green as each highlighted vertex contributes its weight; a yellow
dashed line marks the round-closing threshold. When δ pushes the
total over the threshold, a yellow ring + "is_last" label appears
on δ on every cast member's lane simultaneously. Round 0 closes;
round 1 opens with ε at weight 1.
The chapter's pedagogical punch: nobody voted, nobody declared the
boundary. Every honest player who has the same five messages
computes the same total weight, sees δ push the same threshold, and
flags δ identically. The thermometer is a calculator, not a
ballot box.
Closing beats:
- "Each player keeps their own DAG. Full stop." overlay
- "Re-gossip is harmless" demonstrated: a duplicate α envelope
flies to Ben, arrives, gets a "✗ DUPLICATE — DROPPED" label,
and dissolves. No tower update, no thermometer change.
- "Weight is arithmetic. Arithmetic doesn't depend on who you ask."
`Ch03Timeline.swift` (new) — 17 beats over ~72s. New beat kinds
this chapter introduces: `introduceWeights` / `highlightVertex` /
`markIsLast` / `openNewRound` / `bookkeepingNote` /
`reGossipDuplicate`.
`Ch04_Rounds.swift` rewritten end-to-end (91 → 460 lines) to
render from Ch03Timeline. Lanes carry the carry-forward five
messages with weight + round labels; thermometer at top; bookkeeping
note overlay below; perception towers at the bottom (now also
showing round numbers per block).
`SceneEngine` and `ImmersiveView` extended for chapter 3.
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
08f6e6ff8b
commit
b33a2cf4a5
4 changed files with 703 additions and 58 deletions
|
|
@ -1,6 +1,13 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch04: "Rounds from Weight" — PoW weight accumulation triggers round boundaries.
|
||||
/// Ch03 (chapter index 3): "Counting witnesses to mark a round."
|
||||
///
|
||||
/// Renders from `Ch03Timeline`. Picks up Ch02's final state ({α, β, γ,
|
||||
/// δ, ε} on every player's lane), then walks through each message in
|
||||
/// turn — adding its proof-of-work weight to a thermometer at the top
|
||||
/// of the canvas — until the threshold is crossed and the round
|
||||
/// boundary is marked. The chapter shows that round numbers are
|
||||
/// DERIVED from arithmetic on weight, not declared or negotiated.
|
||||
struct Ch04_Rounds: View {
|
||||
let sceneIndex: Int
|
||||
let localTime: Double
|
||||
|
|
@ -8,84 +15,468 @@ struct Ch04_Rounds: View {
|
|||
let dm: DataManager
|
||||
@Environment(AppSettings.self) private var settings
|
||||
|
||||
private var dataStep: Int { sceneIndex + 2 } // steps 2, 3, 4
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
render(context: &context, size: size, time: localTime)
|
||||
let t = Ch03Scenes.timelineT(sceneIndex: sceneIndex,
|
||||
localTime: localTime)
|
||||
render(in: &context, size: size, t: t)
|
||||
}
|
||||
}
|
||||
|
||||
private func render(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
guard dm.sim != nil,
|
||||
let snap = dm.honestData(step: dataStep) else { return }
|
||||
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
|
||||
let world = Ch03Timeline.state(at: t)
|
||||
|
||||
let vertices = snap.vertices
|
||||
let edges = snap.edges
|
||||
drawLanes(in: &context, size: size)
|
||||
drawCastFigures(in: &context, size: size, t: t)
|
||||
drawAcceptedVertices(in: &context, size: size, world: world)
|
||||
drawAcceptedEdges(in: &context, size: size, world: world)
|
||||
|
||||
// Draw the DAG with round separators prominent
|
||||
let layout = DAGLayout.compute(vertices: vertices, edges: edges, nodes: dm.castOrderedNodes(),
|
||||
canvasSize: size, margin: 60)
|
||||
let minRound = vertices.map { $0.round }.min() ?? 0
|
||||
layout.drawNodeLanes(in: &context, nodes: dm.castOrderedNodes(), canvasSize: size, dm: dm, textScale: settings.textScale)
|
||||
layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: minRound, alpha: 0.4, textScale: settings.textScale)
|
||||
layout.drawEdges(in: &context, edges: edges, alpha: 0.3)
|
||||
drawThermometer(in: &context, size: size, world: world)
|
||||
|
||||
// Highlight isLast vertices (round boundary markers) with bright rings
|
||||
let roundMarkers = Set(vertices.filter { $0.isLast }.map { $0.digestHex })
|
||||
layout.drawVertices(in: &context, vertices: vertices, nodes: dm.castOrderedNodes(), dm: dm,
|
||||
showLabels: true, showWeight: true, highlightSet: roundMarkers, textScale: settings.textScale)
|
||||
if let bookkeeping = world.bookkeepingText {
|
||||
drawBookkeepingNote(in: &context, size: size, text: bookkeeping)
|
||||
}
|
||||
if let regossip = world.reGossipFlash {
|
||||
drawReGossipDuplicate(in: &context, size: size, regossip: regossip)
|
||||
}
|
||||
|
||||
// Weight bars per round at the bottom
|
||||
let rounds = Dictionary(grouping: vertices, by: { $0.round })
|
||||
let barY = size.height - 80.0
|
||||
let barHeight: CGFloat = 20
|
||||
let roundCount = rounds.keys.count
|
||||
let barSpacing = min(120.0, (size.width - 120) / CGFloat(max(roundCount, 1)))
|
||||
let startX = 60.0
|
||||
drawPerceptionTowers(in: &context, size: size, world: world)
|
||||
drawBeatTag(in: &context, size: size, world: world)
|
||||
}
|
||||
|
||||
for (round, verts) in rounds.sorted(by: { $0.key < $1.key }) {
|
||||
let totalWeight = verts.reduce(0) { $0 + $1.weight }
|
||||
let x = startX + CGFloat(round - minRound) * barSpacing
|
||||
let barW = barSpacing * 0.7
|
||||
let maxWeight = 30.0 // scale factor
|
||||
// MARK: - Geometry / lookup helpers
|
||||
|
||||
// Background
|
||||
let bgRect = CGRect(x: x, y: barY, width: barW, height: barHeight)
|
||||
context.fill(RoundedRectangle(cornerRadius: 3).path(in: bgRect),
|
||||
with: .color(.white.opacity(0.05)))
|
||||
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
|
||||
}
|
||||
|
||||
// Fill proportional to weight
|
||||
let fillPct = min(1.0, Double(totalWeight) / maxWeight)
|
||||
let fillW = barW * fillPct
|
||||
let fillRect = CGRect(x: x, y: barY, width: fillW, height: barHeight)
|
||||
let allIsLast = verts.allSatisfy { $0.isLast }
|
||||
let fillColor: Color = allIsLast ? .yellow : .cyan
|
||||
context.fill(RoundedRectangle(cornerRadius: 3).path(in: fillRect),
|
||||
with: .color(fillColor.opacity(0.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))
|
||||
}
|
||||
|
||||
// Label
|
||||
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 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 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 []
|
||||
}
|
||||
|
||||
/// All four cast members hold {α, β, γ, δ, ε} from the carry-forward.
|
||||
private var allMessages: [String] { Ch03Timeline.messageOrder }
|
||||
|
||||
// MARK: - Lanes + cast
|
||||
|
||||
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("R\(round): Σw=\(totalWeight)")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.5)),
|
||||
at: CGPoint(x: x + barW / 2, y: barY + barHeight + 12)
|
||||
Text(cast.role.displayName.capitalized)
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(castColor(cast).opacity(0.75)),
|
||||
at: CGPoint(x: 24, y: y),
|
||||
anchor: .leading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawCastFigures(
|
||||
in context: inout GraphicsContext, size: CGSize, 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 vertices on each lane
|
||||
|
||||
private func drawAcceptedVertices(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState
|
||||
) {
|
||||
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 allMessages.enumerated() {
|
||||
let x = firstX + CGFloat(i) * gap
|
||||
if x > size.width - 60 { break }
|
||||
drawAcceptedVertex(
|
||||
in: &context, at: CGPoint(x: x, y: lane),
|
||||
messageId: mid, world: world
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func drawAcceptedVertex(
|
||||
in context: inout GraphicsContext, at pos: CGPoint,
|
||||
messageId: String, world: Ch03WorldState
|
||||
) {
|
||||
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)
|
||||
|
||||
// Highlight halo if this vertex is the active focus.
|
||||
if world.highlighted == messageId {
|
||||
let haloR: CGFloat = 24
|
||||
context.stroke(
|
||||
Circle().path(in: CGRect(x: pos.x - haloR, y: pos.y - haloR,
|
||||
width: haloR * 2, height: haloR * 2)),
|
||||
with: .color(.white.opacity(0.55)), lineWidth: 1.5
|
||||
)
|
||||
}
|
||||
|
||||
// isLast annotation
|
||||
// is_last yellow ring
|
||||
if world.isLastSet.contains(messageId) {
|
||||
let ringR: CGFloat = 21
|
||||
context.stroke(
|
||||
Circle().path(in: CGRect(x: pos.x - ringR, y: pos.y - ringR,
|
||||
width: ringR * 2, height: ringR * 2)),
|
||||
with: .color(.yellow.opacity(0.95)), lineWidth: 2.4
|
||||
)
|
||||
}
|
||||
|
||||
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("○ = isLast (ROUND BOUNDARY) — WEIGHT TRIGGERS TRANSITION")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.yellow.opacity(0.4)),
|
||||
at: CGPoint(x: size.width / 2, y: barY - 16)
|
||||
Text(messageId)
|
||||
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: pos
|
||||
)
|
||||
|
||||
// Hash + weight + round number below
|
||||
var sub: String = hashOf(messageId)
|
||||
if world.weightsVisible {
|
||||
let round = world.roundOf[messageId] ?? 0
|
||||
sub = "w=1 r=\(round)"
|
||||
}
|
||||
context.draw(
|
||||
Text("\(snap.vertices.count) VERTICES · MAX ROUND \(snap.maxRound)")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.2)),
|
||||
at: CGPoint(x: size.width / 2, y: 14)
|
||||
Text(sub)
|
||||
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.55)),
|
||||
at: CGPoint(x: pos.x, y: pos.y + r + 8)
|
||||
)
|
||||
|
||||
// is_last label
|
||||
if world.isLastSet.contains(messageId) {
|
||||
context.draw(
|
||||
Text("is_last")
|
||||
.font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.yellow.opacity(0.95)),
|
||||
at: CGPoint(x: pos.x, y: pos.y - r - 10)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawAcceptedEdges(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState
|
||||
) {
|
||||
let casts: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
|
||||
for (_, laneIdx) in casts {
|
||||
let lane = castLaneY(laneIdx, size: size)
|
||||
let castX = castPosition(cast: .aaron, size: size).x // all use same x
|
||||
let firstX = castX + 70
|
||||
let gap: CGFloat = 56
|
||||
var positions: [String: CGPoint] = [:]
|
||||
for (i, mid) in allMessages.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()
|
||||
path.move(to: CGPoint(x: childPos.x - 14, y: childPos.y))
|
||||
path.addLine(to: CGPoint(x: parentPos.x + 14, y: parentPos.y))
|
||||
context.stroke(path,
|
||||
with: .color(castColor(authorOf(mid)).opacity(0.55)),
|
||||
lineWidth: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Weight thermometer (top of canvas)
|
||||
|
||||
private func drawThermometer(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState
|
||||
) {
|
||||
// Horizontal bar near the top of the canvas. Width sized so the
|
||||
// composing-slot space is preserved, although Ch03 doesn't use
|
||||
// the slot.
|
||||
let barW: CGFloat = min(560, size.width - 100)
|
||||
let barH: CGFloat = 26
|
||||
let barX = size.width / 2 - barW / 2
|
||||
let barY: CGFloat = 18
|
||||
let rect = CGRect(x: barX, y: barY, width: barW, height: barH)
|
||||
|
||||
// Frame
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(.black.opacity(0.7)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(.white.opacity(0.5)), lineWidth: 1.0)
|
||||
|
||||
// Threshold tick (proportional to threshold within max=5)
|
||||
let maxBar: Double = 5
|
||||
let threshFrac = world.thermometerThreshold / maxBar
|
||||
let threshX = barX + CGFloat(threshFrac) * barW
|
||||
var threshLine = Path()
|
||||
threshLine.move(to: CGPoint(x: threshX, y: barY - 4))
|
||||
threshLine.addLine(to: CGPoint(x: threshX, y: barY + barH + 4))
|
||||
context.stroke(threshLine,
|
||||
with: .color(.yellow.opacity(0.85)),
|
||||
style: StrokeStyle(lineWidth: 1.4, dash: [3, 3]))
|
||||
context.draw(
|
||||
Text("threshold = \(Int(world.thermometerThreshold))")
|
||||
.font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.yellow.opacity(0.85)),
|
||||
at: CGPoint(x: threshX, y: barY - 14)
|
||||
)
|
||||
|
||||
// Fill
|
||||
let fillFrac = min(1.0, world.thermometerWeight / maxBar)
|
||||
let fillW = CGFloat(fillFrac) * barW
|
||||
if fillW > 0 {
|
||||
let fillRect = CGRect(x: barX, y: barY, width: fillW, height: barH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: fillRect),
|
||||
with: .color(.green.opacity(0.7)))
|
||||
}
|
||||
|
||||
// Label
|
||||
let label = String(format: "ROUND %d · weight = %.0f / %d",
|
||||
world.currentRound,
|
||||
world.thermometerWeight,
|
||||
Int(world.thermometerThreshold))
|
||||
context.draw(
|
||||
Text(label)
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.95)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Bookkeeping note + re-gossip duplicate
|
||||
|
||||
private func drawBookkeepingNote(
|
||||
in context: inout GraphicsContext, size: CGSize, text: String
|
||||
) {
|
||||
// Sits under the thermometer.
|
||||
let cy: CGFloat = 64
|
||||
let rect = CGRect(x: size.width / 2 - 280, y: cy - 14,
|
||||
width: 560, height: 28)
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(.black.opacity(0.55)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(.white.opacity(0.35)), lineWidth: 0.8)
|
||||
context.draw(
|
||||
Text(text)
|
||||
.font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.85)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawReGossipDuplicate(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
regossip: Ch03WorldState.ReGossip
|
||||
) {
|
||||
// The duplicate envelope flies from Aaron toward the recipient,
|
||||
// arrives, then displays a "DROP — duplicate" label that fades.
|
||||
let from = castPosition(cast: .aaron, size: size)
|
||||
let to = castPosition(cast: regossip.recipient, size: size)
|
||||
let lift: CGFloat = 36
|
||||
let fromTrack = CGPoint(x: from.x, y: from.y - lift)
|
||||
let toTrack = CGPoint(x: to.x, y: to.y - lift)
|
||||
// Path
|
||||
var path = Path()
|
||||
path.move(to: fromTrack)
|
||||
path.addLine(to: toTrack)
|
||||
context.stroke(path,
|
||||
with: .color(.white.opacity(0.18)),
|
||||
style: StrokeStyle(lineWidth: 1.0, dash: [3, 5]))
|
||||
let p = CGFloat(min(1.0, regossip.progress * 1.5)) // arrives at 0.67
|
||||
let pos = CGPoint(x: fromTrack.x + (toTrack.x - fromTrack.x) * p,
|
||||
y: fromTrack.y + (toTrack.y - fromTrack.y) * p)
|
||||
let envW: CGFloat = 78
|
||||
let envH: CGFloat = 28
|
||||
let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2,
|
||||
width: envW, height: envH)
|
||||
let envFade = regossip.progress < 0.7 ? 1.0 : Double(max(0, 1 - (regossip.progress - 0.7) / 0.3))
|
||||
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(Cast.coral.opacity(0.85 * envFade)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(.white.opacity(0.55 * envFade)),
|
||||
lineWidth: 1.0)
|
||||
context.draw(
|
||||
Text("\(regossip.messageId) (re-gossip)")
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(envFade)),
|
||||
at: pos
|
||||
)
|
||||
// "✗ duplicate dropped" label appears once envelope is at recipient
|
||||
if regossip.progress > 0.65 {
|
||||
let alpha = min(1.0, (regossip.progress - 0.65) / 0.2)
|
||||
context.draw(
|
||||
Text("✗ DUPLICATE — DROPPED")
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.red.opacity(0.95 * alpha)),
|
||||
at: CGPoint(x: to.x, y: to.y - 60)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Perception towers
|
||||
|
||||
private func drawPerceptionTowers(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState
|
||||
) {
|
||||
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]))
|
||||
}
|
||||
|
||||
// Blocks: all five messages in alphabetical order
|
||||
for (j, mid) in allMessages.enumerated() {
|
||||
let blockY = baseY - CGFloat(j + 1) * (blockH + blockGap)
|
||||
let rect = CGRect(x: towerX + 6, y: blockY,
|
||||
width: towerW - 12, height: blockH)
|
||||
let blockColor = castColor(authorOf(mid))
|
||||
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(blockColor.opacity(0.88)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(.white.opacity(0.45)), lineWidth: 1.0)
|
||||
// is_last yellow accent
|
||||
if world.isLastSet.contains(mid) {
|
||||
context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(.yellow.opacity(0.95)),
|
||||
lineWidth: 2.0)
|
||||
}
|
||||
let round = world.roundOf[mid] ?? 0
|
||||
context.draw(
|
||||
Text("\(mid) r\(round)")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Beat tag
|
||||
|
||||
private func drawBeatTag(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch03WorldState
|
||||
) {
|
||||
guard let beatId = world.activeBeat?.id else { return }
|
||||
context.draw(
|
||||
Text(beatId)
|
||||
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.20)),
|
||||
at: CGPoint(x: size.width - 14, y: 10),
|
||||
anchor: .trailing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
248
CrisisViz/Sources/CrisisViz/Engine/Ch03Timeline.swift
Normal file
248
CrisisViz/Sources/CrisisViz/Engine/Ch03Timeline.swift
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch03 — "Counting witnesses to mark a round."
|
||||
///
|
||||
/// The chapter is about ROUND DERIVATION: round numbers in Crisis are
|
||||
/// computed from accumulated proof-of-work weight, not declared or
|
||||
/// negotiated. The chapter walks through the five messages from
|
||||
/// Ch01/Ch02 (α through ε) and shows weight summing into a thermometer
|
||||
/// at the top of the canvas. When a threshold is crossed, the message
|
||||
/// that pushed weight over the line earns the `is_last` flag — that's
|
||||
/// the round boundary marker.
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Ch03BeatKind {
|
||||
case settle(label: String)
|
||||
case carryForward // initial state from Ch02
|
||||
case introduceWeights // each vertex shows its weight
|
||||
case highlightVertex(messageId: String) // thermometer adds weight
|
||||
case markIsLast(messageId: String) // yellow ring + boundary badge
|
||||
case openNewRound(roundNumber: Int) // increment round counter
|
||||
case bookkeepingNote(text: String) // overlay text only
|
||||
case reGossipDuplicate(messageId: String, recipient: Ch01Cast) // duplicate ignored
|
||||
}
|
||||
|
||||
struct Ch03Beat: Identifiable {
|
||||
let id: String
|
||||
let kind: Ch03BeatKind
|
||||
let durationSeconds: Double
|
||||
let narration: String
|
||||
var startTime: Double = 0
|
||||
var endTime: Double { startTime + durationSeconds }
|
||||
}
|
||||
|
||||
struct Ch03WorldState {
|
||||
var weightsVisible: Bool = false // show weight=1 labels next to vertices
|
||||
var roundOf: [String: Int] = [:] // message id → round number once derived
|
||||
var isLastSet: Set<String> = [] // messages flagged is_last
|
||||
var highlighted: String? = nil // currently focused vertex (halo)
|
||||
var currentRound: Int = 0
|
||||
var thermometerWeight: Double = 0 // accumulated weight in current round
|
||||
var thermometerThreshold: Double = 4 // round closes when weight ≥ threshold
|
||||
var bookkeepingText: String? = nil
|
||||
var reGossipFlash: ReGossip? = nil
|
||||
var activeBeat: Ch03Beat? = nil
|
||||
var activeProgress: Double = 0
|
||||
|
||||
struct ReGossip {
|
||||
let messageId: String
|
||||
let recipient: Ch01Cast
|
||||
let progress: Double
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
enum Ch03Timeline {
|
||||
/// Each message has weight 1 in this chapter (PoW puzzles all the
|
||||
/// same difficulty). Round threshold = 4, so α/β/γ/δ close round 0
|
||||
/// (δ is_last) and ε opens round 1.
|
||||
static let messageWeights: [String: Double] = [
|
||||
"α": 1, "β": 1, "γ": 1, "δ": 1, "ε": 1,
|
||||
]
|
||||
static let messageOrder: [String] = ["α", "β", "γ", "δ", "ε"]
|
||||
static let threshold: Double = 4
|
||||
|
||||
static let beats: [Ch03Beat] = {
|
||||
let raw: [Ch03Beat] = [
|
||||
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
|
||||
narration: "Coming out of the partition: all four cast members hold the same five messages — α, β, γ, δ, ε. Now we ask a different question. What ROUND is each one in? And how do we even know?"),
|
||||
|
||||
.init(id: "introduce-weights", kind: .introduceWeights, durationSeconds: 5.0,
|
||||
narration: "First, every message carries a proof-of-work weight. Harder puzzles → heavier messages. In this demo each message has weight 1. The little 'w=1' label appears next to each vertex on every lane."),
|
||||
|
||||
.init(id: "thermometer-explained", kind: .settle(label: "Thermometer at the top"),
|
||||
durationSeconds: 4.0,
|
||||
narration: "Look at the top of the canvas. The thermometer accumulates weight as we count messages within the current round. The dotted line on it is the round-closing threshold."),
|
||||
|
||||
// Walk through α/β/γ/δ — each contributes weight to round 0
|
||||
.init(id: "highlight-alpha", kind: .highlightVertex(messageId: "α"), durationSeconds: 3.5,
|
||||
narration: "α is the first vertex on every lane. Round 0. Weight 1. The thermometer ticks up to 1."),
|
||||
.init(id: "highlight-beta", kind: .highlightVertex(messageId: "β"), durationSeconds: 3.5,
|
||||
narration: "β references α — still inside round 0. Weight 1. Thermometer ticks up to 2."),
|
||||
.init(id: "highlight-gamma", kind: .highlightVertex(messageId: "γ"), durationSeconds: 3.5,
|
||||
narration: "γ also references α — still inside round 0. Weight 1. Thermometer ticks up to 3."),
|
||||
.init(id: "highlight-delta", kind: .highlightVertex(messageId: "δ"), durationSeconds: 4.0,
|
||||
narration: "δ references γ — still inside round 0 because we haven't crossed the threshold yet. Weight 1. Thermometer ticks up to 4 — exactly the threshold."),
|
||||
|
||||
.init(id: "mark-delta-islast", kind: .markIsLast(messageId: "δ"),
|
||||
durationSeconds: 5.5,
|
||||
narration: "The threshold is met. δ — the message that pushed weight over the line — gets the is_last flag. A yellow ring marks it on every lane that holds it. Round 0 has closed."),
|
||||
|
||||
.init(id: "round-0-closed-settle", kind: .settle(label: "Round 0 closed"),
|
||||
durationSeconds: 4.0,
|
||||
narration: "Crucially: NOBODY VOTED. Nobody declared the boundary. Every honest player who has the same five messages computes the same total weight, sees δ push over the same threshold, and flags δ as is_last. Round 0 is DERIVED, not declared."),
|
||||
|
||||
// Open round 1 with ε
|
||||
.init(id: "open-round-1", kind: .openNewRound(roundNumber: 1), durationSeconds: 3.0,
|
||||
narration: "The thermometer resets. Round 1 begins."),
|
||||
.init(id: "highlight-eps", kind: .highlightVertex(messageId: "ε"), durationSeconds: 4.0,
|
||||
narration: "ε is the first vertex of round 1. It references γ as a parent — old parents are perfectly legitimate. Round 1 has weight 1 so far. The threshold has not been met, so round 1 is still open — no message in round 1 has been flagged is_last yet."),
|
||||
|
||||
.init(id: "round-1-open-settle", kind: .settle(label: "Round 1 still open"),
|
||||
durationSeconds: 3.5,
|
||||
narration: "If more messages get written and accepted, weight will accumulate in round 1, and eventually some message will close it. Same arithmetic, same outcome on every honest validator."),
|
||||
|
||||
// Bookkeeping note
|
||||
.init(id: "bookkeeping-1", kind: .bookkeepingNote(text: "Each player keeps their own DAG. Full stop."),
|
||||
durationSeconds: 5.0,
|
||||
narration: "Bookkeeping: every honest player keeps their own DAG of received messages. Nothing else. Nobody tracks who-sent-what-to-whom; the gossip layer fans out and the digest dedupes on the receiver."),
|
||||
.init(id: "bookkeeping-2", kind: .bookkeepingNote(text: "Re-gossip is harmless."),
|
||||
durationSeconds: 4.0,
|
||||
narration: "Re-gossip is harmless. If the same message arrives twice, the receiver detects the duplicate by its hash and drops the second copy. Watch."),
|
||||
|
||||
// Demonstrate duplicate dropping
|
||||
.init(id: "regossip-alpha-ben", kind: .reGossipDuplicate(messageId: "α", recipient: .ben),
|
||||
durationSeconds: 5.5,
|
||||
narration: "Aaron tries to re-send α to Ben. Ben already has α in his view. The envelope arrives, the hash is matched against his local set, the duplicate is detected, and the message is dropped. No tower update. No round-weight change. The system stays consistent."),
|
||||
|
||||
.init(id: "weight-arithmetic", kind: .bookkeepingNote(text: "Weight is arithmetic. Arithmetic doesn't depend on who you ask."),
|
||||
durationSeconds: 6.0,
|
||||
narration: "Weight is arithmetic. Arithmetic doesn't depend on who you ask. As long as two honest validators have the same set of accepted messages, they compute the same round numbers — without exchanging any vote, any negotiation, any consensus message at all."),
|
||||
|
||||
.init(id: "outro", kind: .settle(label: "Rounds derived"),
|
||||
durationSeconds: 4.0,
|
||||
narration: "Rounds are now defined. Next chapter: how a leader is picked from each round, and how the round leaders chain into a total order."),
|
||||
]
|
||||
var t: Double = 0
|
||||
var assigned: [Ch03Beat] = []
|
||||
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) -> Ch03Beat? {
|
||||
let clamped = max(0, min(t, totalDuration))
|
||||
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
|
||||
?? beats.last
|
||||
}
|
||||
|
||||
static func state(at t: Double) -> Ch03WorldState {
|
||||
var w = Ch03WorldState()
|
||||
// All five messages start in round 0; markIsLast/openNewRound
|
||||
// promotes ε to round 1.
|
||||
for mid in messageOrder { w.roundOf[mid] = 0 }
|
||||
|
||||
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: Ch03Beat, progress: Double, isActive: Bool,
|
||||
into w: inout Ch03WorldState
|
||||
) {
|
||||
switch beat.kind {
|
||||
case .settle, .carryForward:
|
||||
break
|
||||
case .introduceWeights:
|
||||
// Permanent fade-in: once visible, stays visible.
|
||||
w.weightsVisible = true
|
||||
case .highlightVertex(let mid):
|
||||
if isActive {
|
||||
w.highlighted = mid
|
||||
}
|
||||
// Even if past, the vertex's weight has been added to the
|
||||
// thermometer.
|
||||
if let weight = messageWeights[mid] {
|
||||
if isActive {
|
||||
w.thermometerWeight += weight * progress
|
||||
} else {
|
||||
w.thermometerWeight += weight
|
||||
}
|
||||
}
|
||||
case .markIsLast(let mid):
|
||||
// Permanent: the message gets the is_last flag once the beat
|
||||
// starts (so the yellow ring appears immediately).
|
||||
w.isLastSet.insert(mid)
|
||||
if isActive {
|
||||
w.highlighted = mid
|
||||
}
|
||||
case .openNewRound(let roundNum):
|
||||
// Permanent: thermometer resets, round counter increments.
|
||||
w.currentRound = roundNum
|
||||
w.thermometerWeight = 0
|
||||
// Promote ε's round number once round 1 opens.
|
||||
for mid in messageOrder where (w.isLastSet.contains(mid) == false && w.roundOf[mid] == 0) {
|
||||
// Messages NOT flagged is_last but in the round-0 batch
|
||||
// stay at round 0; messages AFTER δ go into round 1.
|
||||
if mid == "ε" {
|
||||
w.roundOf[mid] = 1
|
||||
}
|
||||
}
|
||||
case .bookkeepingNote(let text):
|
||||
if isActive {
|
||||
w.bookkeepingText = text
|
||||
}
|
||||
case .reGossipDuplicate(let mid, let recipient):
|
||||
if isActive {
|
||||
w.reGossipFlash = .init(messageId: mid, recipient: recipient,
|
||||
progress: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scene mapping
|
||||
|
||||
enum Ch03Scenes {
|
||||
/// 3 scenes mapping to ~72s of timeline at 1×.
|
||||
static let sceneStarts: [Double] = [0, 23.5, 44.0]
|
||||
static let sceneDurations: [Double] = [23.5, 20.5, 28.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 Ch03Timeline.activeBeat(at: t)?.narration ?? ""
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,10 @@ final class SceneEngine {
|
|||
SceneAddress(chapter: 2, scene: 1): 35.0,
|
||||
SceneAddress(chapter: 2, scene: 2): 22.5,
|
||||
SceneAddress(chapter: 2, scene: 3): 44.0,
|
||||
// Ch03 — rounds (3 scenes mapping to Ch03Timeline windows)
|
||||
SceneAddress(chapter: 3, scene: 0): 23.5,
|
||||
SceneAddress(chapter: 3, scene: 1): 20.5,
|
||||
SceneAddress(chapter: 3, scene: 2): 28.0,
|
||||
]
|
||||
|
||||
/// Effective duration for the current scene, honoring overrides.
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@ struct ImmersiveView: View {
|
|||
return Ch01Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 2:
|
||||
return Ch02Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 3:
|
||||
return Ch03Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
default:
|
||||
return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue