mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
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:
parent
2d353b2208
commit
7291645524
6 changed files with 1130 additions and 1675 deletions
File diff suppressed because it is too large
Load diff
550
CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift
Normal file
550
CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift
Normal 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 body→hash, ✓.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue