Ch01: serial slo-mo timeline + beat-bound narration

Replaces the parallel-beats GossipScript with a strictly serial Ch01
timeline (~75 micro-beats over ~5.5 minutes at 1×). Pedagogical
principle: the learner's eye can only follow serial events, so even
though Crisis is parallel by design we serialize for teaching.

Every micro-event is its own beat with its own narration sentence:
think → select payload → select parents → grind PoW → seal → choose
recipient → fly (one at a time) → arrive → open → read body → read
parents → resolve each parent against the receiver's own local view →
verify hash → accept into view. The asymmetry beat (Carl writes γ
before β reaches him) is the centerpiece of the chapter.

Architecture:

  - `Ch01Timeline.state(at: t)` is a pure function — replaying every
    beat up to t produces the world state. This makes the chapter
    scrub-able and reverse-play-able cleanly later.
  - The 7 existing scenes become navigation labels — windows of the
    same continuous timeline. Arrow keys still let you jump between
    them. Scene durations are now per-scene overrides matching each
    scene's window in the timeline.
  - `Ch02_Graph.swift` rewritten end-to-end (1506 → ~470 lines): one
    timeline-driven render path, no per-scene switch, no dense graph
    rendering for scenes 4-6 (their content folds into the main
    timeline as resolveParent / verifyHash / acceptIntoView beats).
  - `ImmersiveView`'s narration overlay now wraps in its own
    TimelineView so the displayed text updates at frame rate to
    match the active beat. Ch01 reads from
    `Ch01Scenes.narrationAt(scene, localTime)`; other chapters fall
    back to the static per-scene SceneNarrations.
  - `GossipScript.swift` deleted. Old helpers in Ch02_Graph
    (renderStagedBeat, renderHashOneWayVignette,
    renderLocalDAGDeterminismVignette, renderAncestorConeWalk,
    drawCastBubble + view-bubble + composing box) are gone.
  - SceneEngine + SceneVideoCapture duration overrides updated for
    the 7 new scene windows.

Cast members fade onto the stage only when the timeline introduces
them — Ben isn't on screen during "Aaron writes α", Carl isn't on
screen until "Aaron decides to send α to Carl". No ghosted lanes.

