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 != ¤t.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}