Skip to main content

brink_runtime/
session.rs

1//! Story sessions: a journaling, replayable wrapper around [`Story`].
2//!
3//! [`StorySession`] *composes* a [`Story`] (which itself wraps a
4//! [`FlowInstance`](crate::FlowInstance) + [`World`](crate::World)) with a
5//! serializable [`SessionJournal`]. The VM never learns about journaling — the
6//! journal observes inputs at the session boundary (the same place the VM
7//! receives them), so instrumentation composes instead of threading an
8//! `if observer` branch through the stepping hot loop. This generalizes the
9//! in-memory [`ReplayRecorder`](crate::ReplayRecorder): where the recorder
10//! captured only external results for hot-reload, the journal captures every
11//! *input* that entered the VM (start, choices, externals, mutations) as
12//! durable, serde-serializable data.
13//!
14//! Consumers:
15//! - **`bevy-brink`** — first-class sessions/replay/save-load.
16//! - **`brink-web`** — wasm bindings expose [`StorySession`] on the web.
17//! - **`@brink/studio-store`** — `LocalSessionProvider` migrates onto this.
18//!
19//! There is no JS-side journal: the journal serializes to JSON via serde and
20//! that JSON is the durable save artifact. See `docs/story-session-spec.md`
21//! (#370, plus the snapshot half of #371).
22//!
23//! ## Turn-boundary contract
24//!
25//! `set_var` / `go_to_path` / `load_state` are **turn-boundary only**. The
26//! session rejects them mid-turn (status [`Active`](crate::StoryStatus::Active),
27//! i.e. more content is pending) with
28//! [`SessionError::MutationMidTurn`], rather than queuing them. This is the
29//! documented "one behavior" the spec permits — reject, not queue. A caller
30//! drains the current turn (to `Done`/`Choices`/`End`) before mutating, which
31//! keeps the journal event order unambiguous. The schema reserves a per-event
32//! `anchor` so exact mid-turn replay can arrive additively without a format
33//! break.
34//!
35//! ## Escape hatch
36//!
37//! [`StorySession::story`] / [`StorySession::story_mut`] expose the wrapped
38//! [`Story`]. Anything done through them **bypasses the journal** — the
39//! documented journal-bypass contract. Foreign / shared flows (#200) reached
40//! this way never journal, matching the "journaling window" gate: only the
41//! session's own `advance` / `choose` / `resolve_external` frames record.
42
43use alloc::borrow::ToOwned;
44use alloc::boxed::Box;
45use alloc::collections::{BTreeMap, VecDeque};
46use alloc::string::{String, ToString};
47use alloc::vec::Vec;
48use core::cell::RefCell;
49use core::mem;
50
51use brink_format::{LoadReport, SaveState, Value};
52use serde::{Deserialize, Serialize};
53
54use crate::error::RuntimeError;
55use crate::rng::{FastRng, StoryRng};
56use crate::story::Story;
57use crate::story::{
58    ExternalFnHandler, ExternalResult, FallbackHandler, Step, StepOutcome, StoryStatus,
59};
60
61/// Current [`SessionJournal`] format version.
62pub const SESSION_JOURNAL_VERSION: u32 = 1;
63
64/// Upper bound on journal events (unbounded-growth guard, mirroring
65/// [`RECORDING_CAP`](crate::RECORDING_CAP)). Beyond it, appends are dropped and
66/// [`SessionJournal::truncated`] is set — the journal degrades honestly and
67/// restore falls back to the embedded [`checkpoint`](SessionJournal::checkpoint).
68pub const SESSION_JOURNAL_CAP: usize = 65_536;
69
70// ── Journal ──────────────────────────────────────────────────────────────────
71
72/// One ordered log of every input that entered the VM during a session, plus a
73/// terminal fast-restore [`checkpoint`](Self::checkpoint).
74///
75/// Serde-serializable; the canonical durable save artifact. Values serialize
76/// **tagged** (via [`Value`]'s derived enum representation and
77/// [`SaveState`]'s `BTreeMap<String, Value>`) — no lossy `List`/`Divert` → null
78/// mapping. Deterministic: event order is insertion order; embedded maps are
79/// `BTreeMap`.
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81pub struct SessionJournal {
82    /// Format version (see [`SESSION_JOURNAL_VERSION`]).
83    pub version: u32,
84    /// Checksum of the program this journal was recorded against, so replay can
85    /// detect a recompile and decide fast-restore vs full replay.
86    pub program_checksum: u32,
87    /// RNG seed applied at session start, if the host seeded one.
88    pub seed: Option<u64>,
89    /// Ordered inputs, in the order they entered the VM.
90    pub events: Vec<JournalEvent>,
91    /// Set when the cap was hit or a divergence truncated the log.
92    pub truncated: bool,
93    /// Fast-restore: terminal game-state snapshot (ruling: embedded `SaveState`
94    /// in v1). Present once the session has produced state worth checkpointing.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub checkpoint: Option<SaveState>,
97}
98
99impl SessionJournal {
100    /// A fresh, empty journal bound to `program_checksum`.
101    #[must_use]
102    pub fn new(program_checksum: u32, seed: Option<u64>) -> Self {
103        Self {
104            version: SESSION_JOURNAL_VERSION,
105            program_checksum,
106            seed,
107            events: Vec::new(),
108            truncated: false,
109            checkpoint: None,
110        }
111    }
112
113    /// Append `event`, respecting [`SESSION_JOURNAL_CAP`]. Beyond the cap the
114    /// event is dropped and [`truncated`](Self::truncated) is set.
115    fn push(&mut self, event: JournalEvent) {
116        if self.events.len() >= SESSION_JOURNAL_CAP {
117            self.truncated = true;
118            return;
119        }
120        self.events.push(event);
121    }
122
123    /// Number of recorded events.
124    #[must_use]
125    pub fn len(&self) -> usize {
126        self.events.len()
127    }
128
129    /// Whether nothing has been recorded.
130    #[must_use]
131    pub fn is_empty(&self) -> bool {
132        self.events.is_empty()
133    }
134}
135
136/// One input that entered the VM.
137///
138/// The reserved `anchor` / `flow` dimensions are serialized (as `Option`, both
139/// defaulting to `None`) but **not interpreted** in v1 — they let mid-turn
140/// anchoring (`anchor`) and multi-flow journaling (`flow`) arrive additively
141/// without a format break. See the module-level turn-boundary contract.
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143pub struct JournalEvent {
144    /// The input kind + payload.
145    pub kind: EventKind,
146    /// Reserved: per-event position ordinal for future mid-turn anchoring.
147    /// Serialized, never interpreted in v1.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub anchor: Option<u64>,
150    /// Reserved: flow tag for future multi-flow journaling. v1 is
151    /// default-flow-only. Serialized, never interpreted in v1.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub flow: Option<String>,
154}
155
156impl JournalEvent {
157    /// A v1 event with the reserved dimensions left `None`.
158    #[must_use]
159    pub fn new(kind: EventKind) -> Self {
160        Self {
161            kind,
162            anchor: None,
163            flow: None,
164        }
165    }
166}
167
168/// The kind + payload of a [`JournalEvent`].
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(tag = "type", rename_all = "snake_case")]
171pub enum EventKind {
172    /// Session start / play-from-here. `path` is `None` for the default root
173    /// entry, `Some` for a `ChoosePathString` start.
174    Start {
175        #[serde(default, skip_serializing_if = "Option::is_none")]
176        path: Option<String>,
177        #[serde(default, skip_serializing_if = "Vec::is_empty")]
178        args: Vec<Value>,
179    },
180    /// A choice selection. `label` is the choice text as seen (advisory — used
181    /// only for label-drift warnings, never as the selection key).
182    Choice {
183        index: u32,
184        #[serde(default, skip_serializing_if = "Option::is_none")]
185        label: Option<String>,
186    },
187    /// An external-function result, captured where the session's own frame
188    /// received it (the journaling-window gate).
189    External {
190        name: String,
191        #[serde(default, skip_serializing_if = "Vec::is_empty")]
192        args: Vec<Value>,
193        result: Value,
194    },
195    /// A host `set_var` (turn-boundary only).
196    SetVar { name: String, value: Value },
197    /// A host `go_to_path` / `ChoosePathString` (turn-boundary only).
198    GoToPath {
199        path: String,
200        #[serde(default, skip_serializing_if = "Vec::is_empty")]
201        args: Vec<Value>,
202    },
203    /// A host `load_state` (turn-boundary only).
204    LoadState { state: SaveState },
205    /// A journaled `call_function`. The function's *own* externals are resolved
206    /// through the isolated (non-journaling) handler path — only the top-level
207    /// call is journaled here.
208    Call {
209        name: String,
210        #[serde(default, skip_serializing_if = "Vec::is_empty")]
211        args: Vec<Value>,
212    },
213}
214
215// ── Replay ───────────────────────────────────────────────────────────────────
216
217/// How replay obtains external values.
218#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
219pub enum ExternalReplayMode {
220    /// Default. Serve `External` events from the journal (recorded results). No
221    /// effect re-fires; reads stay faithful.
222    #[default]
223    Recorded,
224    /// Re-invoke externals live against the supplied handler. A handler that
225    /// defers ([`ExternalResult::Pending`]) parks the replay as
226    /// [`ReplayOutcome::Failed`] with [`FailReason::AwaitingExternal`]; resume
227    /// via [`StorySession::continue_replay`].
228    Live,
229}
230
231/// Outcome of replaying a journal prefix against a program.
232///
233/// Typed, never silent, never panicking. `Serialize`/`Deserialize` (tagged
234/// `type`, `snake_case`) so wasm/other bindings can mirror this shape verbatim
235/// instead of hand-rolling a parallel JS type — matches the `EventKind`/
236/// `JournalEvent` convention above.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(tag = "type", rename_all = "snake_case")]
239pub enum ReplayOutcome {
240    /// The prefix replayed successfully. `warnings` collects soft issues (e.g.
241    /// choice label drift at a matching index).
242    Replayed { warnings: Vec<ReplayWarning> },
243    /// Replay diverged at `at_event`: the recorded event could not be applied
244    /// against the current program. The journal is truncated at that point and
245    /// the session is parked at the reached position.
246    Diverged {
247        at_event: usize,
248        expected: Box<JournalEvent>,
249        found: DivergenceFound,
250    },
251    /// Replay failed at `at_event` for a non-divergence reason.
252    Failed { at_event: usize, reason: FailReason },
253}
254
255/// A non-fatal replay observation.
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257#[serde(tag = "type", rename_all = "snake_case")]
258pub enum ReplayWarning {
259    /// A choice replayed by index, but its recorded label differs from the
260    /// label now presented at that index (a soft signal the story text drifted
261    /// under the same choice ordering).
262    ChoiceLabelDrift {
263        at_event: usize,
264        index: u32,
265        recorded: String,
266        found: String,
267    },
268}
269
270/// What was found at a divergence point instead of the recorded event.
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[serde(tag = "type", rename_all = "snake_case")]
273pub enum DivergenceFound {
274    /// A choice index the current program does not present (out of range).
275    ChoiceIndexOutOfRange { index: u32, available: usize },
276    /// The session was not waiting for a choice when a `Choice` event replayed.
277    NotWaitingForChoice,
278    /// A path that no longer resolves in the current program.
279    UnknownPath { path: String },
280    /// The event kind cannot be applied from the reached state (e.g. a `Start`
281    /// after the session already started).
282    UnexpectedEvent,
283}
284
285/// Why replay stopped without diverging.
286///
287/// `RuntimeError` is a struct variant (`{ message: String }`), not a newtype
288/// (`RuntimeError(String)`) — serde's internally-tagged representation
289/// (`#[serde(tag = "type")]`) cannot serialize a newtype variant wrapping a
290/// non-map payload like a bare `String`.
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292#[serde(tag = "type", rename_all = "snake_case")]
293pub enum FailReason {
294    /// A runtime error surfaced during stepping.
295    RuntimeError { message: String },
296    /// A step/line budget was exceeded (the caller can restart fresh).
297    Budget,
298    /// Live replay hit a deferred external and parked. Resolve it and call
299    /// [`StorySession::continue_replay`].
300    AwaitingExternal { name: String },
301}
302
303// ── Snapshot / diff ──────────────────────────────────────────────────────────
304
305/// A typed, name-resolved snapshot of a session's game state.
306///
307/// A NEW typed serialization path — distinct from the string-valued
308/// [`DebugSnapshot`](crate::DebugSnapshot). Globals keep their [`Value`]s (list
309/// membership included via [`SnapshotList`]); callstack is summarized to frame
310/// kinds + resolved locations. Deterministic (`BTreeMap` / sorted vectors).
311#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
312pub struct StateSnapshot {
313    /// Global variables by name, typed. `BTreeMap` for determinism.
314    pub globals: BTreeMap<String, Value>,
315    /// Resolved list memberships for any `List`-valued global, keyed by
316    /// variable name (item names, sorted). Complements `globals` for consumers
317    /// that want membership without decoding `DefinitionId`s.
318    pub lists: BTreeMap<String, SnapshotList>,
319    /// Global turn index.
320    pub turn_index: u32,
321    /// Per-knot/stitch visit counts, keyed by resolved path, sorted.
322    ///
323    /// Path-keyed projection: counts for scopes with no resolvable author
324    /// path (anonymous counted containers — gathers, choice points — keyed
325    /// only by hash id) are **omitted** here. This is the known projection
326    /// limit of the typed snapshot; the full id-keyed counts remain available
327    /// via [`StorySession::save_state`].
328    pub visit_counts: BTreeMap<String, u32>,
329    /// Per-knot/stitch turn-since counts, keyed by resolved path, sorted.
330    /// Same path-keyed projection limit as
331    /// [`visit_counts`](Self::visit_counts).
332    pub turn_counts: BTreeMap<String, u32>,
333    /// Callstack summary of the default flow, innermost frame first.
334    pub call_stack: Vec<SnapshotFrame>,
335    /// Execution status.
336    pub status: SnapshotStatus,
337}
338
339/// Resolved membership of a `List`-valued global.
340#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
341pub struct SnapshotList {
342    /// Active item names, sorted for determinism.
343    pub items: Vec<String>,
344}
345
346/// One summarized call frame.
347#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
348pub struct SnapshotFrame {
349    /// Frame kind: `root` / `function` / `tunnel` / `thread` / `external` / `eval`.
350    pub kind: String,
351    /// Nearest named container for this frame, if resolvable.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub location: Option<String>,
354    /// Number of temporaries in this frame.
355    pub temps: usize,
356}
357
358/// Session execution status, serde-friendly.
359#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub enum SnapshotStatus {
362    Active,
363    WaitingForChoice,
364    Done,
365    Ended,
366}
367
368impl From<StoryStatus> for SnapshotStatus {
369    fn from(s: StoryStatus) -> Self {
370        match s {
371            StoryStatus::Active => Self::Active,
372            StoryStatus::WaitingForChoice => Self::WaitingForChoice,
373            StoryStatus::Done => Self::Done,
374            StoryStatus::Ended => Self::Ended,
375        }
376    }
377}
378
379/// A pure diff between two [`StateSnapshot`]s (see [`diff`]).
380#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
381pub struct StateDiff {
382    /// Globals present in `b` but not `a`.
383    pub added_globals: BTreeMap<String, Value>,
384    /// Globals present in `a` but not `b`.
385    pub removed_globals: BTreeMap<String, Value>,
386    /// Globals whose value changed, mapped to `(before, after)`.
387    pub changed_globals: BTreeMap<String, (Value, Value)>,
388    /// Per-list membership deltas for lists that changed, keyed by var name.
389    pub list_deltas: BTreeMap<String, ListDelta>,
390    /// `turn_index` delta `(before, after)` if it changed.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub turn_index: Option<(u32, u32)>,
393    /// Callstack frames pushed in `b` relative to `a` (by innermost-first
394    /// comparison): frames appended beyond the common prefix.
395    pub pushed_frames: Vec<SnapshotFrame>,
396    /// Callstack frames popped in `b` relative to `a`.
397    pub popped_frames: Vec<SnapshotFrame>,
398}
399
400impl StateDiff {
401    /// Whether the two snapshots were identical in every compared dimension.
402    #[must_use]
403    pub fn is_empty(&self) -> bool {
404        self.added_globals.is_empty()
405            && self.removed_globals.is_empty()
406            && self.changed_globals.is_empty()
407            && self.list_deltas.is_empty()
408            && self.turn_index.is_none()
409            && self.pushed_frames.is_empty()
410            && self.popped_frames.is_empty()
411    }
412}
413
414/// Membership delta for one list-valued global.
415#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
416pub struct ListDelta {
417    /// Item names present in `b` but not `a`, sorted.
418    pub added: Vec<String>,
419    /// Item names present in `a` but not `b`, sorted.
420    pub removed: Vec<String>,
421}
422
423/// Pure diff of two snapshots. `a` is "before", `b` is "after".
424#[must_use]
425pub fn diff(a: &StateSnapshot, b: &StateSnapshot) -> StateDiff {
426    let mut d = StateDiff::default();
427
428    for (name, av) in &a.globals {
429        match b.globals.get(name) {
430            None => {
431                d.removed_globals.insert(name.clone(), av.clone());
432            }
433            Some(bv) if bv != av => {
434                d.changed_globals
435                    .insert(name.clone(), (av.clone(), bv.clone()));
436            }
437            Some(_) => {}
438        }
439    }
440    for (name, bv) in &b.globals {
441        if !a.globals.contains_key(name) {
442            d.added_globals.insert(name.clone(), bv.clone());
443        }
444    }
445
446    // List deltas over the union of list-valued globals.
447    let mut list_names: Vec<&String> = a.lists.keys().chain(b.lists.keys()).collect();
448    list_names.sort_unstable();
449    list_names.dedup();
450    for name in list_names {
451        let empty = SnapshotList { items: Vec::new() };
452        let al = a.lists.get(name).unwrap_or(&empty);
453        let bl = b.lists.get(name).unwrap_or(&empty);
454        if al == bl {
455            continue;
456        }
457        let added: Vec<String> = bl
458            .items
459            .iter()
460            .filter(|i| !al.items.contains(i))
461            .cloned()
462            .collect();
463        let removed: Vec<String> = al
464            .items
465            .iter()
466            .filter(|i| !bl.items.contains(i))
467            .cloned()
468            .collect();
469        if !added.is_empty() || !removed.is_empty() {
470            d.list_deltas
471                .insert(name.clone(), ListDelta { added, removed });
472        }
473    }
474
475    if a.turn_index != b.turn_index {
476        d.turn_index = Some((a.turn_index, b.turn_index));
477    }
478
479    // Callstack: compare from the outermost (root) end. The frames are stored
480    // innermost-first, so reverse to find the common outer prefix.
481    let a_outer: Vec<&SnapshotFrame> = a.call_stack.iter().rev().collect();
482    let b_outer: Vec<&SnapshotFrame> = b.call_stack.iter().rev().collect();
483    let common = a_outer
484        .iter()
485        .zip(b_outer.iter())
486        .take_while(|(x, y)| x == y)
487        .count();
488    // Frames beyond the common prefix in b were pushed; in a were popped.
489    d.pushed_frames = b_outer[common..].iter().map(|f| (*f).clone()).collect();
490    d.popped_frames = a_outer[common..].iter().map(|f| (*f).clone()).collect();
491
492    d
493}
494
495// ── Errors ───────────────────────────────────────────────────────────────────
496
497/// Errors from session-level operations (distinct from VM [`RuntimeError`]s,
498/// which are wrapped).
499#[derive(Debug, thiserror::Error)]
500pub enum SessionError {
501    /// A turn-boundary-only mutation (`set_var` / `go_to_path` / `load_state`)
502    /// was attempted mid-turn (status `Active`). Drain the turn first.
503    #[error(
504        "mutation `{op}` attempted mid-turn; set_var/go_to_path/load_state are turn-boundary only"
505    )]
506    MutationMidTurn { op: &'static str },
507    /// The program checksum in a journal does not match the program being
508    /// restored/replayed against, and no fast-restore checkpoint was usable.
509    #[error("journal program checksum {journal} does not match program {program}")]
510    ChecksumMismatch { journal: u32, program: u32 },
511    /// A wrapped VM error.
512    #[error(transparent)]
513    Runtime(#[from] RuntimeError),
514}
515
516// ── Journaling handler ───────────────────────────────────────────────────────
517
518/// Composes a caller's [`ExternalFnHandler`] and journals every inline-resolved
519/// external where the session's own frame receives it. Generalizes
520/// [`RecordingHandler`](crate::RecordingHandler) from in-memory recording to the
521/// durable journal.
522///
523/// Deferred externals ([`ExternalResult::Pending`]) resolve out-of-band; the
524/// session journals those in [`StorySession::resolve_external`] where it has the
525/// name/args/result.
526struct JournalingHandler<'a, H: ExternalFnHandler + ?Sized> {
527    inner: &'a H,
528    // Interior-mutability: the trait method is `&self`, but we need to append.
529    sink: RefCell<&'a mut Vec<(String, Vec<Value>, Value)>>,
530}
531
532impl<'a, H: ExternalFnHandler + ?Sized> JournalingHandler<'a, H> {
533    fn new(inner: &'a H, sink: &'a mut Vec<(String, Vec<Value>, Value)>) -> Self {
534        Self {
535            inner,
536            sink: RefCell::new(sink),
537        }
538    }
539}
540
541impl<H: ExternalFnHandler + ?Sized> ExternalFnHandler for JournalingHandler<'_, H> {
542    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
543        let result = self.inner.call(name, args);
544        if let ExternalResult::Resolved(value) = &result {
545            self.sink
546                .borrow_mut()
547                .push((name.to_owned(), args.to_vec(), value.clone()));
548        }
549        result
550    }
551}
552
553/// Serves external values from a recorded journal prefix during replay
554/// (`ExternalReplayMode::Recorded`). Consumes `External` events in order; on
555/// mismatch it falls through to the ink fallback body (never re-invokes).
556struct RecordedReplayHandler<'a> {
557    // (name, args, result) queue, consumed front-to-back.
558    queue: RefCell<&'a mut VecDeque<(String, Vec<Value>, Value)>>,
559}
560
561impl ExternalFnHandler for RecordedReplayHandler<'_> {
562    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
563        let mut q = self.queue.borrow_mut();
564        match q.front() {
565            Some((n, a, _)) if n == name && a.as_slice() == args => {
566                let (_, _, result) = q.pop_front().unwrap_or_else(|| {
567                    // Unreachable: we just matched `front`. Fall back safely.
568                    (String::new(), Vec::new(), Value::Null)
569                });
570                ExternalResult::Resolved(result)
571            }
572            _ => ExternalResult::Fallback,
573        }
574    }
575}
576
577// ── Session ──────────────────────────────────────────────────────────────────
578
579/// A journaling, replayable session wrapping a [`Story`].
580///
581/// Owns a [`Story`] + a [`SessionJournal`]. Stepping mirrors
582/// [`Story::advance_with`] exactly ([`StepOutcome`]), recording inputs at the
583/// session boundary. The wrapped story is reachable via [`story`](Self::story) /
584/// [`story_mut`](Self::story_mut) for the documented journal-bypass escape
585/// hatch.
586pub struct StorySession<R: StoryRng = FastRng> {
587    story: Story<R>,
588    journal: SessionJournal,
589    started: bool,
590    /// The un-replayed tail of an in-progress replay that parked on a deferred
591    /// external ([`FailReason::AwaitingExternal`]). `Some` only between the
592    /// park and the [`continue_replay`](Self::continue_replay) that resumes
593    /// it — resuming consumes the tail from this cursor instead of dropping
594    /// the remaining recorded inputs.
595    pending_replay: Option<PendingReplay>,
596}
597
598/// Cursor state for a parked, resumable replay: the remaining source events
599/// (with their original indices, for `at_event` reporting), the
600/// recorded-externals queue, the external mode, warnings accumulated so far,
601/// and the source journal's checkpoint to carry over on completion.
602struct PendingReplay {
603    /// `(original_source_index, event)` pairs not yet applied.
604    remaining: VecDeque<(usize, JournalEvent)>,
605    /// Recorded externals still unserved (`ExternalReplayMode::Recorded`).
606    ext_queue: VecDeque<(String, Vec<Value>, Value)>,
607    mode: ExternalReplayMode,
608    warnings: Vec<ReplayWarning>,
609    /// The source journal's terminal checkpoint, applied to the rebuilt
610    /// journal when the replay completes.
611    source_checkpoint: Option<SaveState>,
612    /// Total events in the source journal (for final-step `at_event`).
613    total_events: usize,
614}
615
616/// Internal outcome of a replay stepping burst: parked on a deferred external
617/// (resumable) or failed terminally.
618enum StepPark {
619    Awaiting { name: String },
620    Fail(FailReason),
621}
622
623impl<R: StoryRng> StorySession<R> {
624    /// Wrap `story` in a fresh session. `seed` is advisory metadata recorded in
625    /// the journal (the host is responsible for actually seeding the story via
626    /// [`Story::set_rng_seed`] before/at start).
627    #[must_use]
628    pub fn new(story: Story<R>, seed: Option<u64>) -> Self {
629        let checksum = story.program().source_checksum();
630        Self {
631            journal: SessionJournal::new(checksum, seed),
632            story,
633            started: false,
634            pending_replay: None,
635        }
636    }
637
638    /// Read-only access to the journal (for export / persistence).
639    #[must_use]
640    pub fn journal(&self) -> &SessionJournal {
641        &self.journal
642    }
643
644    /// Take the journal by value, refreshing its checkpoint first so the
645    /// exported artifact can fast-restore. The session keeps a fresh empty
646    /// journal bound to the same program (rarely needed; export usually clones
647    /// via [`journal`](Self::journal)).
648    pub fn export_journal(&mut self) -> SessionJournal {
649        self.refresh_checkpoint();
650        let checksum = self.journal.program_checksum;
651        let seed = self.journal.seed;
652        mem::replace(&mut self.journal, SessionJournal::new(checksum, seed))
653    }
654
655    /// **Escape hatch**: the wrapped story. Reads through here never touch the
656    /// journal (they can't mutate anyway).
657    #[must_use]
658    pub fn story(&self) -> &Story<R> {
659        &self.story
660    }
661
662    /// **Escape hatch**: mutable access to the wrapped story. Anything done
663    /// here **bypasses the journal** — the documented journal-bypass contract.
664    /// Use for foreign / shared flows (#200) whose externals never journal.
665    pub fn story_mut(&mut self) -> &mut Story<R> {
666        &mut self.story
667    }
668
669    /// Update the journal's embedded fast-restore checkpoint from the story's
670    /// current game state.
671    fn refresh_checkpoint(&mut self) {
672        self.journal.checkpoint = Some(self.story.save_state());
673    }
674
675    /// Record the implicit default `Start` if the session hasn't started.
676    fn ensure_started(&mut self) {
677        if !self.started {
678            self.started = true;
679            self.journal.push(JournalEvent::new(EventKind::Start {
680                path: None,
681                args: Vec::new(),
682            }));
683        }
684    }
685
686    // ── Stepping ─────────────────────────────────────────────────────
687
688    /// Advance one step with the default (fallback) handler, journaling any
689    /// inline-resolved externals. Surfaces a deferred external as
690    /// [`StepOutcome::AwaitingExternal`].
691    pub fn advance(&mut self) -> Result<StepOutcome, RuntimeError> {
692        self.advance_with(&FallbackHandler)
693    }
694
695    /// Advance one step with a custom handler, journaling any inline-resolved
696    /// externals it produces (the journaling-window gate: only this frame's
697    /// externals record).
698    pub fn advance_with(
699        &mut self,
700        handler: &dyn ExternalFnHandler,
701    ) -> Result<StepOutcome, RuntimeError> {
702        self.ensure_started();
703        let mut sink: Vec<(String, Vec<Value>, Value)> = Vec::new();
704        let outcome = {
705            let jh = JournalingHandler::new(handler, &mut sink);
706            self.story.advance_with(&jh)
707        };
708        for (name, args, result) in sink {
709            self.journal.push(JournalEvent::new(EventKind::External {
710                name,
711                args,
712                result,
713            }));
714        }
715        outcome
716    }
717
718    /// Advance until one line of content or a yield point, journaling externals.
719    pub fn continue_single(&mut self) -> Result<Step, RuntimeError> {
720        self.ensure_started();
721        let mut sink: Vec<(String, Vec<Value>, Value)> = Vec::new();
722        let outcome = {
723            let jh = JournalingHandler::new(&FallbackHandler, &mut sink);
724            self.story.continue_single_with(&jh)
725        };
726        for (name, args, result) in sink {
727            self.journal.push(JournalEvent::new(EventKind::External {
728                name,
729                args,
730                result,
731            }));
732        }
733        outcome
734    }
735
736    /// Advance to the next pause, journaling externals. The last line is always
737    /// terminal (`Done` / `Choices` / `End`).
738    pub fn continue_to_pause(&mut self) -> Result<Vec<Step>, RuntimeError> {
739        self.ensure_started();
740        let mut sink: Vec<(String, Vec<Value>, Value)> = Vec::new();
741        let outcome = {
742            let jh = JournalingHandler::new(&FallbackHandler, &mut sink);
743            self.story.continue_maximally_with(&jh)
744        };
745        for (name, args, result) in sink {
746            self.journal.push(JournalEvent::new(EventKind::External {
747                name,
748                args,
749                result,
750            }));
751        }
752        outcome
753    }
754
755    /// Select a choice, journaling the `Choice` event (with its advisory label).
756    pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
757        self.ensure_started();
758        let label = self
759            .story
760            .pending_choices()
761            .into_iter()
762            .find(|c| c.index == index)
763            .map(|c| c.text);
764        self.story.choose(index)?;
765        #[expect(clippy::cast_possible_truncation, reason = "choice indices are small")]
766        self.journal.push(JournalEvent::new(EventKind::Choice {
767            index: index as u32,
768            label,
769        }));
770        Ok(())
771    }
772
773    /// Resolve a deferred external (out-of-band, [`ExternalResult::Pending`]),
774    /// journaling it as an `External` event. This is the journaling-window gate
775    /// in action: the session records here because it is the session's own
776    /// pause that is being resolved.
777    pub fn resolve_external(&mut self, value: Value) {
778        let name = self
779            .story
780            .pending_external_name()
781            .map(str::to_owned)
782            .unwrap_or_default();
783        let args = self.story.pending_external_args().to_vec();
784        self.journal.push(JournalEvent::new(EventKind::External {
785            name,
786            args,
787            result: value.clone(),
788        }));
789        self.story.resolve_external(value);
790    }
791
792    /// Whether the session is parked on a deferred external.
793    #[must_use]
794    pub fn has_pending_external(&self) -> bool {
795        self.story.has_pending_external()
796    }
797
798    // ── Turn-boundary mutations ──────────────────────────────────────
799
800    /// Set a global variable. **Turn-boundary only**: rejected mid-turn.
801    ///
802    /// # Errors
803    /// [`SessionError::MutationMidTurn`] if the session is mid-turn (status
804    /// `Active`).
805    pub fn set_var(&mut self, name: &str, value: Value) -> Result<bool, SessionError> {
806        self.require_turn_boundary("set_var")?;
807        let applied = self.story.set_variable(name, value.clone());
808        if applied {
809            self.journal.push(JournalEvent::new(EventKind::SetVar {
810                name: name.to_owned(),
811                value,
812            }));
813        }
814        Ok(applied)
815    }
816
817    /// Move the play head to a path (ink `ChoosePathString`). **Turn-boundary
818    /// only**: rejected mid-turn.
819    ///
820    /// # Errors
821    /// [`SessionError::MutationMidTurn`] mid-turn; wrapped [`RuntimeError`]s
822    /// from the jump.
823    pub fn go_to_path(&mut self, path: &str, args: &[Value]) -> Result<(), SessionError> {
824        self.require_turn_boundary("go_to_path")?;
825        self.ensure_started();
826        if args.is_empty() {
827            self.story.choose_path_string(path)?;
828        } else {
829            self.story.choose_path_string_with_args(path, args)?;
830        }
831        self.journal.push(JournalEvent::new(EventKind::GoToPath {
832            path: path.to_owned(),
833            args: args.to_vec(),
834        }));
835        Ok(())
836    }
837
838    /// Load a durable [`SaveState`]. **Turn-boundary only**: rejected mid-turn.
839    ///
840    /// # Errors
841    /// [`SessionError::MutationMidTurn`] mid-turn.
842    pub fn load_state(&mut self, state: &SaveState) -> Result<LoadReport, SessionError> {
843        self.require_turn_boundary("load_state")?;
844        // W14/#3307 compat honesty (RULED): the report reaches the caller —
845        // a stale load ("3 anonymous visit states dropped") must never be
846        // silent. Previously discarded here.
847        let report = self.story.load_state(state);
848        self.journal.push(JournalEvent::new(EventKind::LoadState {
849            state: state.clone(),
850        }));
851        Ok(report)
852    }
853
854    /// Capture the current durable game state (does not journal).
855    #[must_use]
856    pub fn save_state(&self) -> SaveState {
857        self.story.save_state()
858    }
859
860    /// Evaluate an ink function from engine code, journaling a `Call` event.
861    /// The function's own externals resolve through the isolated (non-journaling)
862    /// [`Story::call_function`] handler path — only the top-level call journals.
863    ///
864    /// # Errors
865    /// Wrapped [`RuntimeError`]s from evaluation.
866    pub fn call_function(
867        &mut self,
868        name: &str,
869        args: &[Value],
870        handler: &dyn ExternalFnHandler,
871    ) -> Result<Value, RuntimeError> {
872        // Note: `story.call_function` runs isolated — its externals do NOT go
873        // through the journaling handler, so they never enter the journal.
874        let result = self.story.call_function(name, args, handler)?;
875        self.journal.push(JournalEvent::new(EventKind::Call {
876            name: name.to_owned(),
877            args: args.to_vec(),
878        }));
879        Ok(result)
880    }
881
882    fn require_turn_boundary(&self, op: &'static str) -> Result<(), SessionError> {
883        // Mid-turn == the story is Active with more content pending. A fresh,
884        // not-yet-started session is at a boundary (allowed).
885        if self.started && self.story.status_is_active() {
886            return Err(SessionError::MutationMidTurn { op });
887        }
888        Ok(())
889    }
890
891    // ── Snapshot / diff ──────────────────────────────────────────────
892
893    /// A typed snapshot of the current game state (globals with list
894    /// membership, turn counts, callstack summary). See [`StateSnapshot`].
895    #[must_use]
896    pub fn snapshot(&self) -> StateSnapshot {
897        self.story.state_snapshot()
898    }
899
900    /// Pure diff of two snapshots (convenience; see the free [`diff`] fn).
901    #[must_use]
902    pub fn diff(a: &StateSnapshot, b: &StateSnapshot) -> StateDiff {
903        diff(a, b)
904    }
905}
906
907impl<R: StoryRng> StorySession<R> {
908    // ── Replay / restore ─────────────────────────────────────────────
909
910    /// Fast-restore: apply a journal's embedded [`checkpoint`](SessionJournal::checkpoint)
911    /// and skip replay when the program checksum matches; otherwise fall back to
912    /// a full [`replay`](Self::replay).
913    ///
914    /// Returns the constructed session and the [`ReplayOutcome`]. On a checksum
915    /// match with a present checkpoint the outcome is
916    /// [`ReplayOutcome::Replayed`] with no warnings (no stepping occurred).
917    ///
918    /// # Errors
919    /// [`SessionError::ChecksumMismatch`] only if the checksum differs *and* no
920    /// checkpoint is present to restore from (nothing safe to do).
921    pub fn restore(
922        story: Story<R>,
923        journal: SessionJournal,
924    ) -> Result<(Self, ReplayOutcome), SessionError> {
925        let program_checksum = story.program().source_checksum();
926        if program_checksum == journal.program_checksum {
927            if let Some(checkpoint) = journal.checkpoint.clone() {
928                let mut session = Self {
929                    story,
930                    journal,
931                    started: true,
932                    pending_replay: None,
933                };
934                session.story.load_state(&checkpoint);
935                return Ok((
936                    session,
937                    ReplayOutcome::Replayed {
938                        warnings: Vec::new(),
939                    },
940                ));
941            }
942        } else if journal.checkpoint.is_none() {
943            return Err(SessionError::ChecksumMismatch {
944                journal: journal.program_checksum,
945                program: program_checksum,
946            });
947        }
948        // Fall back to full replay (recompiled program or no checkpoint).
949        Ok(Self::replay(
950            story,
951            &journal,
952            ExternalReplayMode::Recorded,
953            None,
954        ))
955    }
956
957    /// Replay a journal against `story` from a fresh start. Consumes the journal
958    /// prefix event-by-event; on divergence, truncates the journal at that point
959    /// and parks at the reached position.
960    ///
961    /// The session **re-records** as it replays, rebuilding its own journal. In
962    /// [`ExternalReplayMode::Recorded`], the rebuilt prefix is the **source**
963    /// prefix: source `External` events are re-pushed verbatim, including any
964    /// the re-run did not actually consume (a recorded-mode mismatch falls back
965    /// to the ink fallback body rather than diverging) — the truncated prefix
966    /// is the source's record, not a re-observed trace. In
967    /// [`ExternalReplayMode::Live`] the rebuilt journal *is* a re-observed
968    /// trace: live results are journaled as they resolve and source `External`
969    /// events are not copied.
970    ///
971    /// `mode` selects recorded (journal-served) vs live externals. Live replay
972    /// hitting a deferred external parks with [`FailReason::AwaitingExternal`],
973    /// **retaining the un-replayed tail**: resolve the external
974    /// ([`resolve_external`](Self::resolve_external)) and resume with
975    /// [`continue_replay`](Self::continue_replay), which picks up the remaining
976    /// recorded inputs from the park point.
977    #[must_use]
978    pub fn replay(
979        story: Story<R>,
980        journal: &SessionJournal,
981        mode: ExternalReplayMode,
982        live_handler: Option<&dyn ExternalFnHandler>,
983    ) -> (Self, ReplayOutcome) {
984        let mut session = Self {
985            story,
986            journal: SessionJournal::new(0, None),
987            started: false,
988            pending_replay: None,
989        };
990        // Rebuild an empty journal to re-record faithfully as we replay.
991        session.journal =
992            SessionJournal::new(session.story.program().source_checksum(), journal.seed);
993        // Queue of recorded externals for the recorded-mode handler.
994        let ext_queue = journal
995            .events
996            .iter()
997            .filter_map(|ev| match &ev.kind {
998                EventKind::External { name, args, result } => {
999                    Some((name.clone(), args.clone(), result.clone()))
1000                }
1001                _ => None,
1002            })
1003            .collect();
1004        let state = PendingReplay {
1005            remaining: journal.events.iter().cloned().enumerate().collect(),
1006            ext_queue,
1007            mode,
1008            warnings: Vec::new(),
1009            source_checkpoint: journal.checkpoint.clone(),
1010            total_events: journal.events.len(),
1011        };
1012        let outcome = session.drive_replay(state, live_handler);
1013        (session, outcome)
1014    }
1015
1016    /// Whether a parked replay tail is pending (a live replay hit a deferred
1017    /// external). Resolve it and call [`continue_replay`](Self::continue_replay).
1018    #[must_use]
1019    pub fn has_pending_replay(&self) -> bool {
1020        self.pending_replay.is_some()
1021    }
1022
1023    /// Resume a replay parked on a deferred external
1024    /// ([`FailReason::AwaitingExternal`]). Resolve the pending external first
1025    /// (via [`resolve_external`](Self::resolve_external)), then call this: the
1026    /// session resumes consuming the retained journal tail from the park
1027    /// point — it can complete ([`ReplayOutcome::Replayed`] with all warnings
1028    /// accumulated across parks), park again, diverge later, or fail.
1029    ///
1030    /// With **no pending replay tail** this keeps the advance-only behavior:
1031    /// it steps the live story to its next pause with `live_handler`
1032    /// (journaling externals as in normal play) and returns `Replayed` on
1033    /// reaching the pause, or `Failed` if it parks or errors (`at_event` is
1034    /// then the rebuilt journal's current length, where the next event would
1035    /// land).
1036    pub fn continue_replay(
1037        &mut self,
1038        live_handler: Option<&dyn ExternalFnHandler>,
1039    ) -> ReplayOutcome {
1040        if let Some(state) = self.pending_replay.take() {
1041            return self.drive_replay(state, live_handler);
1042        }
1043        // Advance-only: no recorded inputs left to consume.
1044        let handler = live_handler.unwrap_or(&FallbackHandler);
1045        let mut steps = 0usize;
1046        loop {
1047            if steps >= Self::REPLAY_STEP_BUDGET {
1048                self.journal.truncated = true;
1049                return ReplayOutcome::Failed {
1050                    at_event: self.journal.len(),
1051                    reason: FailReason::Budget,
1052                };
1053            }
1054            steps += 1;
1055            match self.advance_with(handler) {
1056                Ok(StepOutcome::Step(step)) if step.is_terminal() => {
1057                    return ReplayOutcome::Replayed {
1058                        warnings: Vec::new(),
1059                    };
1060                }
1061                Ok(StepOutcome::Step(_)) => {}
1062                Ok(StepOutcome::AwaitingExternal) => {
1063                    let name = self
1064                        .story
1065                        .pending_external_name()
1066                        .map(str::to_owned)
1067                        .unwrap_or_default();
1068                    return ReplayOutcome::Failed {
1069                        at_event: self.journal.len(),
1070                        reason: FailReason::AwaitingExternal { name },
1071                    };
1072                }
1073                Err(e) => {
1074                    self.journal.truncated = true;
1075                    return ReplayOutcome::Failed {
1076                        at_event: self.journal.len(),
1077                        reason: runtime_fail(e),
1078                    };
1079                }
1080            }
1081        }
1082    }
1083
1084    /// Maximum `advance` calls per replay stepping burst (unbounded-growth /
1085    /// no-hang guard).
1086    const REPLAY_STEP_BUDGET: usize = 100_000;
1087
1088    /// Drive (or resume) a replay from its cursor `state`. On a resumable park
1089    /// ([`FailReason::AwaitingExternal`]) the state is retained in
1090    /// [`pending_replay`](Self::pending_replay); on divergence or terminal
1091    /// failure it is dropped (the journal is truncated at that point).
1092    fn drive_replay(
1093        &mut self,
1094        mut state: PendingReplay,
1095        live_handler: Option<&dyn ExternalFnHandler>,
1096    ) -> ReplayOutcome {
1097        loop {
1098            // All recorded inputs consumed: final trailing step (a story with
1099            // no choices, or content after the last input, only advances
1100            // here), then complete.
1101            let Some((at, _)) = state.remaining.front().cloned() else {
1102                if self.started {
1103                    let at = state.total_events.saturating_sub(1);
1104                    if let Err(park) =
1105                        self.replay_step_to_pause(state.mode, live_handler, &mut state.ext_queue)
1106                    {
1107                        return self.park_or_fail(state, at, park);
1108                    }
1109                }
1110                // Carry over the terminal checkpoint if the source had one.
1111                self.journal.checkpoint.clone_from(&state.source_checkpoint);
1112                return ReplayOutcome::Replayed {
1113                    warnings: state.warnings,
1114                };
1115            };
1116
1117            // A Choice consumes a pause: step to it first (this is also where
1118            // a resumed drive picks up after its external was resolved).
1119            let next_is_choice = matches!(
1120                state.remaining.front().map(|(_, ev)| &ev.kind),
1121                Some(EventKind::Choice { .. })
1122            );
1123            if next_is_choice
1124                && let Err(park) =
1125                    self.replay_step_to_pause(state.mode, live_handler, &mut state.ext_queue)
1126            {
1127                return self.park_or_fail(state, at, park);
1128            }
1129
1130            let Some((i, ev)) = state.remaining.pop_front() else {
1131                // Unreachable: `front` was `Some` above.
1132                continue;
1133            };
1134            match self.replay_apply(i, &ev, state.mode, &mut state.warnings) {
1135                Ok(()) => {}
1136                Err(outcome) => return outcome,
1137            }
1138        }
1139    }
1140
1141    /// Park (retaining `state` for [`continue_replay`](Self::continue_replay))
1142    /// or fail terminally (truncating the rebuilt journal).
1143    fn park_or_fail(&mut self, state: PendingReplay, at: usize, park: StepPark) -> ReplayOutcome {
1144        match park {
1145            StepPark::Awaiting { name } => {
1146                self.pending_replay = Some(state);
1147                ReplayOutcome::Failed {
1148                    at_event: at,
1149                    reason: FailReason::AwaitingExternal { name },
1150                }
1151            }
1152            StepPark::Fail(reason) => {
1153                self.journal.truncated = true;
1154                ReplayOutcome::Failed {
1155                    at_event: at,
1156                    reason,
1157                }
1158            }
1159        }
1160    }
1161
1162    /// Apply one recorded input during replay. `Err` is the terminal
1163    /// [`ReplayOutcome`] (divergence or failure).
1164    fn replay_apply(
1165        &mut self,
1166        i: usize,
1167        ev: &JournalEvent,
1168        mode: ExternalReplayMode,
1169        warnings: &mut Vec<ReplayWarning>,
1170    ) -> Result<(), ReplayOutcome> {
1171        match &ev.kind {
1172            EventKind::Start { path, args } => {
1173                if self.started {
1174                    return Err(self.diverge_at(i, ev, DivergenceFound::UnexpectedEvent));
1175                }
1176                self.started = true;
1177                self.journal.push(JournalEvent::new(EventKind::Start {
1178                    path: path.clone(),
1179                    args: args.clone(),
1180                }));
1181                if let Some(p) = path {
1182                    let jump = if args.is_empty() {
1183                        self.story.choose_path_string(p)
1184                    } else {
1185                        self.story.choose_path_string_with_args(p, args)
1186                    };
1187                    if jump.is_err() {
1188                        return Err(self.diverge_at(
1189                            i,
1190                            ev,
1191                            DivergenceFound::UnknownPath { path: p.clone() },
1192                        ));
1193                    }
1194                }
1195            }
1196            EventKind::External { .. } => {
1197                // Recorded mode: re-push the source event verbatim — the
1198                // rebuilt prefix is the SOURCE prefix, not a re-observed trace
1199                // (a mismatched entry falls back rather than diverging, so the
1200                // re-run may not have consumed it). Live mode journals actual
1201                // results during stepping instead, so nothing to copy here.
1202                if mode == ExternalReplayMode::Recorded {
1203                    self.journal.push(ev.clone());
1204                }
1205            }
1206            EventKind::Choice { index, label } => {
1207                self.replay_choice(i, *index, label.as_ref(), ev, warnings)?;
1208            }
1209            EventKind::SetVar { name, value } => {
1210                self.story.set_variable(name, value.clone());
1211                self.journal.push(ev.clone());
1212            }
1213            EventKind::GoToPath { path, args } => {
1214                let jump = if args.is_empty() {
1215                    self.story.choose_path_string(path)
1216                } else {
1217                    self.story.choose_path_string_with_args(path, args)
1218                };
1219                if jump.is_err() {
1220                    return Err(self.diverge_at(
1221                        i,
1222                        ev,
1223                        DivergenceFound::UnknownPath { path: path.clone() },
1224                    ));
1225                }
1226                self.journal.push(ev.clone());
1227            }
1228            EventKind::LoadState { state } => {
1229                self.story.load_state(state);
1230                self.journal.push(ev.clone());
1231            }
1232            EventKind::Call { name, args } => {
1233                // Journaled but isolated: re-invoke through the fallback
1234                // handler (recorded externals for a Call aren't separately
1235                // journaled; a live handler would be host-supplied). We use
1236                // the fallback so replay never blocks.
1237                let _ = self.story.call_function(name, args, &FallbackHandler);
1238                self.journal.push(ev.clone());
1239            }
1240        }
1241        Ok(())
1242    }
1243
1244    /// Replay one `Choice` event (the driver has already stepped to the choice
1245    /// pause): range-check the recorded index against what the current program
1246    /// presents, emit a soft label-drift warning, then select. Returns
1247    /// `Err(outcome)` on divergence/failure.
1248    fn replay_choice(
1249        &mut self,
1250        at: usize,
1251        index: u32,
1252        label: Option<&String>,
1253        ev: &JournalEvent,
1254        warnings: &mut Vec<ReplayWarning>,
1255    ) -> Result<(), ReplayOutcome> {
1256        if !self.story.status_is_waiting_for_choice() {
1257            return Err(self.diverge_at(at, ev, DivergenceFound::NotWaitingForChoice));
1258        }
1259        let presented = self.story.pending_choices();
1260        let available = presented.len();
1261        let Some(current) = presented.iter().find(|c| c.index == index as usize) else {
1262            return Err(self.diverge_at(
1263                at,
1264                ev,
1265                DivergenceFound::ChoiceIndexOutOfRange { index, available },
1266            ));
1267        };
1268        // Label-drift soft warning (matching index, different text).
1269        if let Some(recorded) = label
1270            && recorded != &current.text
1271        {
1272            warnings.push(ReplayWarning::ChoiceLabelDrift {
1273                at_event: at,
1274                index,
1275                recorded: recorded.clone(),
1276                found: current.text.clone(),
1277            });
1278        }
1279        if let Err(e) = self.story.choose(index as usize) {
1280            self.journal.truncated = true;
1281            return Err(ReplayOutcome::Failed {
1282                at_event: at,
1283                reason: runtime_fail(e),
1284            });
1285        }
1286        self.journal.push(ev.clone());
1287        Ok(())
1288    }
1289
1290    /// Step to the next pause during replay, serving externals per `mode`. In
1291    /// Live mode, inline-resolved results are journaled as they happen (the
1292    /// rebuilt journal is a re-observed trace). Returns `Err(park)` when a
1293    /// deferred external pauses the flow (resumable) or on a terminal failure.
1294    fn replay_step_to_pause(
1295        &mut self,
1296        mode: ExternalReplayMode,
1297        live_handler: Option<&dyn ExternalFnHandler>,
1298        ext_queue: &mut VecDeque<(String, Vec<Value>, Value)>,
1299    ) -> Result<(), StepPark> {
1300        let mut steps = 0usize;
1301        loop {
1302            if steps >= Self::REPLAY_STEP_BUDGET {
1303                return Err(StepPark::Fail(FailReason::Budget));
1304            }
1305            steps += 1;
1306            let outcome = match mode {
1307                ExternalReplayMode::Recorded => {
1308                    let h = RecordedReplayHandler {
1309                        queue: RefCell::new(ext_queue),
1310                    };
1311                    self.story.advance_with(&h)
1312                }
1313                ExternalReplayMode::Live => {
1314                    // Journal live results where the VM receives them, so the
1315                    // rebuilt journal reflects what actually fed this run.
1316                    let mut sink: Vec<(String, Vec<Value>, Value)> = Vec::new();
1317                    let outcome = {
1318                        let h = live_handler.unwrap_or(&FallbackHandler);
1319                        let jh = JournalingHandler::new(h, &mut sink);
1320                        self.story.advance_with(&jh)
1321                    };
1322                    for (name, args, result) in sink {
1323                        self.journal.push(JournalEvent::new(EventKind::External {
1324                            name,
1325                            args,
1326                            result,
1327                        }));
1328                    }
1329                    outcome
1330                }
1331            };
1332            match outcome {
1333                Ok(StepOutcome::Step(step)) => {
1334                    if step.is_terminal() {
1335                        return Ok(());
1336                    }
1337                }
1338                Ok(StepOutcome::AwaitingExternal) => {
1339                    let name = self
1340                        .story
1341                        .pending_external_name()
1342                        .map(str::to_owned)
1343                        .unwrap_or_default();
1344                    return Err(StepPark::Awaiting { name });
1345                }
1346                Err(e) => return Err(StepPark::Fail(runtime_fail(e))),
1347            }
1348        }
1349    }
1350
1351    /// Truncate the rebuilt journal at the divergence point and build a
1352    /// `Diverged` outcome. The rebuilt journal keeps the **source prefix** as
1353    /// re-pushed so far (in recorded mode this includes source `External`
1354    /// events verbatim, even ones the re-run fell back on instead of
1355    /// consuming — see [`replay`](Self::replay)); the checkpoint is cleared
1356    /// because it described the source's terminal state, not this park point.
1357    fn diverge_at(
1358        &mut self,
1359        at: usize,
1360        expected: &JournalEvent,
1361        found: DivergenceFound,
1362    ) -> ReplayOutcome {
1363        self.journal.truncated = true;
1364        self.journal.checkpoint = None;
1365        ReplayOutcome::Diverged {
1366            at_event: at,
1367            expected: Box::new(expected.clone()),
1368            found,
1369        }
1370    }
1371}
1372
1373fn runtime_fail(e: RuntimeError) -> FailReason {
1374    match e {
1375        RuntimeError::StepLimitExceeded(_) | RuntimeError::LineLimitExceeded(_) => {
1376            FailReason::Budget
1377        }
1378        other => FailReason::RuntimeError {
1379            message: other.to_string(),
1380        },
1381    }
1382}