Build clean, harness still 55/55 invariants, 0 audit errors,
281 PNGs, 36/36 MP4 written.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-06 22:45:38 +02:00
parent 2d353b2208
commit 7291645524
6 changed files with 1130 additions and 1675 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,550 @@
import SwiftUI
/// Ch01 unified serial timeline.
///
/// Replaces the old `GossipScript` (which had parallel beats α flying to
/// Ben and Carl simultaneously). The pedagogical principle the user
/// articulated: even though Crisis is parallel by design, the LEARNER's
/// eye can only follow serial events. So this timeline strictly serializes
/// every micro-event:
///
/// compose seal choose recipient flight (one at a time)
/// arrive open read body read parents resolve each parent
/// recursively verify hash accept into local view
///
/// Every beat is a deterministic function of timeline position `t`. State
/// at any `t` is whatever you'd get by replaying every beat up to `t`. This
/// makes the timeline scrub-able and reverse-playable cleanly.
///
/// The chapter's 7 scenes are now just navigation labels windows of
/// the same continuous timeline. The actual rendering reads `t` and
/// produces state. Narration is bound to the *currently active beat*, not
/// to scenes.
// MARK: - Types
enum Ch01Cast: String, Hashable, CaseIterable {
case aaron, ben, carl, dave
var role: CastRole {
switch self {
case .aaron: return Cast.aaron
case .ben: return Cast.ben
case .carl: return Cast.carl
case .dave: return Cast.dave
}
}
}
struct Ch01Message: Hashable {
let id: String // "α", "β", "γ"
let author: Ch01Cast
let payload: String
let parents: [String]
let hashShort: String // e.g. "43f3"
}
enum Ch01BeatKind {
/// A cast member fades onto the stage for the first time. Until this
/// beat fires, that cast's lane is invisible they don't yet exist
/// in the story.
case introduce(Ch01Cast)
/// Author looks inward and decides what to do next. Renders as a
/// thought bubble above the cast circle.
case think(Ch01Cast, label: String)
/// Author writes the payload. Composing slot fills the body line.
case selectPayload(messageId: String)
/// Author selects parent references. Composing slot reveals the
/// parents line; in the cast's view, the parent vertices pulse.
case selectParents(messageId: String)
/// Author grinds proof-of-work. Composing slot shows a spinner /
/// "computing PoW" line. This is intentionally long PoW is the
/// real bottleneck in Crisis.
case computePoW(messageId: String)
/// Hash is now sealed. Composing slot shows the hash line filled in
/// and a small lock icon. The author's view permanently gains the
/// message.
case seal(messageId: String)
/// Author chooses a recipient. An arrow points from author to that
/// recipient (and the recipient's lane fades in if not already on
/// stage).
case decideSend(from: Ch01Cast, to: Ch01Cast, messageId: String)
/// Envelope physically flies from sender to recipient over the
/// beat's duration.
case fly(from: Ch01Cast, to: Ch01Cast, messageId: String)
/// Envelope arrives at the recipient small flash, then settles
/// against the recipient's lane.
case arrive(at: Ch01Cast, messageId: String)
/// Recipient opens the envelope (animation: envelope unfolds into
/// the body card).
case open(at: Ch01Cast, messageId: String)
/// Body lines reveal one by one inside the recipient's view bubble.
case readBody(at: Ch01Cast, messageId: String)
/// Recipient sees the parents list. Highlight: parents line glows.
case readParents(at: Ch01Cast, messageId: String)
/// Recipient resolves a single parent reference by looking it up in
/// their own local view. Animation: connector line from the
/// just-arrived envelope's parents-line to the matching vertex in
/// the recipient's view.
case resolveParent(at: Ch01Cast, messageId: String, parentId: String)
/// Recipient hashes the body and confirms it equals the envelope's
/// claimed hash. Animation: SHA arrow bodyhash, .
case verifyHash(at: Ch01Cast, messageId: String)
/// Recipient permanently accepts the message into their local view.
/// Their view bubble grows by one row.
case acceptIntoView(at: Ch01Cast, messageId: String)
/// Quiet beat no new event, just give the eye time to settle.
case settle(label: String)
}
struct Ch01Beat: Identifiable {
let id: String
let kind: Ch01BeatKind
let durationSeconds: Double
/// Narration bound to this beat. The GlassNarration overlay shows
/// this exact text whenever the timeline cursor is inside the beat.
let narration: String
/// Cumulative start time, computed once at timeline-build time.
var startTime: Double = 0
var endTime: Double { startTime + durationSeconds }
}
// MARK: - World state
/// Snapshot of the dramatized world at one moment in time. Pure function
/// of the timeline `t` replaying beats up to `t` produces this.
struct Ch01WorldState {
/// Cast members currently on the stage. Lanes for cast NOT in this
/// set are invisible.
var introduced: Set<Ch01Cast> = []
/// Messages whose seal beat has fired (so their hash exists).
var sealedMessages: Set<String> = []
/// Each cast member's local view: messages they have fully accepted.
var views: [Ch01Cast: Set<String>] = [:]
/// What the active recipient has read of the just-arrived envelope
/// payload (if .readBody fired), parents list (if .readParents
/// fired), each individual resolved parent.
var openEnvelope: OpenEnvelopeState? = nil
/// In-flight envelope animation, if active.
var inFlight: InFlightState? = nil
/// Composing animation, if active.
var composing: ComposingState? = nil
/// "Send decision" arrow from sender to recipient, if active.
var decideArrow: DecideArrowState? = nil
/// Thought bubble above a cast member, if active.
var thought: ThoughtState? = nil
/// The currently active beat plus its progress 0..1. Drives the
/// "spotlight" effect on whichever cast member is the focus.
var activeBeat: Ch01Beat? = nil
var activeProgress: Double = 0
struct OpenEnvelopeState {
let recipient: Ch01Cast
let messageId: String
var bodyRevealed: Bool = false
var parentsRevealed: Bool = false
var resolvedParents: Set<String> = []
var verified: Bool = false
}
struct InFlightState {
let messageId: String
let from: Ch01Cast
let to: Ch01Cast
let progress: Double // 0..1
}
struct ComposingState {
let messageId: String
let author: Ch01Cast
var payloadFilled: Bool = false
var parentsFilled: Bool = false
var powProgress: Double = 0
var sealed: Bool = false
}
struct DecideArrowState {
let from: Ch01Cast
let to: Ch01Cast
let messageId: String
}
struct ThoughtState {
let cast: Ch01Cast
let label: String
}
}
// MARK: - Timeline
enum Ch01Timeline {
/// The three messages in Ch01.
static let messages: [String: Ch01Message] = [
"α": Ch01Message(id: "α", author: .aaron, payload: "step-1-aaron",
parents: [], hashShort: "43f3"),
"β": Ch01Message(id: "β", author: .ben, payload: "step-2-ben",
parents: ["α"], hashShort: "7638"),
"γ": Ch01Message(id: "γ", author: .carl, payload: "step-3-carl",
parents: ["α"], hashShort: "5ce9"),
// Note: γ's parents = [α] only (NOT [α, β]). This is the
// asymmetry beat Carl wrote γ before β arrived at him.
]
/// Build the beat list with cumulative startTimes filled in. Heavy
/// pedagogical pacing: long PoW beats, slow flights, distinct
/// thinking beats, every parent reference resolved one at a time.
static let beats: [Ch01Beat] = {
let raw: [Ch01Beat] = [
// Phase 1: Aaron writes α
.init(id: "intro-aaron", kind: .introduce(.aaron), durationSeconds: 4.0,
narration: "Meet Aaron. He's one of four validators who will eventually share a single ordered history. Right now he's alone on stage."),
.init(id: "aaron-thinks-write", kind: .think(.aaron, label: "Time to write."), durationSeconds: 4.0,
narration: "Aaron decides to write the very first message. There is no global clock — he just chooses to start now."),
.init(id: "aaron-payload", kind: .selectPayload(messageId: "α"), durationSeconds: 4.0,
narration: "First, Aaron picks his payload — the body of the message. He writes 'step-1-aaron'."),
.init(id: "aaron-parents", kind: .selectParents(messageId: "α"), durationSeconds: 3.5,
narration: "Next, Aaron lists parent messages. He references nothing — α is the genesis, the first message ever."),
.init(id: "aaron-pow", kind: .computePoW(messageId: "α"), durationSeconds: 9.0,
narration: "Aaron grinds proof-of-work. He hashes the message header with successive nonces until the hash starts with enough zeros. This is the real cost of producing a message in Crisis."),
.init(id: "aaron-seal", kind: .seal(messageId: "α"), durationSeconds: 3.5,
narration: "Done. The valid hash is 43f3…. From this moment on, the message's name IS its hash. α is sealed."),
.init(id: "aaron-knows-alpha", kind: .acceptIntoView(at: .aaron, messageId: "α"), durationSeconds: 3.0,
narration: "Aaron's local view now contains α — the first vertex on his lifeline."),
// Phase 2: Aaron sends α to Ben
.init(id: "intro-ben", kind: .introduce(.ben), durationSeconds: 3.5,
narration: "Meet Ben — Aaron's first recipient. He fades in on his own lane, ready to listen."),
.init(id: "aaron-decides-ben", kind: .decideSend(from: .aaron, to: .ben, messageId: "α"), durationSeconds: 3.5,
narration: "Aaron decides to send α to Ben first. A choice arrow appears from Aaron's lane toward Ben's."),
.init(id: "alpha-flies-to-ben", kind: .fly(from: .aaron, to: .ben, messageId: "α"), durationSeconds: 11.0,
narration: "α travels through the gossip network. Slow motion: this is the only time-and-distance the protocol cares about."),
.init(id: "alpha-arrives-ben", kind: .arrive(at: .ben, messageId: "α"), durationSeconds: 2.5,
narration: "The envelope reaches Ben's lane. He sees something has arrived — but he hasn't opened it yet."),
.init(id: "ben-opens-alpha", kind: .open(at: .ben, messageId: "α"), durationSeconds: 3.0,
narration: "Ben opens the envelope. The body and metadata become visible to him."),
.init(id: "ben-reads-body-alpha", kind: .readBody(at: .ben, messageId: "α"), durationSeconds: 4.0,
narration: "Ben reads the body line by line: 'step-1-aaron'. This is what Aaron actually said."),
.init(id: "ben-reads-parents-alpha", kind: .readParents(at: .ben, messageId: "α"), durationSeconds: 3.0,
narration: "Ben reads the parents list: empty. So α is a genesis message — no prior context to resolve."),
.init(id: "ben-verifies-alpha", kind: .verifyHash(at: .ben, messageId: "α"), durationSeconds: 4.5,
narration: "Ben hashes the body himself and gets 43f3…. It matches the envelope's claimed hash. The message is authentic. ✓"),
.init(id: "ben-accepts-alpha", kind: .acceptIntoView(at: .ben, messageId: "α"), durationSeconds: 3.0,
narration: "Ben accepts α into his local view. His lifeline now carries α as well: {α}."),
// Phase 3: Aaron sends α to Carl
.init(id: "intro-carl", kind: .introduce(.carl), durationSeconds: 3.5,
narration: "Meet Carl. He fades in on his lane — Aaron is about to send α to him too."),
.init(id: "aaron-decides-carl", kind: .decideSend(from: .aaron, to: .carl, messageId: "α"), durationSeconds: 3.5,
narration: "Aaron now decides to send α to Carl as well. This is the SECOND copy of α — in real Crisis it would fan out simultaneously, but we serialize for clarity."),
.init(id: "alpha-flies-to-carl", kind: .fly(from: .aaron, to: .carl, messageId: "α"), durationSeconds: 11.0,
narration: "α travels to Carl. Different path, possibly different speed — but the same content."),
.init(id: "alpha-arrives-carl", kind: .arrive(at: .carl, messageId: "α"), durationSeconds: 2.5,
narration: "The envelope reaches Carl."),
.init(id: "carl-opens-alpha", kind: .open(at: .carl, messageId: "α"), durationSeconds: 3.0,
narration: "Carl opens it."),
.init(id: "carl-reads-body-alpha", kind: .readBody(at: .carl, messageId: "α"), durationSeconds: 4.0,
narration: "Carl reads the body. Same payload Ben saw: 'step-1-aaron'."),
.init(id: "carl-reads-parents-alpha", kind: .readParents(at: .carl, messageId: "α"), durationSeconds: 3.0,
narration: "Carl reads parents: empty. Same as for Ben."),
.init(id: "carl-verifies-alpha", kind: .verifyHash(at: .carl, messageId: "α"), durationSeconds: 4.5,
narration: "Carl recomputes the hash, matches 43f3…. ✓"),
.init(id: "carl-accepts-alpha", kind: .acceptIntoView(at: .carl, messageId: "α"), durationSeconds: 3.0,
narration: "Carl accepts α. His view: {α}. Three players now share α."),
// Phase 4: Ben writes β (referencing α)
.init(id: "ben-thinks-write", kind: .think(.ben, label: "I should respond."), durationSeconds: 4.0,
narration: "Now Ben decides to write his own message. He has α in his local view, so β can reference it."),
.init(id: "ben-payload", kind: .selectPayload(messageId: "β"), durationSeconds: 4.0,
narration: "Ben picks his payload: 'step-2-ben'."),
.init(id: "ben-parents", kind: .selectParents(messageId: "β"), durationSeconds: 4.5,
narration: "Ben picks parents: α — he saw α before he started writing, so he embeds α's hash in β. This is what 'I saw your message before I spoke' looks like in code."),
.init(id: "ben-pow", kind: .computePoW(messageId: "β"), durationSeconds: 9.0,
narration: "Ben grinds proof-of-work for β. Same cost as Aaron paid for α."),
.init(id: "ben-seal", kind: .seal(messageId: "β"), durationSeconds: 3.5,
narration: "β is sealed. Hash: 7638…."),
.init(id: "ben-knows-beta", kind: .acceptIntoView(at: .ben, messageId: "β"), durationSeconds: 3.0,
narration: "Ben's local view: {α, β}. Two vertices on his lifeline."),
// Phase 5: Ben sends β to Aaron RESOLVE PARENT
.init(id: "ben-decides-aaron", kind: .decideSend(from: .ben, to: .aaron, messageId: "β"), durationSeconds: 3.5,
narration: "Ben sends β back to Aaron first."),
.init(id: "beta-flies-to-aaron", kind: .fly(from: .ben, to: .aaron, messageId: "β"), durationSeconds: 11.0,
narration: "β travels."),
.init(id: "beta-arrives-aaron", kind: .arrive(at: .aaron, messageId: "β"), durationSeconds: 2.5,
narration: "Aaron receives the envelope."),
.init(id: "aaron-opens-beta", kind: .open(at: .aaron, messageId: "β"), durationSeconds: 3.0,
narration: "Aaron opens β."),
.init(id: "aaron-reads-body-beta", kind: .readBody(at: .aaron, messageId: "β"), durationSeconds: 4.0,
narration: "He reads the body: 'step-2-ben'. So Ben said something."),
.init(id: "aaron-reads-parents-beta", kind: .readParents(at: .aaron, messageId: "β"), durationSeconds: 3.5,
narration: "He reads parents: α. So β refers to a message named α."),
.init(id: "aaron-resolves-alpha-from-beta", kind: .resolveParent(at: .aaron, messageId: "β", parentId: "α"), durationSeconds: 5.0,
narration: "Aaron looks up α in his own local view. Yes — he already has α. (It's his own message. He wrote it.) The reference resolves cleanly. ✓"),
.init(id: "aaron-verifies-beta", kind: .verifyHash(at: .aaron, messageId: "β"), durationSeconds: 4.0,
narration: "Aaron hashes β's body, gets 7638…. Matches. ✓"),
.init(id: "aaron-accepts-beta", kind: .acceptIntoView(at: .aaron, messageId: "β"), durationSeconds: 3.0,
narration: "Aaron accepts β. His view: {α, β}."),
// Phase 6: Carl writes γ THE ASYMMETRY BEAT
.init(id: "asymmetry-pause", kind: .settle(label: "Crucial moment ahead"), durationSeconds: 3.5,
narration: "PAY ATTENTION. The next beat is the lesson of this chapter. Carl is about to write his own message — but β has not yet reached him."),
.init(id: "carl-thinks-write", kind: .think(.carl, label: "I have α; let me write."), durationSeconds: 4.5,
narration: "Carl decides to write γ. At this moment his local view contains only α — β is still in flight in the background of the story (we'll get to it)."),
.init(id: "carl-payload", kind: .selectPayload(messageId: "γ"), durationSeconds: 4.0,
narration: "Carl picks his payload: 'step-3-carl'."),
.init(id: "carl-parents", kind: .selectParents(messageId: "γ"), durationSeconds: 5.5,
narration: "Carl picks parents from HIS local view. He has only α. So γ references just α — NOT β. This is the asymmetry: γ does not depend on β. Different validators see different worlds at the moment they speak."),
.init(id: "carl-pow", kind: .computePoW(messageId: "γ"), durationSeconds: 9.0,
narration: "Carl grinds proof-of-work for γ."),
.init(id: "carl-seal", kind: .seal(messageId: "γ"), durationSeconds: 3.5,
narration: "γ is sealed. Hash: 5ce9…."),
.init(id: "carl-knows-gamma", kind: .acceptIntoView(at: .carl, messageId: "γ"), durationSeconds: 3.0,
narration: "Carl's local view: {α, γ}. Notice — he STILL doesn't have β."),
// Phase 7: Carl sends γ to Aaron
.init(id: "carl-decides-aaron", kind: .decideSend(from: .carl, to: .aaron, messageId: "γ"), durationSeconds: 3.5,
narration: "Carl sends γ to Aaron."),
.init(id: "gamma-flies-to-aaron", kind: .fly(from: .carl, to: .aaron, messageId: "γ"), durationSeconds: 10.0,
narration: "γ travels to Aaron."),
.init(id: "gamma-arrives-aaron", kind: .arrive(at: .aaron, messageId: "γ"), durationSeconds: 2.5,
narration: "Aaron receives γ."),
.init(id: "aaron-opens-gamma", kind: .open(at: .aaron, messageId: "γ"), durationSeconds: 3.0,
narration: "Aaron opens γ."),
.init(id: "aaron-reads-body-gamma", kind: .readBody(at: .aaron, messageId: "γ"), durationSeconds: 3.5,
narration: "He reads body: 'step-3-carl'."),
.init(id: "aaron-reads-parents-gamma", kind: .readParents(at: .aaron, messageId: "γ"), durationSeconds: 3.5,
narration: "He reads parents: α (only). Notice — γ does NOT mention β. Aaron now sees evidence of the asymmetry: Carl wrote γ before β reached him."),
.init(id: "aaron-resolves-alpha-from-gamma", kind: .resolveParent(at: .aaron, messageId: "γ", parentId: "α"), durationSeconds: 4.5,
narration: "Aaron resolves the α reference in his local view. ✓"),
.init(id: "aaron-verifies-gamma", kind: .verifyHash(at: .aaron, messageId: "γ"), durationSeconds: 3.5,
narration: "Hash check: 5ce9…. Matches. ✓"),
.init(id: "aaron-accepts-gamma", kind: .acceptIntoView(at: .aaron, messageId: "γ"), durationSeconds: 3.0,
narration: "Aaron's local view: {α, β, γ}. He's the first to hold all three."),
// Phase 8: Ben sends β to Carl (closes the asymmetry gap)
.init(id: "ben-decides-carl", kind: .decideSend(from: .ben, to: .carl, messageId: "β"), durationSeconds: 3.5,
narration: "Ben now sends β to Carl. By the time it gets there, Carl has already written γ — but β is still useful to him."),
.init(id: "beta-flies-to-carl", kind: .fly(from: .ben, to: .carl, messageId: "β"), durationSeconds: 11.0,
narration: "β travels to Carl."),
.init(id: "beta-arrives-carl", kind: .arrive(at: .carl, messageId: "β"), durationSeconds: 2.5,
narration: "Carl receives β — finally."),
.init(id: "carl-opens-beta", kind: .open(at: .carl, messageId: "β"), durationSeconds: 3.0,
narration: "Carl opens β."),
.init(id: "carl-reads-body-beta", kind: .readBody(at: .carl, messageId: "β"), durationSeconds: 3.5,
narration: "Reads body: 'step-2-ben'."),
.init(id: "carl-reads-parents-beta", kind: .readParents(at: .carl, messageId: "β"), durationSeconds: 3.5,
narration: "Reads parents: α."),
.init(id: "carl-resolves-alpha-from-beta", kind: .resolveParent(at: .carl, messageId: "β", parentId: "α"), durationSeconds: 4.0,
narration: "Carl resolves α in his local view. ✓"),
.init(id: "carl-verifies-beta", kind: .verifyHash(at: .carl, messageId: "β"), durationSeconds: 3.5,
narration: "Hash: 7638…. Matches. ✓"),
.init(id: "carl-accepts-beta", kind: .acceptIntoView(at: .carl, messageId: "β"), durationSeconds: 3.0,
narration: "Carl's local view: {α, β, γ}. He has all three now — even though γ doesn't reference β. The DAG records what Carl actually KNEW when he spoke, not what was true elsewhere."),
// Phase 9: Carl sends γ to Ben
.init(id: "carl-decides-ben", kind: .decideSend(from: .carl, to: .ben, messageId: "γ"), durationSeconds: 3.5,
narration: "Carl sends γ to Ben."),
.init(id: "gamma-flies-to-ben", kind: .fly(from: .carl, to: .ben, messageId: "γ"), durationSeconds: 10.0,
narration: "γ travels to Ben."),
.init(id: "gamma-arrives-ben", kind: .arrive(at: .ben, messageId: "γ"), durationSeconds: 2.5,
narration: "Ben receives γ."),
.init(id: "ben-opens-gamma", kind: .open(at: .ben, messageId: "γ"), durationSeconds: 3.0,
narration: "Ben opens γ."),
.init(id: "ben-reads-body-gamma", kind: .readBody(at: .ben, messageId: "γ"), durationSeconds: 3.5,
narration: "Reads body."),
.init(id: "ben-reads-parents-gamma", kind: .readParents(at: .ben, messageId: "γ"), durationSeconds: 3.5,
narration: "Reads parents: α (only). Same evidence Aaron saw — Carl wrote γ before he knew about β."),
.init(id: "ben-resolves-alpha-from-gamma", kind: .resolveParent(at: .ben, messageId: "γ", parentId: "α"), durationSeconds: 4.0,
narration: "Ben resolves α. ✓"),
.init(id: "ben-verifies-gamma", kind: .verifyHash(at: .ben, messageId: "γ"), durationSeconds: 3.5,
narration: "Hash check passes. ✓"),
.init(id: "ben-accepts-gamma", kind: .acceptIntoView(at: .ben, messageId: "γ"), durationSeconds: 3.0,
narration: "Ben's local view: {α, β, γ}."),
// Phase 10: Convergence
.init(id: "convergence", kind: .settle(label: "Converged"), durationSeconds: 8.0,
narration: "All three honest validators now hold the SAME set of messages: {α, β, γ}. Their local DAGs have converged. This is local consensus emerging — not from any vote, but from each player observing what the others said and accepting it after verification. Common knowledge of the events has formed."),
]
// Fill in cumulative startTimes.
var t: Double = 0
var assigned: [Ch01Beat] = []
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
}
/// Find the active beat at timeline position `t`. Returns nil before
/// the first beat or after the last.
static func activeBeat(at t: Double) -> Ch01Beat? {
let clamped = max(0, min(t, totalDuration))
// Linear scan is fine for ~75 beats. Could binary-search if it ever matters.
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
?? beats.last
}
/// Pure function: world state at any timeline position.
static func state(at t: Double) -> Ch01WorldState {
var w = Ch01WorldState()
for cast in Ch01Cast.allCases { w.views[cast] = [] }
let clamped = max(0, min(t, totalDuration))
for beat in beats {
// Beats fully in the past contribute their permanent effects.
// Beats currently active contribute permanent-up-to-now plus
// their ephemeral animation state.
// Beats in the future contribute nothing.
if clamped < beat.startTime { break }
let isActive = clamped < beat.endTime
let progress = isActive
? max(0, min(1, (clamped - beat.startTime) / beat.durationSeconds))
: 1.0
applyBeat(beat, progress: progress, isActive: isActive, into: &w)
if isActive {
w.activeBeat = beat
w.activeProgress = progress
}
}
return w
}
private static func applyBeat(
_ beat: Ch01Beat, progress: Double, isActive: Bool,
into w: inout Ch01WorldState
) {
switch beat.kind {
case .introduce(let cast):
// Permanent once started.
w.introduced.insert(cast)
case .think(let cast, let label):
if isActive {
w.thought = .init(cast: cast, label: label)
}
case .selectPayload(let mid):
// Permanent: composing exists with payload filled (until seal).
ensureComposing(messageId: mid, into: &w)
w.composing?.payloadFilled = true
if isActive { /* current focus */ }
case .selectParents(let mid):
ensureComposing(messageId: mid, into: &w)
w.composing?.parentsFilled = true
case .computePoW(let mid):
ensureComposing(messageId: mid, into: &w)
// PoW progress equals the beat's progress (0..1).
w.composing?.powProgress = progress
case .seal(let mid):
// Permanent: message is sealed; composing finishes.
w.sealedMessages.insert(mid)
// Author "knows" their own message after sealing.
if let msg = messages[mid] {
w.views[msg.author, default: []].insert(mid)
}
// Once sealed, drop the composing state.
if !isActive { w.composing = nil }
else {
ensureComposing(messageId: mid, into: &w)
w.composing?.sealed = true
}
case .decideSend(let from, let to, let mid):
if isActive {
w.decideArrow = .init(from: from, to: to, messageId: mid)
}
case .fly(let from, let to, let mid):
if isActive {
w.inFlight = .init(messageId: mid, from: from, to: to,
progress: progress)
}
case .arrive(let at, let mid):
if isActive {
// The envelope is settling against the recipient's lane.
w.openEnvelope = .init(recipient: at, messageId: mid)
}
case .open(let at, let mid):
if isActive {
w.openEnvelope = .init(recipient: at, messageId: mid)
} else {
// Stays "open" until verify completes handled below.
if w.openEnvelope?.recipient != at || w.openEnvelope?.messageId != mid {
w.openEnvelope = .init(recipient: at, messageId: mid)
}
}
case .readBody(let at, let mid):
ensureOpenEnvelope(at: at, messageId: mid, into: &w)
w.openEnvelope?.bodyRevealed = true
case .readParents(let at, let mid):
ensureOpenEnvelope(at: at, messageId: mid, into: &w)
w.openEnvelope?.parentsRevealed = true
case .resolveParent(let at, let mid, let parentId):
ensureOpenEnvelope(at: at, messageId: mid, into: &w)
w.openEnvelope?.resolvedParents.insert(parentId)
case .verifyHash(let at, let mid):
ensureOpenEnvelope(at: at, messageId: mid, into: &w)
w.openEnvelope?.verified = true
case .acceptIntoView(let at, let mid):
// Permanent: recipient now holds the message.
w.views[at, default: []].insert(mid)
// Once accepted, the open envelope is dismissed.
if !isActive {
if w.openEnvelope?.recipient == at && w.openEnvelope?.messageId == mid {
w.openEnvelope = nil
}
}
case .settle:
break // pure narration / quiet beat
}
// Clear ephemeral state on transition out: if this beat is in the
// past, its ephemeral things should not bleed forward.
if !isActive {
switch beat.kind {
case .think:
if w.thought != nil && w.activeBeat?.id != beat.id {
// Only clear if a later think doesn't override.
w.thought = nil
}
case .decideSend:
if w.decideArrow != nil && w.activeBeat?.id != beat.id {
w.decideArrow = nil
}
case .fly:
if w.inFlight != nil && w.activeBeat?.id != beat.id {
w.inFlight = nil
}
default: break
}
}
}
private static func ensureComposing(messageId: String, into w: inout Ch01WorldState) {
if w.composing?.messageId != messageId {
guard let msg = messages[messageId] else { return }
w.composing = .init(messageId: messageId, author: msg.author)
}
}
private static func ensureOpenEnvelope(at: Ch01Cast, messageId: String, into w: inout Ch01WorldState) {
if w.openEnvelope?.recipient != at || w.openEnvelope?.messageId != messageId {
w.openEnvelope = .init(recipient: at, messageId: messageId)
}
}
}

View file

@ -1,236 +0,0 @@
import SwiftUI
/// Hand-crafted gossip dramatization for Ch01 scene 3.
///
/// The user's brief: "extreme slow motion, players appearing in random ways,
/// message bodies being created and being filled line by line, and then sent
/// out and then they fly very slowly to some guy in the gossip network and
/// also to some other guy. Once the message arrives at a player, the
/// player's perspective bubble changes slowly during the reading."
///
/// The simulation snapshots can't deliver this they're step-aligned and
/// gossip catches up too quickly. So this is a synthetic vignette: a fixed
/// list of `Beat`s that play out over ~30 seconds. At any local time t the
/// rendering computes:
/// - which messages each cast member has CREATED (knows about)
/// - which messages have ARRIVED at each cast member (their local view)
/// - which messages are IN FLIGHT and how far along the path
///
/// Every beat's timing is explicit so a future edit just changes the script.
struct GossipScript {
/// Lifecycle of one staged message.
struct ScriptedMessage: Hashable {
let id: String // e.g. "α", "β", "γ"
let author: CastRoleKey // who created it
let parents: [String] // ids of referenced parents
let payload: String // human-readable label
let hashShort: String // 6-char hash to display
}
/// Reference cast members by stable key so the script doesn't drift if
/// the live `Cast.aaron` etc. instance changes.
enum CastRoleKey: String, Hashable, CaseIterable {
case aaron, ben, carl, dave
var role: CastRole {
switch self {
case .aaron: return Cast.aaron
case .ben: return Cast.ben
case .carl: return Cast.carl
case .dave: return Cast.dave
}
}
}
/// Discrete event types in the dramatization.
enum BeatKind {
/// Author starts physically writing the message body. Lasts
/// `composeDuration` seconds; bytes/parent-hashes appear line by line.
case compose(durationSeconds: Double)
/// The message hash is finalized (PoW completed instantly we don't
/// dramatize the work itself, only its outcome).
case sealHash
/// Author dispatches the message to a target. Hop has its own
/// `flightDuration` so simultaneous fan-out can use different
/// arrival times.
case send(to: CastRoleKey, flightDuration: Double)
/// Recipient absorbs the message into their local view. Lasts
/// `readDuration` seconds during which their bubble grows the entry.
case receive(at: CastRoleKey, readDuration: Double)
}
struct Beat {
let startTime: Double // scene-local seconds
let messageId: String
let kind: BeatKind
}
let messages: [ScriptedMessage]
let beats: [Beat]
let totalDuration: Double
// MARK: - Snapshot computation
/// State of one cast member's local view at time t.
/// `received[id] = absorptionProgress (0..1)`. A message is "fully read"
/// when progress = 1.
struct ViewState {
var received: [String: Double] = [:]
}
/// State of one in-flight (sent but not yet received) message.
struct InFlightMessage {
let message: ScriptedMessage
let from: CastRoleKey
let to: CastRoleKey
let progress: Double // 0 = just sent, 1 = arrived
}
/// State of one composition-in-progress message.
struct ComposingMessage {
let message: ScriptedMessage
let author: CastRoleKey
let progress: Double // 0..1, controls how many lines are filled
let sealed: Bool
}
struct WorldState {
var views: [CastRoleKey: ViewState] = [:]
var inFlight: [InFlightMessage] = []
var composing: [ComposingMessage] = []
/// Messages that have been finalized + sealed (so their hash is known
/// and can be referenced by later messages).
var sealedMessages: Set<String> = []
/// Highlight: the most recently completed beat used to flash the
/// receiver/composer briefly.
var spotlight: (CastRoleKey, BeatKind)?
}
func state(at t: Double) -> WorldState {
var w = WorldState()
for key in CastRoleKey.allCases { w.views[key] = ViewState() }
// For each beat, advance state.
// We must process beats in order; a `receive` beat at time T only
// updates the receiver's view if the corresponding `send` started
// earlier (the script is responsible for that ordering).
for beat in beats {
let elapsed = t - beat.startTime
switch beat.kind {
case .compose(let dur):
guard elapsed >= 0 else { continue }
let progress = min(1.0, elapsed / dur)
if progress < 1.0 {
if let msg = messages.first(where: { $0.id == beat.messageId }) {
w.composing.append(ComposingMessage(
message: msg, author: authorKey(of: beat),
progress: progress, sealed: false
))
w.spotlight = (authorKey(of: beat), beat.kind)
}
}
// After composition completes, the message is "owned" by the
// author but not yet sealed (sealing is its own beat).
case .sealHash:
if elapsed >= 0 {
w.sealedMessages.insert(beat.messageId)
// The author KNOWS their own message the moment it's
// sealed. Without this the author's view bubble shows
// "empty" while their message is in flight, which
// contradicts the lesson (the author is the first to
// know their own message).
if let msg = messages.first(where: { $0.id == beat.messageId }) {
w.views[msg.author]?.received[msg.id] = 1.0
}
}
case .send(let to, let dur):
guard elapsed >= 0 else { continue }
let progress = min(1.0, elapsed / dur)
if let msg = messages.first(where: { $0.id == beat.messageId }) {
if progress < 1.0 {
w.inFlight.append(InFlightMessage(
message: msg, from: msg.author,
to: to, progress: progress
))
}
}
case .receive(let at, let dur):
guard elapsed >= 0 else { continue }
let progress = min(1.0, elapsed / dur)
w.views[at]?.received[beat.messageId] = progress
if progress < 1.0 {
w.spotlight = (at, beat.kind)
}
}
}
return w
}
private func authorKey(of beat: Beat) -> CastRoleKey {
messages.first(where: { $0.id == beat.messageId })?.author ?? .aaron
}
// MARK: - Default script
/// The Ch01 scene-3 dramatization. Total ~28 seconds, designed to fit
/// within an extended scene duration when localTime is unconstrained.
/// Beats:
/// t= 0.5: Aaron starts composing α (3s)
/// t= 3.5: α sealed
/// t= 4.0: Aaron sends α to Ben (4s flight)
/// t= 4.0: Aaron sends α to Carl (5s flight)
/// t= 8.0: Ben receives α (1.5s read)
/// t= 9.0: Carl receives α (1.5s read)
/// t=10.5: Ben starts composing β (referencing α) (3s)
/// t=13.5: β sealed
/// t=14.0: Ben sends β to Aaron (3.5s flight)
/// t=14.0: Ben sends β to Carl (4s flight)
/// t=15.0: Carl starts composing γ (referencing α only Carl has not
/// received β yet) (3s)
/// t=18.0: γ sealed
/// t=18.0: Aaron receives β (1.5s)
/// t=18.5: Carl sends γ to Aaron (3s flight)
/// t=18.5: Carl sends γ to Ben (3s flight)
/// t=18.0: Carl receives β (1.5s) by this point Carl already sent γ
/// without referencing β; the asymmetry is the lesson
/// t=21.5: Aaron receives γ
/// t=21.5: Ben receives γ
/// t=23.5: All three views have {α, β, γ}
static let ch01 = GossipScript(
messages: [
ScriptedMessage(id: "α", author: .aaron, parents: [],
payload: "step-1-aaron",
hashShort: "43f3"),
ScriptedMessage(id: "β", author: .ben, parents: ["α"],
payload: "step-2-ben",
hashShort: "7638"),
ScriptedMessage(id: "γ", author: .carl, parents: ["α"],
payload: "step-3-carl",
hashShort: "5ce9"),
],
beats: [
Beat(startTime: 0.5, messageId: "α", kind: .compose(durationSeconds: 3.0)),
Beat(startTime: 3.5, messageId: "α", kind: .sealHash),
Beat(startTime: 4.0, messageId: "α", kind: .send(to: .ben, flightDuration: 4.0)),
Beat(startTime: 4.0, messageId: "α", kind: .send(to: .carl, flightDuration: 5.0)),
Beat(startTime: 8.0, messageId: "α", kind: .receive(at: .ben, readDuration: 1.5)),
Beat(startTime: 9.0, messageId: "α", kind: .receive(at: .carl, readDuration: 1.5)),
Beat(startTime: 10.5, messageId: "β", kind: .compose(durationSeconds: 3.0)),
Beat(startTime: 13.5, messageId: "β", kind: .sealHash),
Beat(startTime: 14.0, messageId: "β", kind: .send(to: .aaron, flightDuration: 3.5)),
Beat(startTime: 14.0, messageId: "β", kind: .send(to: .carl, flightDuration: 4.0)),
// Carl starts composing γ at 15s only references α because β
// hasn't arrived yet. This is the punch line of the chapter:
// async means simultaneous-yet-different views.
Beat(startTime: 15.0, messageId: "γ", kind: .compose(durationSeconds: 3.0)),
Beat(startTime: 17.5, messageId: "β", kind: .receive(at: .aaron, readDuration: 1.5)),
Beat(startTime: 18.0, messageId: "γ", kind: .sealHash),
Beat(startTime: 18.0, messageId: "β", kind: .receive(at: .carl, readDuration: 1.5)),
Beat(startTime: 18.5, messageId: "γ", kind: .send(to: .aaron, flightDuration: 3.0)),
Beat(startTime: 18.5, messageId: "γ", kind: .send(to: .ben, flightDuration: 3.0)),
Beat(startTime: 21.5, messageId: "γ", kind: .receive(at: .aaron, readDuration: 1.5)),
Beat(startTime: 21.5, messageId: "γ", kind: .receive(at: .ben, readDuration: 1.5)),
],
totalDuration: 24.0
)
}

View file

@ -30,11 +30,19 @@ final class SceneEngine {
private var advanceGeneration: Int = 0
let sceneDuration: Double = 8.0
/// Per-(chapter,scene) duration overrides. Some scenes notably the
/// Ch01 scene-3 slow-motion gossip dramatization can't compress into
/// 8 seconds without losing the pedagogy. List them explicitly here.
/// Per-(chapter,scene) duration overrides. Ch01's 7 scenes are now
/// windows of one continuous serial timeline (`Ch01Timeline`), so each
/// scene's duration is the duration of its window. The total Ch01
/// runtime at 1× 326 seconds this is intentional pedagogical
/// slo-mo; speed it up with `adjustSpeed`.
private static let durationOverrides: [SceneAddress: Double] = [
SceneAddress(chapter: 1, scene: 3): 24.0 // gossip dramatization
SceneAddress(chapter: 1, scene: 0): 69.0, // Aaron writes α + sends to Ben
SceneAddress(chapter: 1, scene: 1): 38.0, // α to Carl
SceneAddress(chapter: 1, scene: 2): 67.5, // Ben writes β + sends to Aaron
SceneAddress(chapter: 1, scene: 3): 33.0, // Carl writes γ asymmetry
SceneAddress(chapter: 1, scene: 4): 37.0, // γ to Aaron
SceneAddress(chapter: 1, scene: 5): 37.5, // β to Carl
SceneAddress(chapter: 1, scene: 6): 44.5, // γ to Ben + convergence
]
/// Effective duration for the current scene, honoring overrides.

View file

@ -23,11 +23,19 @@ enum SceneVideoCapture {
/// auto-advance interval so each clip captures one nominal play-through.
static let durationSeconds: Double = 8.0
/// Per-scene-address duration overrides clips for these scenes run
/// longer so the scripted dramatization plays in full. Mirror this to
/// Per-scene-address duration overrides. Mirrors
/// `SceneEngine.durationOverrides` so live and capture stay in sync.
/// Ch01 scenes carry the full serial timeline; clips for them are
/// long but that's intentional they're meant for inspection of
/// every micro-beat.
static let durationOverrides: [SceneAddress: Double] = [
SceneAddress(chapter: 1, scene: 3): 24.0 // Ch1.3 gossip script
SceneAddress(chapter: 1, scene: 0): 69.0,
SceneAddress(chapter: 1, scene: 1): 38.0,
SceneAddress(chapter: 1, scene: 2): 67.5,
SceneAddress(chapter: 1, scene: 3): 33.0,
SceneAddress(chapter: 1, scene: 4): 37.0,
SceneAddress(chapter: 1, scene: 5): 37.5,
SceneAddress(chapter: 1, scene: 6): 44.5,
]
static func durationFor(_ address: SceneAddress) -> Double {

View file

@ -47,25 +47,31 @@ struct ImmersiveView: View {
.background(.black)
}
// Narration overlay bottom left (dimmed while inspecting)
VStack {
Spacer()
HStack {
GlassNarration(
title: sceneTitle,
narration: sceneNarration,
chapterTitle: engine.currentChapter.title,
chapterIndex: engine.address.chapter,
sceneIndex: engine.address.scene,
sceneCount: engine.currentChapter.sceneCount,
globalSceneIndex: engine.address.globalIndex,
totalScenes: AllChapters.totalScenes,
isExpanded: $narrationExpanded
)
// Narration overlay bottom left (dimmed while inspecting).
// Wrapped in its OWN TimelineView so the displayed text
// updates at frame rate as the active beat changes (Ch01 is
// beat-bound; other chapters fall back to per-scene text).
TimelineView(.animation(minimumInterval: 1.0 / 30)) { timeline in
let live = engine.localTime(at: timeline.date)
VStack {
Spacer()
HStack {
GlassNarration(
title: sceneTitle,
narration: liveNarration(localTime: live),
chapterTitle: engine.currentChapter.title,
chapterIndex: engine.address.chapter,
sceneIndex: engine.address.scene,
sceneCount: engine.currentChapter.sceneCount,
globalSceneIndex: engine.address.globalIndex,
totalScenes: AllChapters.totalScenes,
isExpanded: $narrationExpanded
)
Spacer()
}
.padding(.leading, 20)
.padding(.bottom, 80)
}
.padding(.leading, 20)
.padding(.bottom, 80)
}
.opacity(inspection.isActive ? 0 : 1)
@ -143,4 +149,15 @@ struct ImmersiveView: View {
let addr = engine.address
return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene)
}
/// Beat-bound narration for chapters that have a serial timeline
/// (currently only Ch01); falls back to scene-bound narration for
/// every other chapter.
private func liveNarration(localTime: Double) -> String {
let addr = engine.address
if addr.chapter == 1 {
return Ch01Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
}
return SceneNarrations.narration(chapter: addr.chapter, scene: addr.scene)
}
}