Skip to main content

brink_runtime/
story.rs

1//! Per-instance mutable story state.
2
3use std::collections::HashMap;
4use std::marker::PhantomData;
5use std::sync::Arc;
6
7use brink_format::{ChoiceFlags, DefinitionId, PluralResolver, Value};
8
9use crate::error::RuntimeError;
10use crate::output::OutputBuffer;
11use crate::program::Program;
12use crate::rng::{FastRng, StoryRng};
13use crate::state::{ContextAccess, WriteObserver};
14use crate::vm;
15use crate::world::{ContextView, FlowLocal, ResolvedPolicy, World};
16
17/// The current execution status of a story.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum StoryStatus {
20    /// Ready to step.
21    Active,
22    /// Waiting for a choice selection via [`Story::choose`].
23    WaitingForChoice,
24    /// Hit a `done` opcode — can still resume after output is consumed.
25    Done,
26    /// Hit an `end` opcode — permanently finished.
27    Ended,
28}
29
30/// A single step of story output from [`Story::continue_single`].
31///
32/// The enum tells the caller what to do next:
33/// - `Text` — more output may follow, keep calling `continue_single`.
34/// - `Done` — this turn's output is complete. Call `continue_single`
35///   again for the next turn (the story isn't over).
36/// - `Choices` — pick a choice via [`Story::choose`], then resume.
37/// - `End` — the story has permanently ended.
38#[derive(Debug, Clone)]
39pub enum Line {
40    /// One line of story content. More may follow — keep calling
41    /// [`Story::continue_single`].
42    Text { text: String, tags: Vec<String> },
43    /// This turn's output is complete (ink `-> DONE`). The story isn't
44    /// over — call [`Story::continue_single`] again for more.
45    Done { text: String, tags: Vec<String> },
46    /// The story is presenting choices. Call [`Story::choose`] then
47    /// resume with [`Story::continue_single`].
48    Choices {
49        text: String,
50        tags: Vec<String>,
51        choices: Vec<Choice>,
52    },
53    /// The story has permanently ended (ink `-> END`).
54    End { text: String, tags: Vec<String> },
55}
56
57impl Line {
58    /// The text content of this line, regardless of variant.
59    pub fn text(&self) -> &str {
60        match self {
61            Self::Text { text, .. }
62            | Self::Done { text, .. }
63            | Self::Choices { text, .. }
64            | Self::End { text, .. } => text,
65        }
66    }
67
68    /// The tags associated with this line, regardless of variant.
69    pub fn tags(&self) -> &[String] {
70        match self {
71            Self::Text { tags, .. }
72            | Self::Done { tags, .. }
73            | Self::Choices { tags, .. }
74            | Self::End { tags, .. } => tags,
75        }
76    }
77
78    /// Returns true if this is a terminal variant (`Done`, `Choices`, or `End`).
79    pub fn is_terminal(&self) -> bool {
80        !matches!(self, Self::Text { .. })
81    }
82}
83
84/// Outcome of a single [`FlowInstance::advance`] step.
85///
86/// Like [`Line`], but with an extra variant for when a binding handler
87/// deferred an external call ([`ExternalResult::Pending`]) — e.g. a
88/// world-access query hit during normal playback. The flow is paused with
89/// its state intact: inspect the pending call via
90/// [`pending_external_name`](FlowInstance::pending_external_name) /
91/// [`pending_external_args`](FlowInstance::pending_external_args), supply
92/// the result with [`resolve_external`](FlowInstance::resolve_external),
93/// then call [`advance`](FlowInstance::advance) again.
94///
95/// [`step_single_line`](FlowInstance::step_single_line) is the simpler API
96/// for consumers whose handler never pauses — it maps `AwaitingExternal`
97/// to an error.
98#[derive(Debug, Clone)]
99pub enum StepOutcome {
100    /// A line of output, or a yield point (`Done`/`Choices`/`End`).
101    Line(Line),
102    /// The flow paused on a deferred external; resolve it and `advance`.
103    AwaitingExternal,
104}
105
106/// A single choice presented to the player.
107#[derive(Debug, Clone)]
108pub struct Choice {
109    pub text: String,
110    pub index: usize,
111    pub tags: Vec<String>,
112}
113
114// ── Stats ───────────────────────────────────────────────────────────────────
115
116/// Lightweight counters tracking VM activity over a story's lifetime.
117///
118/// Always-on — incrementing a `u64` is effectively free compared to opcode
119/// dispatch. Use [`Story::stats`] to read after a run.
120#[derive(Debug, Clone, Default)]
121pub struct Stats {
122    /// Total opcodes dispatched.
123    pub opcodes: u64,
124    /// Total `vm::step` calls from the outer loop.
125    pub steps: u64,
126    /// Threads forked (via `ThreadCall` and choice creation).
127    pub threads_created: u64,
128    /// Threads that completed and were popped.
129    pub threads_completed: u64,
130    /// Call frames pushed onto thread stacks.
131    pub frames_pushed: u64,
132    /// Call frames popped from thread stacks.
133    pub frames_popped: u64,
134    /// Choice sets presented to the player.
135    pub choices_presented: u64,
136    /// Individual choices selected.
137    pub choices_selected: u64,
138    /// `CallStack::snapshot` cache hits (reused existing `Arc`).
139    pub snapshot_cache_hits: u64,
140    /// `CallStack::snapshot` cache misses (new allocation).
141    pub snapshot_cache_misses: u64,
142    /// `CallStack::materialize` calls (flattened inherited prefix).
143    pub materializations: u64,
144}
145
146// ── Internal types ──────────────────────────────────────────────────────────
147
148#[derive(Debug, Clone, Copy)]
149pub(crate) struct ContainerPosition {
150    pub container_idx: u32,
151    pub offset: usize,
152}
153
154/// Distinguishes call frame types for container-stack-empty semantics:
155///
156/// - **Root**: the initial frame. Yields for pending choices.
157/// - **Function**: `f()` calls. Output is captured as a return value.
158/// - **Tunnel**: `->t->` calls. Yields for pending choices (the tunnel
159///   needs the player's choice before it can continue).
160/// - **Thread**: boundary frame pushed by `ThreadCall`. When this frame
161///   exhausts, the thread is done — inherited frames below it are never
162///   unwound into during normal execution. `->->` (`TunnelReturn`) strips
163///   Thread frames to find the enclosing Tunnel.
164/// - **External**: pushed by `CallExternal`. Holds popped arguments in
165///   `temps` and the external function's [`DefinitionId`] in
166///   `external_fn_id`. The orchestration layer resolves it (binding or
167///   fallback) before the VM resumes.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub(crate) enum CallFrameType {
170    Root,
171    Function,
172    Tunnel,
173    Thread,
174    External,
175    /// Boundary frame pushed by an engine→ink call
176    /// ([`FlowInstance::begin_function_eval`]). Behaves like `Function`
177    /// for output trimming and implicit-return purposes, but marks where
178    /// a from-game evaluation began so the eval driver knows when the
179    /// function has returned. Mirrors C#'s
180    /// `PushPopType.FunctionEvaluationFromGame`.
181    FunctionEvalFromGame,
182}
183
184#[derive(Debug, Clone)]
185pub(crate) struct CallFrame {
186    pub return_address: Option<ContainerPosition>,
187    pub temps: Vec<Value>,
188    pub container_stack: Vec<ContainerPosition>,
189    pub frame_type: CallFrameType,
190    /// For `External` frames: the `DefinitionId` of the external function,
191    /// used to look up the fallback container if no binding is registered.
192    pub external_fn_id: Option<DefinitionId>,
193    /// For `Function` frames: the length of the active output target at
194    /// call time.  On return, trailing whitespace is trimmed back to this
195    /// point — matching the C# runtime's `TrimWhitespaceFromFunctionEnd`.
196    pub function_output_start: Option<usize>,
197}
198
199/// Two-part call stack: shared read-only prefix + owned mutable frames.
200///
201/// `fork_thread` snapshots the parent's frames into a cached `Arc<[CallFrame]>`
202/// (one clone, amortized across all children). Children get `Arc::clone` — O(1).
203/// The parent keeps its `own` vec unchanged and continues mutating freely.
204#[derive(Debug, Clone)]
205pub(crate) struct CallStack {
206    /// Shared read-only prefix inherited from the parent thread.
207    inherited: Option<Arc<[CallFrame]>>,
208    /// Frames owned by this thread (above the fork point).
209    own: Vec<CallFrame>,
210    /// Cached snapshot so multiple forks from the same parent share one allocation.
211    cached_snapshot: Option<Arc<[CallFrame]>>,
212    /// Count of materializations (flattening inherited prefix into own).
213    pub(crate) materialization_count: u64,
214}
215
216impl CallStack {
217    pub fn new(frame: CallFrame) -> Self {
218        Self {
219            inherited: None,
220            own: vec![frame],
221            cached_snapshot: None,
222            materialization_count: 0,
223        }
224    }
225
226    pub fn push(&mut self, frame: CallFrame) {
227        self.cached_snapshot = None;
228        self.own.push(frame);
229    }
230
231    pub fn pop(&mut self) -> Option<CallFrame> {
232        self.cached_snapshot = None;
233        if let Some(f) = self.own.pop() {
234            return Some(f);
235        }
236        self.materialize();
237        self.own.pop()
238    }
239
240    pub fn last(&self) -> Option<&CallFrame> {
241        self.own
242            .last()
243            .or_else(|| self.inherited.as_ref().and_then(|h| h.last()))
244    }
245
246    pub fn last_mut(&mut self) -> Option<&mut CallFrame> {
247        if !self.own.is_empty() {
248            return self.own.last_mut();
249        }
250        self.materialize();
251        self.own.last_mut()
252    }
253
254    pub fn len(&self) -> usize {
255        self.inherited.as_ref().map_or(0, |h| h.len()) + self.own.len()
256    }
257
258    pub fn is_empty(&self) -> bool {
259        self.own.is_empty() && self.inherited.as_ref().is_none_or(|h| h.is_empty())
260    }
261
262    /// Get a frame by absolute index (0 = bottom of stack).
263    pub fn get(&self, index: usize) -> Option<&CallFrame> {
264        let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
265        if index < inherited_len {
266            self.inherited.as_ref().and_then(|h| h.get(index))
267        } else {
268            self.own.get(index - inherited_len)
269        }
270    }
271
272    /// Get a mutable reference to a frame by absolute index.
273    /// Materializes the inherited prefix if the target is in it.
274    pub fn get_mut(&mut self, index: usize) -> Option<&mut CallFrame> {
275        let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
276        if index < inherited_len {
277            self.materialize();
278            self.own.get_mut(index)
279        } else {
280            self.own.get_mut(index - inherited_len)
281        }
282    }
283
284    /// Build an `Arc<[CallFrame]>` snapshot of the full stack (inherited + own).
285    /// The result is cached so multiple forks from the same parent share one
286    /// allocation. Returns `(snapshot, cache_hit)`.
287    pub fn snapshot(&mut self) -> (Arc<[CallFrame]>, bool) {
288        if let Some(ref cached) = self.cached_snapshot {
289            return (Arc::clone(cached), true);
290        }
291        let rc = match &self.inherited {
292            None => Arc::from(self.own.as_slice()),
293            Some(prefix) if self.own.is_empty() => Arc::clone(prefix),
294            Some(prefix) => {
295                let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
296                combined.extend_from_slice(prefix);
297                combined.extend_from_slice(&self.own);
298                Arc::from(combined)
299            }
300        };
301        self.cached_snapshot = Some(Arc::clone(&rc));
302        (rc, false)
303    }
304
305    /// Flatten inherited prefix into `own`. Returns `true` if work was done.
306    fn materialize(&mut self) -> bool {
307        self.cached_snapshot = None;
308        if let Some(prefix) = self.inherited.take() {
309            let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
310            combined.extend_from_slice(&prefix);
311            combined.append(&mut self.own);
312            self.own = combined;
313            self.materialization_count += 1;
314            true
315        } else {
316            false
317        }
318    }
319}
320
321/// A single execution thread with its own call stack.
322#[derive(Debug, Clone)]
323pub(crate) struct Thread {
324    pub call_stack: CallStack,
325}
326
327/// How the choice display text is stored internally.
328#[derive(Debug, Clone)]
329pub(crate) enum ChoiceDisplay {
330    /// Eagerly resolved text (legacy path, converter, or non-fragment codegen).
331    Text(String),
332    /// Index into the output buffer's fragment store — resolved on demand.
333    Fragment(u32),
334}
335
336#[derive(Debug, Clone)]
337pub(crate) struct PendingChoice {
338    pub display: ChoiceDisplay,
339    pub target_id: DefinitionId,
340    pub target_idx: u32,
341    pub target_offset: usize,
342    pub flags: ChoiceFlags,
343    #[expect(
344        dead_code,
345        reason = "needs research — likely needed for structured output / voice acting"
346    )]
347    pub original_index: usize,
348    /// Tags collected during choice evaluation.
349    pub tags: Vec<String>,
350    /// Snapshot of the current thread at choice creation time, so that
351    /// selecting this choice can restore the execution context
352    /// (including temp variables from enclosing tunnels/functions).
353    pub thread_fork: Thread,
354}
355
356/// Per-flow execution context. Owns threads, eval stack, output, choices.
357#[derive(Debug, Clone)]
358#[expect(
359    clippy::struct_excessive_bools,
360    reason = "VM flags are inherently boolean"
361)]
362pub(crate) struct Flow {
363    pub threads: Vec<Thread>,
364    pub value_stack: Vec<Value>,
365    pub output: OutputBuffer,
366    pub pending_choices: Vec<PendingChoice>,
367    pub current_tags: Vec<String>,
368    pub in_tag: bool,
369    pub skipping_choice: bool,
370    /// Set to `true` when a `Done` opcode fires (explicit `-> DONE`).
371    /// Cleared at the start of each `continue_single` call.
372    pub did_safe_exit: bool,
373    /// Set to `true` when a `Yield` opcode falls through with no
374    /// pending choices — the story passed through an empty choice set.
375    /// Cleared at the start of each `continue_single` call.
376    pub did_unsafe_yield: bool,
377}
378
379impl Flow {
380    /// Returns a reference to the current (topmost) thread.
381    ///
382    /// # Panics
383    ///
384    /// Panics if the thread stack is empty. This is a programming error —
385    /// flows are always constructed with at least one thread.
386    #[expect(clippy::expect_used)]
387    pub fn current_thread(&self) -> &Thread {
388        self.threads
389            .last()
390            .expect("flow must always have at least one thread")
391    }
392
393    /// Returns a mutable reference to the current (topmost) thread.
394    ///
395    /// # Panics
396    ///
397    /// Panics if the thread stack is empty. This is a programming error —
398    /// flows are always constructed with at least one thread.
399    #[expect(clippy::expect_used)]
400    pub fn current_thread_mut(&mut self) -> &mut Thread {
401        self.threads
402            .last_mut()
403            .expect("flow must always have at least one thread")
404    }
405
406    pub fn can_pop_thread(&self) -> bool {
407        self.threads.len() > 1
408    }
409
410    /// Returns `true` if a `FunctionEvalFromGame` boundary frame is present
411    /// in the current thread's call stack — i.e. an engine→ink function
412    /// evaluation is still in progress. Functions don't fork threads, so
413    /// the current thread is where the boundary lives. The eval driver
414    /// uses this to detect when the function has returned (boundary popped).
415    pub fn has_eval_boundary(&self) -> bool {
416        let cs = &self.current_thread().call_stack;
417        (0..cs.len())
418            .filter_map(|i| cs.get(i))
419            .any(|f| f.frame_type == CallFrameType::FunctionEvalFromGame)
420    }
421
422    pub fn pop_thread(&mut self) {
423        self.threads.pop();
424    }
425
426    /// Fork a new thread from the current one. Returns `(thread, snapshot_cache_hit)`.
427    pub fn fork_thread(&mut self) -> (Thread, bool) {
428        let (shared, cache_hit) = self.current_thread_mut().call_stack.snapshot();
429        (
430            Thread {
431                call_stack: CallStack {
432                    inherited: Some(shared),
433                    own: Vec::new(),
434                    cached_snapshot: None,
435                    materialization_count: 0,
436                },
437            },
438            cache_hit,
439        )
440    }
441
442    /// Drain materialization counts from all thread call stacks.
443    pub fn drain_materializations(&mut self) -> u64 {
444        let mut total = 0;
445        for thread in &mut self.threads {
446            total += thread.call_stack.materialization_count;
447            thread.call_stack.materialization_count = 0;
448        }
449        total
450    }
451
452    /// Read the arguments from the top External frame.
453    pub fn external_args(&self) -> &[Value] {
454        let frame = self.current_thread().call_stack.last();
455        match frame {
456            Some(f) if f.frame_type == CallFrameType::External => &f.temps,
457            _ => &[],
458        }
459    }
460
461    /// Read the external function's `DefinitionId` from the top External frame.
462    pub fn external_fn_id(&self) -> Option<DefinitionId> {
463        let frame = self.current_thread().call_stack.last()?;
464        if frame.frame_type == CallFrameType::External {
465            frame.external_fn_id
466        } else {
467            None
468        }
469    }
470
471    /// Resolve an external call: pop the External frame and push the
472    /// return value onto the value stack.
473    pub fn resolve_external(&mut self, value: Value) {
474        let thread = self.current_thread_mut();
475        if let Some(frame) = thread.call_stack.last()
476            && frame.frame_type == CallFrameType::External
477        {
478            let ret_addr = frame.return_address;
479            thread.call_stack.pop();
480            self.value_stack.push(value);
481            // Restore position from return address (if any).
482            if let Some(pos) = ret_addr
483                && let Some(f) = self.current_thread_mut().call_stack.last_mut()
484                && let Some(top) = f.container_stack.last_mut()
485            {
486                *top = pos;
487            }
488        }
489    }
490
491    /// Replace the External frame with a Function frame pointing at the
492    /// fallback container. Args are pushed back onto the value stack so
493    /// the fallback body's `temp=` opcodes can pop them.
494    pub fn invoke_fallback(&mut self, container_idx: u32) {
495        let output_start = self.output.target_len();
496        let thread = self.current_thread_mut();
497        if let Some(frame) = thread.call_stack.last_mut()
498            && frame.frame_type == CallFrameType::External
499        {
500            let args = core::mem::take(&mut frame.temps);
501            frame.frame_type = CallFrameType::Function;
502            frame.container_stack = vec![ContainerPosition {
503                container_idx,
504                offset: 0,
505            }];
506            frame.external_fn_id = None;
507            frame.function_output_start = Some(output_start);
508            // Push args back onto the value stack — the fallback body
509            // starts with `temp=` instructions that pop them.
510            self.value_stack.extend(args);
511        }
512    }
513
514    /// Pop a value from the value stack.
515    pub fn pop_value(&mut self) -> Result<Value, RuntimeError> {
516        self.value_stack.pop().ok_or(RuntimeError::StackUnderflow)
517    }
518
519    /// Peek at the top value without popping.
520    pub fn peek_value(&self) -> Result<&Value, RuntimeError> {
521        self.value_stack.last().ok_or(RuntimeError::StackUnderflow)
522    }
523}
524
525/// Result of an external function handler call.
526#[derive(Debug, Clone)]
527pub enum ExternalResult {
528    /// The handler resolved the call and returned a value.
529    /// `Value::Null` is valid for fire-and-forget calls.
530    Resolved(Value),
531    /// The handler declined — use the ink fallback body if available.
532    Fallback,
533    /// The handler cannot resolve the call yet (async resolution).
534    /// The VM freezes with the `External` frame intact. The caller must
535    /// resolve via `story.resolve_external(value)` before continuing.
536    Pending,
537}
538
539/// Trait for handling external function calls from ink.
540///
541/// Implement this to provide runtime-injected external function behavior.
542/// The orchestration layer calls [`call`](ExternalFnHandler::call) when the
543/// VM encounters a `CallExternal` opcode. The handler can resolve the call
544/// immediately, decline to handle it (triggering fallback), or in the future,
545/// indicate that resolution is pending (async/WASM).
546pub trait ExternalFnHandler {
547    /// Handle an external function call.
548    ///
549    /// `name` is the ink-declared function name. `args` are the values
550    /// popped from the value stack, in declaration order.
551    fn call(&self, name: &str, args: &[Value]) -> ExternalResult;
552}
553
554/// Default handler that always falls back to the ink function body.
555///
556/// Use this as the `handler` argument to [`FlowInstance::step_single_line`]
557/// or [`FlowInstance::choose`] when you don't want to provide a custom
558/// external-function binding registry. Every external call returns
559/// [`ExternalResult::Fallback`], delegating to the in-story fallback
560/// container declared on the `EXTERNAL` declaration.
561pub struct FallbackHandler;
562
563impl ExternalFnHandler for FallbackHandler {
564    fn call(&self, _name: &str, _args: &[Value]) -> ExternalResult {
565        ExternalResult::Fallback
566    }
567}
568
569/// Outcome of an engine→ink function evaluation
570/// ([`FlowInstance::begin_function_eval`] / [`resume_function_eval`](FlowInstance::resume_function_eval)).
571///
572/// Evaluating an ink function from engine code does not advance the
573/// player-visible story: its output is isolated and discarded, and the
574/// transcript is untouched. The only result is the function's return
575/// value — unless the function calls an external that can't be resolved
576/// synchronously.
577#[derive(Debug, Clone)]
578pub enum FunctionEval {
579    /// The function returned this value and evaluation is complete.
580    /// (Functions with no explicit `~ return` yield [`Value::Null`].)
581    Returned(Value),
582    /// The function called an external whose handler returned
583    /// [`ExternalResult::Pending`] — typically a binding that needs
584    /// engine/World access resolved out-of-band. Evaluation is paused
585    /// with its full state intact. Inspect the pending call via
586    /// [`pending_external_name`](FlowInstance::pending_external_name) /
587    /// [`pending_external_args`](FlowInstance::pending_external_args),
588    /// supply the result with
589    /// [`resolve_external`](FlowInstance::resolve_external), then call
590    /// [`resume_function_eval`](FlowInstance::resume_function_eval).
591    AwaitingExternal,
592}
593
594// ── FlowInstance ────────────────────────────────────────────────────────────
595
596/// A single independent execution context within a story. The default flow
597/// runs from the root container; named flows can be spawned at arbitrary
598/// entry points via [`FlowInstance::new_at`].
599///
600/// A `FlowInstance` is opaque from outside the crate: its internal fields
601/// (`flow`, `status`, `stats`) are crate-private, but consumers can hold,
602/// clone, serialize, and pass `&mut FlowInstance` to the runtime's step
603/// functions. Use the inherent methods ([`step_single_line`](Self::step_single_line),
604/// [`choose`](Self::choose), [`transcript`](Self::transcript),
605/// [`status`](Self::status), etc.) for all interaction.
606#[derive(Clone, Debug)]
607pub struct FlowInstance {
608    pub(crate) flow: Flow,
609    pub(crate) status: StoryStatus,
610    pub(crate) stats: Stats,
611    /// Transient state for an in-progress engine→ink function evaluation
612    /// ([`begin_function_eval`](Self::begin_function_eval)). `Some` only
613    /// while a from-game call is mid-flight (possibly paused on an
614    /// external); `None` during normal play. Not meaningful to persist.
615    pub(crate) eval: Option<EvalState>,
616}
617
618/// Bookkeeping for an in-progress engine→ink function evaluation.
619#[derive(Debug, Clone)]
620pub(crate) struct EvalState {
621    /// Value-stack length recorded before arguments were pushed, so the
622    /// return value (and any leftover args) can be reclaimed on return.
623    pub value_floor: usize,
624    /// Pending-choice count when the eval began. A function that *grows*
625    /// this presented a choice — illegal, and distinct from choices the
626    /// main story may already have waiting.
627    pub choice_floor: usize,
628}
629
630/// Outcome of a single [`FlowInstance::drive`] call: either the drive
631/// reached a terminal line, or it paused on a deferred external mid-drive.
632/// Both variants carry every [`Line`] produced during *this* call — for
633/// `AwaitingExternal`, that's the (possibly empty) run of `Line::Text`
634/// produced before the pause; for `Terminal`, the terminal line is always
635/// the last element (see [`FlowInstance::drive`]).
636#[derive(Debug, Clone)]
637pub enum DriveOutcome {
638    /// Reached a terminal line ([`Line::Done`], [`Line::Choices`], or
639    /// [`Line::End`]) — always the last element of the `Vec`.
640    Terminal(Vec<Line>),
641    /// Paused on a deferred external
642    /// ([`ExternalResult::Pending`](crate::ExternalResult::Pending)).
643    /// Resolve it ([`FlowInstance::resolve_external`]) and call
644    /// [`FlowInstance::drive`] again — with the **same** `budget` — to
645    /// resume.
646    AwaitingExternal(Vec<Line>),
647}
648
649impl FlowInstance {
650    /// Create a new flow instance starting at the program's root container,
651    /// along with a fresh [`World`] initialized from the program's global
652    /// defaults.
653    pub fn new_at_root(program: &Program) -> (Self, World) {
654        Self::new_at(program, program.root_idx())
655    }
656
657    /// Create a new flow instance starting at an arbitrary container index,
658    /// along with a fresh [`World`]. Use this to spawn a named flow at a
659    /// specific entry point. The caller is responsible for deciding whether
660    /// to share the returned `World` with other flows or discard it and
661    /// reuse an existing one.
662    pub fn new_at(program: &Program, container_idx: u32) -> (Self, World) {
663        let globals = program.global_defaults();
664        let initial_frame = CallFrame {
665            return_address: None,
666            temps: Vec::new(),
667            container_stack: vec![ContainerPosition {
668                container_idx,
669                offset: 0,
670            }],
671            frame_type: CallFrameType::Root,
672            external_fn_id: None,
673            function_output_start: None,
674        };
675        let initial_thread = Thread {
676            call_stack: CallStack::new(initial_frame),
677        };
678        let flow_instance = Self {
679            flow: Flow {
680                threads: vec![initial_thread],
681                value_stack: Vec::new(),
682                output: OutputBuffer::new(),
683                pending_choices: Vec::new(),
684                current_tags: Vec::new(),
685                in_tag: false,
686                skipping_choice: false,
687                did_safe_exit: false,
688                did_unsafe_yield: false,
689            },
690            status: StoryStatus::Active,
691            stats: Stats::default(),
692            eval: None,
693        };
694        // All existing construction paths default to the all-`World`
695        // policy (see `docs/scoped-flow-state-spec.md` "The policy") — this
696        // is the fast path that needs no `Program` symbol lookups and
697        // can't fail, so `new_at`/`new_at_root` keep their infallible
698        // `(Self, World)` signature.
699        let world = World::from_globals(globals, ResolvedPolicy::all_world());
700        (flow_instance, world)
701    }
702
703    /// Maximum VM steps per `continue_maximally` call before erroring.
704    /// Prevents infinite loops from malformed bytecode.
705    const STEP_LIMIT: u64 = 1_000_000;
706
707    /// Execute until one complete line of output is available, or until a
708    /// yield point (choices/done/ended) if no newline occurs first.
709    ///
710    /// Returns a [`Line`] telling the caller what happened (`Text`/`Done`/
711    /// `Choices`/`End`). This is the simple API for consumers whose
712    /// external handler never defers: if the handler returns
713    /// [`ExternalResult::Pending`], this errors with
714    /// [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall).
715    /// For pausable world-access bindings, use [`advance`](Self::advance).
716    pub fn step_single_line<R: StoryRng>(
717        &mut self,
718        program: &Program,
719        line_tables: &[Vec<brink_format::LineEntry>],
720        context: &mut (impl ContextAccess + ?Sized),
721        handler: &dyn ExternalFnHandler,
722        resolver: Option<&dyn PluralResolver>,
723    ) -> Result<Line, RuntimeError> {
724        match self.advance::<R>(program, line_tables, context, handler, resolver)? {
725            StepOutcome::Line(line) => Ok(line),
726            StepOutcome::AwaitingExternal => {
727                // Preserve historical behavior for consumers using this
728                // (non-pausing) API: a deferred external they can't resolve
729                // is an error.
730                let id = self
731                    .flow
732                    .external_fn_id()
733                    .ok_or(RuntimeError::CallStackUnderflow)?;
734                Err(RuntimeError::UnresolvedExternalCall(id))
735            }
736        }
737    }
738
739    /// Maximum lines produced by a single [`drive_to_terminal`](Self::drive_to_terminal)
740    /// call before erroring. Safety net against infinite loops from
741    /// malformed bytecode.
742    pub const LINE_LIMIT: usize = 10_000;
743
744    /// Step this flow forward until the next terminal line (`Done`,
745    /// `Choices`, or `End`), collecting every [`Line`] produced along the
746    /// way.
747    ///
748    /// This is the single Layer-2 "drive to terminal" loop: [`Story`]'s
749    /// `continue_maximally*` family is a thin wrapper over it, and any other
750    /// holder of a `FlowInstance` (e.g. an engine integration like
751    /// `bevy-brink`) should reach for this instead of hand-rolling the same
752    /// loop. Semantics:
753    ///
754    /// - Steps via [`step_single_line`](Self::step_single_line): a deferred
755    ///   external ([`ExternalResult::Pending`]) is **not** paused on here —
756    ///   it errors with [`RuntimeError::UnresolvedExternalCall`], exactly as
757    ///   `step_single_line` does. Callers that need to pause on world-access
758    ///   externals mid-drive should drive [`advance`](Self::advance)
759    ///   themselves rather than use this method.
760    /// - Stops at the first [`Line`] for which [`Line::is_terminal`] returns
761    ///   `true`; that line is always the last element of the returned
762    ///   `Vec`, and every element before it is a [`Line::Text`].
763    /// - Bounded by [`Self::LINE_LIMIT`] (10,000) lines produced in a single
764    ///   call; exceeding it returns [`RuntimeError::LineLimitExceeded`]
765    ///   rather than looping forever.
766    ///
767    /// # Errors
768    /// Any error [`step_single_line`](Self::step_single_line) itself can
769    /// produce, plus [`RuntimeError::LineLimitExceeded`] if the drive
770    /// produces [`Self::LINE_LIMIT`] lines without reaching a terminal one.
771    pub fn drive_to_terminal<R: StoryRng>(
772        &mut self,
773        program: &Program,
774        line_tables: &[Vec<brink_format::LineEntry>],
775        context: &mut (impl ContextAccess + ?Sized),
776        handler: &dyn ExternalFnHandler,
777        resolver: Option<&dyn PluralResolver>,
778    ) -> Result<Vec<Line>, RuntimeError> {
779        let mut lines = Vec::new();
780        loop {
781            let line =
782                self.step_single_line::<R>(program, line_tables, context, handler, resolver)?;
783            let terminal = line.is_terminal();
784            lines.push(line);
785            if terminal {
786                return Ok(lines);
787            }
788            if lines.len() >= Self::LINE_LIMIT {
789                return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
790            }
791        }
792    }
793
794    /// The pausable Layer-2 "drive to terminal or external pause" op:
795    /// [`drive_to_terminal`](Self::drive_to_terminal)'s sibling for callers
796    /// (e.g. `bevy-brink`) whose external bindings need to pause mid-drive
797    /// for out-of-band (world-access) resolution rather than erroring.
798    ///
799    /// Steps via [`advance`](Self::advance) instead of
800    /// [`step_single_line`](Self::step_single_line): a deferred external
801    /// yields [`DriveOutcome::AwaitingExternal`] (carrying every line
802    /// produced so far this call) instead of
803    /// [`RuntimeError::UnresolvedExternalCall`]. Resolve it and call `drive`
804    /// again to continue — the drive is logically one operation spanning
805    /// however many pauses it takes.
806    ///
807    /// `budget` is the caller-owned line budget for that whole logical
808    /// operation: each line `drive` produces (whether the call ends in
809    /// `Terminal` or `AwaitingExternal`) decrements it by one, and it is
810    /// **not** reset between calls — the caller passes the same `&mut
811    /// usize` back in on resume, so a drive spanning many external pauses
812    /// still has exactly one bound on total output, not a fresh
813    /// [`Self::LINE_LIMIT`] per resume (see the "guard against unbounded
814    /// growth" rule). Start a fresh logical drive with a fresh
815    /// `budget = FlowInstance::LINE_LIMIT` (or any caller-chosen cap).
816    ///
817    /// Like `drive_to_terminal`, the terminal line is always the last
818    /// element of the returned `Vec` and every line before it is
819    /// [`Line::Text`].
820    ///
821    /// # Errors
822    /// Any error [`advance`](Self::advance) itself can produce, plus
823    /// [`RuntimeError::LineLimitExceeded`] if `budget` reaches zero before a
824    /// terminal line is produced.
825    pub fn drive<R: StoryRng>(
826        &mut self,
827        program: &Program,
828        line_tables: &[Vec<brink_format::LineEntry>],
829        context: &mut (impl ContextAccess + ?Sized),
830        handler: &dyn ExternalFnHandler,
831        resolver: Option<&dyn PluralResolver>,
832        budget: &mut usize,
833    ) -> Result<DriveOutcome, RuntimeError> {
834        // Captured only to report a meaningful number on exhaustion: the
835        // remaining budget *this call* started with, not the (possibly
836        // already-partially-spent, across earlier resumes) original cap.
837        let starting_budget = *budget;
838        let mut lines = Vec::new();
839        loop {
840            if *budget == 0 {
841                return Err(RuntimeError::LineLimitExceeded(starting_budget));
842            }
843            match self.advance::<R>(program, line_tables, context, handler, resolver)? {
844                StepOutcome::AwaitingExternal => return Ok(DriveOutcome::AwaitingExternal(lines)),
845                StepOutcome::Line(line) => {
846                    let terminal = line.is_terminal();
847                    *budget -= 1;
848                    lines.push(line);
849                    if terminal {
850                        return Ok(DriveOutcome::Terminal(lines));
851                    }
852                }
853            }
854        }
855    }
856
857    /// Like [`step_single_line`](Self::step_single_line), but surfaces a
858    /// deferred external ([`ExternalResult::Pending`]) as
859    /// [`StepOutcome::AwaitingExternal`] instead of an error — so a
860    /// world-access binding hit during normal playback can pause cleanly.
861    /// Resolve the pending external and call `advance` again to continue.
862    pub fn advance<R: StoryRng>(
863        &mut self,
864        program: &Program,
865        line_tables: &[Vec<brink_format::LineEntry>],
866        context: &mut (impl ContextAccess + ?Sized),
867        handler: &dyn ExternalFnHandler,
868        resolver: Option<&dyn PluralResolver>,
869    ) -> Result<StepOutcome, RuntimeError> {
870        self.advance_with_limit::<R>(
871            program,
872            line_tables,
873            context,
874            handler,
875            resolver,
876            Self::STEP_LIMIT,
877        )
878    }
879
880    /// Like [`advance`](Self::advance), but the per-call VM step budget is
881    /// `step_limit` rather than the hardcoded [`Self::STEP_LIMIT`].
882    ///
883    /// This is what lets [`crate::Speculation::advance`] cap a single
884    /// visible-line drive at a small, caller-supplied budget instead of the
885    /// production 1,000,000-step ceiling, so a runaway speculative probe
886    /// errors quickly instead of burning a huge step budget before giving
887    /// up. `advance` itself is a thin wrapper over this with
888    /// `step_limit: Self::STEP_LIMIT` — every existing call site keeps its
889    /// exact prior behavior.
890    #[expect(clippy::too_many_lines)]
891    pub(crate) fn advance_with_limit<R: StoryRng>(
892        &mut self,
893        program: &Program,
894        line_tables: &[Vec<brink_format::LineEntry>],
895        context: &mut (impl ContextAccess + ?Sized),
896        handler: &dyn ExternalFnHandler,
897        resolver: Option<&dyn PluralResolver>,
898        step_limit: u64,
899    ) -> Result<StepOutcome, RuntimeError> {
900        // 1. If buffer already has a completed line from a previous step,
901        //    take it immediately (no VM stepping needed).
902        if self.flow.output.has_completed_line()
903            && let Some((text, tags)) =
904                self.flow
905                    .output
906                    .take_first_line(program, line_tables, resolver)
907        {
908            return Ok(StepOutcome::Line(Line::Text { text, tags }));
909        }
910
911        // 2. If buffer has partial content but VM has already yielded
912        //    (any non-Active state), flush it. At a yield point, no more
913        //    output is coming, so trailing Newlines are committed.
914        if self.flow.output.has_unread() && self.status != StoryStatus::Active {
915            let (text, tags) = flush_remaining(&mut self.flow, program, line_tables, resolver);
916            return Ok(StepOutcome::Line(make_yield_line(
917                self.status,
918                text,
919                tags,
920                &self.flow,
921                program,
922                line_tables,
923                resolver,
924            )));
925        }
926
927        // 3. Status checks.
928        if self.status == StoryStatus::Ended {
929            return Err(RuntimeError::StoryEnded);
930        }
931        if self.status == StoryStatus::WaitingForChoice {
932            return Err(RuntimeError::NotWaitingForChoice);
933        }
934
935        // 4. Reset Done → Active (resuming after output).
936        //    If the previous cycle ended without a safe exit (no explicit
937        //    -> DONE opcode), the story ran out of content. The previous
938        //    call delivered the text — error now.
939        if self.status == StoryStatus::Done {
940            if !self.flow.did_safe_exit {
941                return Err(RuntimeError::RanOutOfContent);
942            }
943            self.status = StoryStatus::Active;
944        }
945
946        // Clear flags — will be set during this cycle if relevant.
947        self.flow.did_safe_exit = false;
948        self.flow.did_unsafe_yield = false;
949
950        // 5. Step VM loop.
951        let Self {
952            flow,
953            status,
954            stats,
955            ..
956        } = self;
957        let step_start = stats.steps;
958
959        loop {
960            stats.steps += 1;
961
962            if stats.steps - step_start > step_limit {
963                return Err(RuntimeError::StepLimitExceeded(step_limit));
964            }
965
966            let stepped = vm::step::<R>(flow, program, line_tables, context, stats, resolver)?;
967            stats.materializations += flow.drain_materializations();
968
969            match stepped {
970                vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {
971                    if flow.output.has_completed_line()
972                        && let Some((text, tags)) =
973                            flow.output.take_first_line(program, line_tables, resolver)
974                    {
975                        return Ok(StepOutcome::Line(Line::Text { text, tags }));
976                    }
977                }
978
979                vm::Stepped::ExternalCall => {
980                    // `false` means the handler deferred (Pending): pause
981                    // cleanly so the caller can resolve it out-of-band.
982                    if !resolve_external_call(flow, program, handler)? {
983                        return Ok(StepOutcome::AwaitingExternal);
984                    }
985                    if flow.output.has_completed_line()
986                        && let Some((text, tags)) =
987                            flow.output.take_first_line(program, line_tables, resolver)
988                    {
989                        return Ok(StepOutcome::Line(Line::Text { text, tags }));
990                    }
991                }
992
993                vm::Stepped::Done => {
994                    context.increment_turn_index();
995
996                    // Handle invisible default choices: auto-select and keep running.
997                    if !flow.pending_choices.is_empty() {
998                        let all_invisible = flow
999                            .pending_choices
1000                            .iter()
1001                            .all(|pc| pc.flags.is_invisible_default);
1002                        if all_invisible {
1003                            select_choice(flow, context, status, stats, 0)?;
1004                            if flow.output.has_completed_line()
1005                                && let Some((text, tags)) =
1006                                    flow.output.take_first_line(program, line_tables, resolver)
1007                            {
1008                                return Ok(StepOutcome::Line(Line::Text { text, tags }));
1009                            }
1010                            continue;
1011                        }
1012                    }
1013
1014                    // Set status based on remaining choices.
1015                    if flow.pending_choices.is_empty() {
1016                        *status = StoryStatus::Done;
1017                    } else {
1018                        *status = StoryStatus::WaitingForChoice;
1019                        stats.choices_presented += 1;
1020                    }
1021
1022                    if flow.output.has_completed_line()
1023                        && let Some((text, tags)) =
1024                            flow.output.take_first_line(program, line_tables, resolver)
1025                    {
1026                        return Ok(StepOutcome::Line(Line::Text { text, tags }));
1027                    }
1028
1029                    let (text, tags) = flush_remaining(flow, program, line_tables, resolver);
1030                    return Ok(StepOutcome::Line(make_yield_line(
1031                        *status,
1032                        text,
1033                        tags,
1034                        flow,
1035                        program,
1036                        line_tables,
1037                        resolver,
1038                    )));
1039                }
1040
1041                vm::Stepped::Ended => {
1042                    context.increment_turn_index();
1043                    *status = StoryStatus::Ended;
1044
1045                    if flow.output.has_completed_line()
1046                        && let Some((text, tags)) =
1047                            flow.output.take_first_line(program, line_tables, resolver)
1048                    {
1049                        return Ok(StepOutcome::Line(Line::Text { text, tags }));
1050                    }
1051
1052                    let (text, tags) = flush_remaining(flow, program, line_tables, resolver);
1053                    return Ok(StepOutcome::Line(Line::End { text, tags }));
1054                }
1055            }
1056        }
1057    }
1058
1059    /// Select a choice by index. Call [`step_single_line`](Self::step_single_line)
1060    /// afterward to continue execution from the chosen branch.
1061    pub fn choose(
1062        &mut self,
1063        context: &mut (impl ContextAccess + ?Sized),
1064        index: usize,
1065    ) -> Result<(), RuntimeError> {
1066        if self.status != StoryStatus::WaitingForChoice {
1067            return Err(RuntimeError::NotWaitingForChoice);
1068        }
1069        select_choice(
1070            &mut self.flow,
1071            context,
1072            &mut self.status,
1073            &mut self.stats,
1074            index,
1075        )
1076    }
1077
1078    /// Move the play head to a named knot/stitch path — the equivalent of
1079    /// ink's `Story.ChoosePathString(path)` (with its default
1080    /// `resetCallstack: true`). Call [`step_single_line`](Self::step_single_line)
1081    /// (or any continue method) afterward to run from there.
1082    ///
1083    /// `path` is a dot-separated runtime path: a knot (`intro`), a qualified
1084    /// stitch (`intro.dock`), or — for programs compiled by `brink-compiler` —
1085    /// an author label (`knot.label`, `knot.stitch.label`; an extension over
1086    /// C#, which cannot address labels).
1087    ///
1088    /// Mirroring the C# reference (`Story.ChoosePathString` →
1089    /// `ResetCallstack`/`ForceEnd` → `ChoosePath` → `state.SetChosenPath` +
1090    /// `VisitChangedContainersDueToDivert`):
1091    ///
1092    /// - The current flow is **force-completed** first: the call stack
1093    ///   collapses to a single fresh root frame (abandoning any tunnels,
1094    ///   threads, or in-progress weave), pending choices are cleared, and
1095    ///   the jump counts as a safe exit (as if the story had hit `-> DONE`).
1096    /// - The jump **counts as a visit** to the target, with exactly the
1097    ///   semantics of an in-story `-> path` divert (it goes through the same
1098    ///   goto machinery, so counting flags are honored identically).
1099    /// - Output already produced but not yet consumed is **kept** (C# leaves
1100    ///   the output stream untouched); it is delivered before content from
1101    ///   the new location. The value stack is likewise left as-is.
1102    /// - A permanently **ended** story (`-> END`) may be re-entered by
1103    ///   jumping, matching C# where `ChoosePathString` + `Continue` works
1104    ///   after the story has ended.
1105    ///
1106    /// # Errors
1107    /// - [`UnknownPath`](RuntimeError::UnknownPath) if `path` resolves to no
1108    ///   target (the message names the path).
1109    /// - [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
1110    ///   if the flow is parked on an unresolved external call — a pending
1111    ///   host call must be resolved, not silently abandoned.
1112    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1113    ///   if an engine→ink function evaluation is in progress (C# likewise
1114    ///   refuses to redirect mid-function).
1115    pub fn choose_path_string(
1116        &mut self,
1117        program: &Program,
1118        context: &mut (impl ContextAccess + ?Sized),
1119        path: &str,
1120    ) -> Result<(), RuntimeError> {
1121        self.choose_path_string_with_args(program, context, path, &[])
1122    }
1123
1124    /// Like [`choose_path_string`](Self::choose_path_string) but **binds the
1125    /// target knot's declared parameters** from `args` — host-directed entry
1126    /// into a parameterized knot/stitch (`=== call(action, present) ===`),
1127    /// which a plain path jump can't reach with its params bound.
1128    ///
1129    /// Semantics are otherwise identical to `choose_path_string` (force-ends
1130    /// the current flow, counts as a visit, etc.). The args are pushed onto the
1131    /// value stack in declaration order and bound by the target's prologue —
1132    /// exactly as an in-story `-> call(a, b)` divert binds them, so this enters
1133    /// at the container start (where the prologue runs).
1134    ///
1135    /// # Errors
1136    /// In addition to [`choose_path_string`](Self::choose_path_string)'s errors:
1137    /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) if `args.len()`
1138    /// differs from the target container's declared parameter count. (Programs
1139    /// built by the converter record no param counts, so they report `0` — pass
1140    /// no args.)
1141    pub fn choose_path_string_with_args(
1142        &mut self,
1143        program: &Program,
1144        context: &mut (impl ContextAccess + ?Sized),
1145        path: &str,
1146        args: &[Value],
1147    ) -> Result<(), RuntimeError> {
1148        // A parked host call cannot be silently abandoned: erroring is the
1149        // strictest safe behavior (brink-specific — C# has no pausable
1150        // externals during normal playback).
1151        if let Some(id) = self.flow.external_fn_id() {
1152            let external = program
1153                .external_fn(id)
1154                .map_or_else(|| format!("{id}"), |e| program.name(e.name).to_owned());
1155            return Err(RuntimeError::JumpWhileAwaitingExternal {
1156                path: path.to_owned(),
1157                external,
1158            });
1159        }
1160        // An in-flight engine→ink evaluation (possibly paused on an external)
1161        // must finish or be aborted before the flow can be redirected.
1162        if self.eval.is_some() {
1163            return Err(RuntimeError::AlreadyEvaluatingFunction);
1164        }
1165
1166        let target_id = program
1167            .find_path_target(path)
1168            .ok_or_else(|| RuntimeError::UnknownPath(path.to_owned()))?;
1169
1170        // Arity-check before mutating any state. The target container's
1171        // declared param count is what its prologue's `DeclareTemp`s will pop.
1172        let expected = program.path_param_count(path).unwrap_or(0);
1173        if args.len() != expected as usize {
1174            return Err(RuntimeError::ArgCountMismatch {
1175                target: path.to_owned(),
1176                expected,
1177                got: args.len(),
1178            });
1179        }
1180
1181        // Force-end the current flow, mirroring C# `ResetCallstack` →
1182        // `StoryState.ForceEnd`: a single fresh root frame (callStack.Reset),
1183        // cleared choices, null pointers (the empty container stack), and
1184        // didSafeExit = true. The output buffer and value stack are
1185        // deliberately left untouched — C# `ForceEnd` does not clear the
1186        // output stream or the evaluation stack.
1187        let root_frame = CallFrame {
1188            return_address: None,
1189            temps: Vec::new(),
1190            container_stack: Vec::new(),
1191            frame_type: CallFrameType::Root,
1192            external_fn_id: None,
1193            function_output_start: None,
1194        };
1195        self.flow.threads = vec![Thread {
1196            call_stack: CallStack::new(root_frame),
1197        }];
1198        self.flow.pending_choices.clear();
1199        // Transient intra-step flags. Both are false at any point a host can
1200        // observe (between lines / at a yield), but the jump abandons whatever
1201        // produced them, so clear defensively.
1202        self.flow.skipping_choice = false;
1203        self.flow.in_tag = false;
1204        self.flow.did_safe_exit = true;
1205
1206        // Push the arguments in declaration order; the target's prologue
1207        // (`DeclareTemp`) binds them, exactly as `begin_function_eval` and an
1208        // in-story `-> call(a, b)` divert do.
1209        self.flow.value_stack.extend_from_slice(args);
1210
1211        // Jump via the same divert machinery as an in-story `-> path`
1212        // (mirrors C# `ChoosePath` → `SetChosenPath` +
1213        // `VisitChangedContainersDueToDivert`): sets the position and
1214        // increments the target's visit/turn counts per its counting flags.
1215        vm::goto_target(&mut self.flow, program, context, target_id)?;
1216
1217        self.status = StoryStatus::Active;
1218        Ok(())
1219    }
1220
1221    /// The current execution status of this flow.
1222    #[must_use]
1223    pub fn status(&self) -> StoryStatus {
1224        self.status
1225    }
1226
1227    /// Runtime statistics (instructions, materialization counts, etc.)
1228    /// accumulated over this flow's execution.
1229    #[must_use]
1230    pub fn stats(&self) -> &Stats {
1231        &self.stats
1232    }
1233
1234    /// The full append-only transcript of all output parts produced so far.
1235    ///
1236    /// The transcript stores structural references (e.g. `LineRef`) rather
1237    /// than resolved strings, so it can be re-rendered in any locale by
1238    /// passing a different set of line tables to
1239    /// [`transcript::render_transcript`](crate::transcript::render_transcript).
1240    #[must_use]
1241    pub fn transcript(&self) -> &[crate::output::OutputPart] {
1242        self.flow.output.transcript()
1243    }
1244
1245    /// Number of parts in the transcript.
1246    #[must_use]
1247    pub fn transcript_len(&self) -> usize {
1248        self.flow.output.transcript_len()
1249    }
1250
1251    /// Reset the transcript read cursor to the beginning (for re-rendering,
1252    /// e.g. after a locale swap).
1253    pub fn reset_cursor(&mut self) {
1254        self.flow.output.reset_cursor();
1255    }
1256
1257    /// The fragments captured during execution (for re-rendering choice
1258    /// display text and computed substrings in a different locale).
1259    #[must_use]
1260    pub fn fragments(&self) -> &[crate::output::Fragment] {
1261        self.flow.output.fragments()
1262    }
1263
1264    // ── External calls (ink → engine) ────────────────────────────────
1265
1266    /// Returns `true` if this flow is frozen on an unresolved external
1267    /// call — i.e. the VM hit a `CallExternal` opcode and the handler
1268    /// returned [`ExternalResult::Pending`], leaving the `External` frame
1269    /// on top of the call stack.
1270    ///
1271    /// The orchestration layer (e.g. a Bevy resolver system) polls this to
1272    /// decide whether the flow needs an external resolved before it can be
1273    /// driven further. Resolve via [`resolve_external`](Self::resolve_external).
1274    #[must_use]
1275    pub fn has_pending_external(&self) -> bool {
1276        self.flow.external_fn_id().is_some()
1277    }
1278
1279    /// The [`DefinitionId`] of the pending external function, if this flow
1280    /// is frozen on one. Returns `None` otherwise.
1281    #[must_use]
1282    pub fn pending_external_fn_id(&self) -> Option<DefinitionId> {
1283        self.flow.external_fn_id()
1284    }
1285
1286    /// The arguments to the pending external call, in declaration order.
1287    /// Empty if no external call is pending.
1288    #[must_use]
1289    pub fn pending_external_args(&self) -> &[Value] {
1290        self.flow.external_args()
1291    }
1292
1293    /// The ink-declared name of the pending external function, resolved
1294    /// against `program`'s name table. Returns `None` if no external is
1295    /// pending (or the entry is missing, which would indicate a malformed
1296    /// program).
1297    ///
1298    /// The orchestration layer uses this to look up the binding registered
1299    /// for this name.
1300    #[must_use]
1301    pub fn pending_external_name<'p>(&self, program: &'p Program) -> Option<&'p str> {
1302        let id = self.flow.external_fn_id()?;
1303        let entry = program.external_fn(id)?;
1304        Some(program.name(entry.name))
1305    }
1306
1307    /// Resolve a pending external call by supplying its return value. Pops
1308    /// the `External` frame and pushes `value` onto the value stack so the
1309    /// VM can resume. For fire-and-forget externals, pass [`Value::Null`].
1310    ///
1311    /// No-op if no external call is pending. After resolving, drive the
1312    /// flow forward with [`step_single_line`](Self::step_single_line).
1313    pub fn resolve_external(&mut self, value: Value) {
1314        self.flow.resolve_external(value);
1315    }
1316
1317    // ── Engine → ink calls ───────────────────────────────────────────
1318
1319    /// Evaluate an ink function from engine code, returning its value.
1320    ///
1321    /// This does **not** advance the player-visible story: a
1322    /// `FunctionEvalFromGame` boundary frame is pushed, `args` are passed
1323    /// in declaration order (exactly as a normal call site would), output
1324    /// is captured and discarded, and the function runs until it returns.
1325    ///
1326    /// If the function calls an external whose handler returns
1327    /// [`ExternalResult::Pending`] (e.g. a binding that needs Bevy World
1328    /// access), evaluation pauses and returns
1329    /// [`FunctionEval::AwaitingExternal`]; the caller resolves the
1330    /// external (see [`resolve_external`](Self::resolve_external)) and
1331    /// calls [`resume_function_eval`](Self::resume_function_eval).
1332    ///
1333    /// `container_idx` is the function's container, typically obtained from
1334    /// [`Program::find_address`](crate::Program::find_address) on the
1335    /// function name. Unlike a normal `Call`, this does not increment the
1336    /// function's visit count — an engine query is out-of-band, matching
1337    /// C#'s `EvaluateFunction`.
1338    ///
1339    /// # Errors
1340    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1341    ///   if a function evaluation is already in progress on this flow.
1342    /// - [`FunctionYielded`](RuntimeError::FunctionYielded) if the function
1343    ///   presents choices or ends the story (functions must not yield).
1344    /// - [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall)
1345    ///   if an external has neither a binding nor a fallback.
1346    #[expect(
1347        clippy::too_many_arguments,
1348        reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target and args"
1349    )]
1350    pub fn begin_function_eval<R: StoryRng>(
1351        &mut self,
1352        program: &Program,
1353        line_tables: &[Vec<brink_format::LineEntry>],
1354        context: &mut (impl ContextAccess + ?Sized),
1355        handler: &dyn ExternalFnHandler,
1356        container_idx: u32,
1357        args: &[Value],
1358        resolver: Option<&dyn PluralResolver>,
1359    ) -> Result<FunctionEval, RuntimeError> {
1360        if self.eval.is_some() {
1361            return Err(RuntimeError::AlreadyEvaluatingFunction);
1362        }
1363
1364        // Record floors BEFORE pushing args: the value-stack length (so the
1365        // return value and any leftover args can be reclaimed), and the
1366        // pending-choice count (so we can tell a choice the function
1367        // presents from choices the main story already has waiting).
1368        let value_floor = self.flow.value_stack.len();
1369        let choice_floor = self.flow.pending_choices.len();
1370
1371        // Isolate output: anything the function emits routes to the
1372        // capture scratch space and never reaches the transcript.
1373        self.flow.output.begin_capture();
1374
1375        let output_start = self.flow.output.target_len();
1376        let boundary = CallFrame {
1377            return_address: None,
1378            temps: Vec::new(),
1379            container_stack: vec![ContainerPosition {
1380                container_idx,
1381                offset: 0,
1382            }],
1383            frame_type: CallFrameType::FunctionEvalFromGame,
1384            external_fn_id: None,
1385            function_output_start: Some(output_start),
1386        };
1387        self.flow.current_thread_mut().call_stack.push(boundary);
1388        self.stats.frames_pushed += 1;
1389
1390        // Pass arguments onto the value stack in declaration order — the
1391        // function's prologue (`DeclareTemp`) binds them exactly as it
1392        // would for an in-story call.
1393        self.flow.value_stack.extend_from_slice(args);
1394
1395        self.eval = Some(EvalState {
1396            value_floor,
1397            choice_floor,
1398        });
1399        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
1400    }
1401
1402    /// Resume a function evaluation that paused on
1403    /// [`FunctionEval::AwaitingExternal`], after the pending external has
1404    /// been resolved via [`resolve_external`](Self::resolve_external).
1405    ///
1406    /// # Errors
1407    /// - [`NotEvaluatingFunction`](RuntimeError::NotEvaluatingFunction) if
1408    ///   no evaluation is in progress.
1409    /// - Same evaluation errors as
1410    ///   [`begin_function_eval`](Self::begin_function_eval).
1411    pub fn resume_function_eval<R: StoryRng>(
1412        &mut self,
1413        program: &Program,
1414        line_tables: &[Vec<brink_format::LineEntry>],
1415        context: &mut (impl ContextAccess + ?Sized),
1416        handler: &dyn ExternalFnHandler,
1417        resolver: Option<&dyn PluralResolver>,
1418    ) -> Result<FunctionEval, RuntimeError> {
1419        if self.eval.is_none() {
1420            return Err(RuntimeError::NotEvaluatingFunction);
1421        }
1422        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
1423    }
1424
1425    /// Returns `true` if a function evaluation is in progress (possibly
1426    /// paused awaiting an external).
1427    #[must_use]
1428    pub fn is_evaluating_function(&self) -> bool {
1429        self.eval.is_some()
1430    }
1431
1432    /// Step the VM until the in-progress function evaluation returns or
1433    /// pauses on a pending external. Shared by `begin`/`resume`.
1434    fn drive_function_eval<R: StoryRng>(
1435        &mut self,
1436        program: &Program,
1437        line_tables: &[Vec<brink_format::LineEntry>],
1438        context: &mut (impl ContextAccess + ?Sized),
1439        handler: &dyn ExternalFnHandler,
1440        resolver: Option<&dyn PluralResolver>,
1441    ) -> Result<FunctionEval, RuntimeError> {
1442        let step_start = self.stats.steps;
1443        loop {
1444            self.stats.steps += 1;
1445            if self.stats.steps - step_start > Self::STEP_LIMIT {
1446                self.abort_eval(program, line_tables, resolver);
1447                return Err(RuntimeError::StepLimitExceeded(Self::STEP_LIMIT));
1448            }
1449
1450            let stepped = vm::step::<R>(
1451                &mut self.flow,
1452                program,
1453                line_tables,
1454                context,
1455                &mut self.stats,
1456                resolver,
1457            )?;
1458            self.stats.materializations += self.flow.drain_materializations();
1459
1460            match stepped {
1461                vm::Stepped::Done | vm::Stepped::Ended => {
1462                    // A function reached `-> DONE`/`-> END` — illegal.
1463                    self.abort_eval(program, line_tables, resolver);
1464                    return Err(RuntimeError::FunctionYielded);
1465                }
1466                vm::Stepped::ExternalCall => {
1467                    if let Some(pending) =
1468                        self.resolve_eval_external(program, line_tables, resolver, handler)?
1469                    {
1470                        return Ok(pending);
1471                    }
1472                }
1473                vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {}
1474            }
1475
1476            // Did the boundary frame pop? Then the function has returned
1477            // (via `~ return` or implicit exhaustion).
1478            if !self.flow.has_eval_boundary() {
1479                let _captured = self.flow.output.end_capture(program, line_tables, resolver);
1480                let floor = self.eval.take().map_or(0, |e| e.value_floor);
1481                let mut ret: Option<Value> = None;
1482                while self.flow.value_stack.len() > floor {
1483                    let v = self.flow.value_stack.pop();
1484                    if ret.is_none() {
1485                        ret = v; // first popped = top of stack = the return value
1486                    }
1487                }
1488                return Ok(FunctionEval::Returned(ret.unwrap_or(Value::Null)));
1489            }
1490
1491            // A function must not present choices. Compare against the
1492            // count when the eval began — the main story may already have
1493            // choices waiting, which are none of our concern.
1494            let choice_floor = self.eval.as_ref().map_or(0, |e| e.choice_floor);
1495            if self.flow.pending_choices.len() > choice_floor {
1496                self.abort_eval(program, line_tables, resolver);
1497                return Err(RuntimeError::FunctionYielded);
1498            }
1499        }
1500    }
1501
1502    /// Resolve an external hit during function evaluation, mirroring the
1503    /// normal step path but surfacing [`ExternalResult::Pending`] as
1504    /// [`FunctionEval::AwaitingExternal`] (returned as `Some`) rather than
1505    /// an error. Returns `None` when the external resolved and stepping
1506    /// should continue.
1507    fn resolve_eval_external(
1508        &mut self,
1509        program: &Program,
1510        line_tables: &[Vec<brink_format::LineEntry>],
1511        resolver: Option<&dyn PluralResolver>,
1512        handler: &dyn ExternalFnHandler,
1513    ) -> Result<Option<FunctionEval>, RuntimeError> {
1514        let fn_id = self
1515            .flow
1516            .external_fn_id()
1517            .ok_or(RuntimeError::CallStackUnderflow)?;
1518        let entry = program.external_fn(fn_id);
1519        let fn_name = entry.map_or("?", |e| program.name(e.name));
1520        match handler.call(fn_name, self.flow.external_args()) {
1521            ExternalResult::Resolved(value) => {
1522                self.flow.resolve_external(value);
1523                Ok(None)
1524            }
1525            ExternalResult::Fallback => {
1526                if let Some(fb_id) = entry.and_then(|e| e.fallback) {
1527                    let container_idx = program
1528                        .resolve_target(fb_id)
1529                        .map(|(idx, _)| idx)
1530                        .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1531                    self.flow.invoke_fallback(container_idx);
1532                    Ok(None)
1533                } else {
1534                    self.abort_eval(program, line_tables, resolver);
1535                    Err(RuntimeError::UnresolvedExternalCall(fn_id))
1536                }
1537            }
1538            ExternalResult::Pending => Ok(Some(FunctionEval::AwaitingExternal)),
1539        }
1540    }
1541
1542    /// Tear down an aborted/failed evaluation: end the output capture and
1543    /// clear the eval marker. Leaves the call stack as-is (the caller is
1544    /// erroring out).
1545    fn abort_eval(
1546        &mut self,
1547        program: &Program,
1548        line_tables: &[Vec<brink_format::LineEntry>],
1549        resolver: Option<&dyn PluralResolver>,
1550    ) {
1551        if self.eval.take().is_some() {
1552            let _ = self.flow.output.end_capture(program, line_tables, resolver);
1553        }
1554    }
1555}
1556
1557/// Internal: set execution position to the given choice target, clear
1558/// pending choices, and set status to Active. No status precondition.
1559#[expect(clippy::similar_names)]
1560/// Returns the `DefinitionId` of the selected choice target, so the
1561/// caller can notify observers if needed.
1562fn select_choice(
1563    flow: &mut Flow,
1564    context: &mut (impl ContextAccess + ?Sized),
1565    status: &mut StoryStatus,
1566    stats: &mut Stats,
1567    index: usize,
1568) -> Result<(), RuntimeError> {
1569    let available = flow.pending_choices.len();
1570    if index >= available {
1571        return Err(RuntimeError::InvalidChoiceIndex { index, available });
1572    }
1573
1574    let choice = flow.pending_choices.swap_remove(index);
1575    let target_id = choice.target_id;
1576
1577    // Increment visit count for the choice target container so that
1578    // once-only choices can be filtered on subsequent passes.
1579    context.increment_visit(target_id);
1580    context.set_turn_count(target_id, context.turn_index());
1581
1582    // Replace the current thread with the fork from choice creation
1583    // time. By selection time, all spawned threads should have
1584    // completed — only the main thread remains.
1585    let current = flow.current_thread_mut();
1586    *current = choice.thread_fork;
1587
1588    // Set execution position to the choice target. We reset the top
1589    // frame's container_stack to just the target — the snapshot may
1590    // have captured stale nesting from inside the choice eval block.
1591    let frame = current
1592        .call_stack
1593        .last_mut()
1594        .ok_or(RuntimeError::CallStackUnderflow)?;
1595
1596    frame.container_stack.clear();
1597    frame.container_stack.push(ContainerPosition {
1598        container_idx: choice.target_idx,
1599        offset: choice.target_offset,
1600    });
1601
1602    flow.pending_choices.clear();
1603    *status = StoryStatus::Active;
1604    stats.choices_selected += 1;
1605
1606    Ok(())
1607}
1608
1609/// Resolve an external function call using the handler and program metadata.
1610///
1611/// Returns `Ok(true)` if the call was resolved (a value was supplied or the
1612/// in-story fallback was invoked) and stepping should continue; `Ok(false)`
1613/// if the handler deferred ([`ExternalResult::Pending`]), leaving the
1614/// `External` frame intact for the caller to resolve out-of-band. Errors
1615/// only when the handler declined and no fallback exists.
1616fn resolve_external_call(
1617    flow: &mut Flow,
1618    program: &Program,
1619    handler: &dyn ExternalFnHandler,
1620) -> Result<bool, RuntimeError> {
1621    let fn_id = flow
1622        .external_fn_id()
1623        .ok_or(RuntimeError::CallStackUnderflow)?;
1624
1625    let entry = program.external_fn(fn_id);
1626    let fn_name = entry.map_or("?", |e| program.name(e.name));
1627
1628    let result = handler.call(fn_name, flow.external_args());
1629    match result {
1630        ExternalResult::Resolved(value) => {
1631            flow.resolve_external(value);
1632            Ok(true)
1633        }
1634        ExternalResult::Fallback => {
1635            let fallback_id = entry.and_then(|e| e.fallback);
1636            if let Some(fb_id) = fallback_id {
1637                let container_idx = program
1638                    .resolve_target(fb_id)
1639                    .map(|(idx, _)| idx)
1640                    .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1641
1642                flow.invoke_fallback(container_idx);
1643                Ok(true)
1644            } else {
1645                Err(RuntimeError::UnresolvedExternalCall(fn_id))
1646            }
1647        }
1648        ExternalResult::Pending => {
1649            // Leave the External frame intact — the caller resolves it
1650            // out-of-band (via resolve_external) before continuing.
1651            Ok(false)
1652        }
1653    }
1654}
1655
1656/// Flush remaining output buffer content into `(text, tags)`.
1657///
1658/// At a yield point (Done/Choices/Ended), no more output is coming, so
1659/// trailing newlines are committed. Lines are joined with `\n` and tags
1660/// are flattened into a single vec.
1661fn flush_remaining(
1662    flow: &mut Flow,
1663    program: &Program,
1664    line_tables: &[Vec<brink_format::LineEntry>],
1665    resolver: Option<&dyn brink_format::PluralResolver>,
1666) -> (String, Vec<String>) {
1667    let lines = flow.output.flush_lines(program, line_tables, resolver);
1668    let mut text = String::new();
1669    let mut tags = Vec::new();
1670    for (i, (line_text, line_tags)) in lines.iter().enumerate() {
1671        if i > 0 {
1672            text.push('\n');
1673        }
1674        text.push_str(line_text);
1675        tags.extend_from_slice(line_tags);
1676    }
1677    (text, tags)
1678}
1679
1680/// Build the appropriate [`Line`] variant for a yield point based on
1681/// the current story status.
1682fn make_yield_line(
1683    status: StoryStatus,
1684    text: String,
1685    tags: Vec<String>,
1686    flow: &Flow,
1687    program: &Program,
1688    line_tables: &[Vec<brink_format::LineEntry>],
1689    resolver: Option<&dyn brink_format::PluralResolver>,
1690) -> Line {
1691    match status {
1692        StoryStatus::WaitingForChoice => {
1693            let choices = flow
1694                .pending_choices
1695                .iter()
1696                .enumerate()
1697                .filter(|(_, pc)| !pc.flags.is_invisible_default)
1698                .map(|(i, pc)| {
1699                    let display_text = match &pc.display {
1700                        ChoiceDisplay::Text(s) => s.clone(),
1701                        ChoiceDisplay::Fragment(idx) => {
1702                            flow.output
1703                                .resolve_fragment(*idx, program, line_tables, resolver)
1704                        }
1705                    };
1706                    // Trim spaces/tabs from choice display text, matching C#:
1707                    // choice.text = (startText + choiceOnlyText).Trim(' ', '\t');
1708                    let display_text = display_text
1709                        .trim_matches(|c: char| c == ' ' || c == '\t')
1710                        .to_string();
1711                    Choice {
1712                        text: display_text,
1713                        index: i,
1714                        tags: pc.tags.clone(),
1715                    }
1716                })
1717                .collect();
1718            Line::Choices {
1719                text,
1720                tags,
1721                choices,
1722            }
1723        }
1724        StoryStatus::Ended => Line::End { text, tags },
1725        StoryStatus::Done => Line::Done { text, tags },
1726        StoryStatus::Active => Line::Text { text, tags },
1727    }
1728}
1729
1730// ── Story ───────────────────────────────────────────────────────────────────
1731
1732/// Per-instance mutable state for executing stories.
1733///
1734/// Created from a [`Program`] via [`Story::new`]. Holds all mutable state
1735/// (stacks, globals, output buffer) while the immutable program data lives
1736/// in [`Program`].
1737///
1738/// Generic over `R: StoryRng` — defaults to [`FastRng`]. Use
1739/// [`DotNetRng`](crate::DotNetRng) for .NET-compatible deterministic output.
1740pub struct Story<R: StoryRng = FastRng> {
1741    program: Arc<Program>,
1742    pub(crate) default: FlowInstance,
1743    pub(crate) default_context: World,
1744    /// The default flow's per-flow override layer. Empty in F1.3 (F3 fills
1745    /// it in) — the routing view built from `(default_context, default_local)`
1746    /// is an all-`World` passthrough, so this contributes nothing yet.
1747    default_local: FlowLocal,
1748    line_tables: Vec<Vec<brink_format::LineEntry>>,
1749    instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
1750    /// Named flows that **share** `default_context` (globals / visit counts /
1751    /// rng) — true ink concurrent-flow semantics, where one flow's writes are
1752    /// visible to the others. Each still has its own call stack + temps (those
1753    /// live in the [`FlowInstance`]). Distinct from `instances`, whose flows
1754    /// each own an isolated `World` (bevy-brink's per-entity model). Transient
1755    /// studio/host state — not persisted in a [`StorySnapshot`].
1756    shared_instances: HashMap<String, FlowInstance>,
1757    resolver: Option<Box<dyn PluralResolver>>,
1758    _rng: PhantomData<R>,
1759}
1760
1761impl<R: StoryRng> Clone for Story<R> {
1762    fn clone(&self) -> Self {
1763        Self {
1764            program: Arc::clone(&self.program),
1765            default: self.default.clone(),
1766            default_context: self.default_context.clone(),
1767            default_local: self.default_local.clone(),
1768            line_tables: self.line_tables.clone(),
1769            instances: self.instances.clone(),
1770            shared_instances: self.shared_instances.clone(),
1771            resolver: None,
1772            _rng: PhantomData,
1773        }
1774    }
1775}
1776
1777/// Owned story state that can be detached from a `Program` and reattached later.
1778///
1779/// Created by [`Story::into_snapshot`], consumed by [`Story::from_snapshot`].
1780/// This enables locale hot-swapping: detach state, mutate the program's line
1781/// tables, then reattach.
1782pub struct StorySnapshot<R: StoryRng = FastRng> {
1783    default: FlowInstance,
1784    default_context: World,
1785    default_local: FlowLocal,
1786    instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
1787    _rng: PhantomData<R>,
1788}
1789
1790impl<R: StoryRng> Story<R> {
1791    /// Create a new story instance from a linked program and its line tables.
1792    pub fn new(program: Arc<Program>, line_tables: Vec<Vec<brink_format::LineEntry>>) -> Self {
1793        let (default, default_context) = FlowInstance::new_at_root(&program);
1794        Self {
1795            program,
1796            default,
1797            default_context,
1798            default_local: FlowLocal::new(),
1799            line_tables,
1800            instances: HashMap::new(),
1801            shared_instances: HashMap::new(),
1802            resolver: None,
1803            _rng: PhantomData,
1804        }
1805    }
1806
1807    /// Set the plural resolver for Select resolution in localized lines.
1808    pub fn set_plural_resolver(&mut self, resolver: Box<dyn PluralResolver>) {
1809        self.resolver = Some(resolver);
1810    }
1811
1812    /// Replace the active line tables (e.g. for locale swapping).
1813    pub fn set_line_tables(&mut self, tables: Vec<Vec<brink_format::LineEntry>>) {
1814        self.line_tables = tables;
1815    }
1816
1817    /// Read-only access to the current line tables.
1818    pub fn line_tables(&self) -> &[Vec<brink_format::LineEntry>] {
1819        &self.line_tables
1820    }
1821
1822    /// The full append-only transcript of all output parts produced so far.
1823    pub fn transcript(&self) -> &[crate::output::OutputPart] {
1824        self.default.flow.output.transcript()
1825    }
1826
1827    /// Number of parts in the transcript.
1828    pub fn transcript_len(&self) -> usize {
1829        self.default.flow.output.transcript_len()
1830    }
1831
1832    /// Reset the transcript read cursor to the beginning (for re-rendering).
1833    pub fn reset_cursor(&mut self) {
1834        self.default.flow.output.reset_cursor();
1835    }
1836
1837    /// Resolve a slice of the transcript against the current line tables.
1838    /// Returns `(text, tags)` tuples — one per line in the resolved output.
1839    pub fn resolve_transcript_slice(
1840        &self,
1841        range: std::ops::Range<usize>,
1842    ) -> Vec<(String, Vec<String>)> {
1843        let transcript = self.default.flow.output.transcript();
1844        let end = range.end.min(transcript.len());
1845        let start = range.start.min(end);
1846        let slice = &transcript[start..end];
1847        let fragments = self.default.flow.output.fragments();
1848        crate::output::resolve_lines(
1849            slice,
1850            &self.program,
1851            &self.line_tables,
1852            self.resolver.as_deref(),
1853            fragments,
1854        )
1855    }
1856
1857    /// Re-resolve all pending choices against the current line tables.
1858    /// Returns the same choices that would appear in `Line::Choices`,
1859    /// but freshly resolved (useful after locale switch).
1860    pub fn pending_choices(&self) -> Vec<Choice> {
1861        self.resolved_choices_for(&self.default.flow)
1862    }
1863
1864    /// Resolve a given flow's pending choices against the current line tables.
1865    /// Shared by [`pending_choices`](Self::pending_choices) (default flow) and
1866    /// the per-flow debug snapshot (#200 shared flows).
1867    fn resolved_choices_for(&self, flow: &Flow) -> Vec<Choice> {
1868        flow.pending_choices
1869            .iter()
1870            .enumerate()
1871            .filter(|(_, pc)| !pc.flags.is_invisible_default)
1872            .map(|(i, pc)| {
1873                let display_text = match &pc.display {
1874                    ChoiceDisplay::Text(s) => s.clone(),
1875                    ChoiceDisplay::Fragment(idx) => flow.output.resolve_fragment(
1876                        *idx,
1877                        &self.program,
1878                        &self.line_tables,
1879                        self.resolver.as_deref(),
1880                    ),
1881                };
1882                let display_text = display_text
1883                    .trim_matches(|c: char| c == ' ' || c == '\t')
1884                    .to_string();
1885                Choice {
1886                    text: display_text,
1887                    index: i,
1888                    tags: pc.tags.clone(),
1889                }
1890            })
1891            .collect()
1892    }
1893
1894    /// Resolve a fragment against the current line tables.
1895    pub fn resolve_fragment(&self, idx: u32) -> String {
1896        self.default.flow.output.resolve_fragment(
1897            idx,
1898            &self.program,
1899            &self.line_tables,
1900            self.resolver.as_deref(),
1901        )
1902    }
1903
1904    /// Get the fragment index for a pending choice's display text, if any.
1905    pub fn choice_fragment_idx(&self, choice_index: usize) -> Option<u32> {
1906        self.default
1907            .flow
1908            .pending_choices
1909            .get(choice_index)
1910            .and_then(|pc| match &pc.display {
1911                ChoiceDisplay::Fragment(idx) => Some(*idx),
1912                ChoiceDisplay::Text(_) => None,
1913            })
1914    }
1915
1916    /// Read-only access to the fragment store (for transcript serialization).
1917    pub fn fragments(&self) -> &[crate::output::Fragment] {
1918        self.default.flow.output.fragments()
1919    }
1920
1921    /// Read-only access to the program.
1922    pub fn program(&self) -> &Program {
1923        &self.program
1924    }
1925
1926    /// Cheap `Arc` clone of the program, for callers (e.g. [`crate::save`])
1927    /// that need a `&Program` alongside a disjoint mutable borrow of another
1928    /// field — `self.program()` ties its `&Program` to all of `&self`, which
1929    /// conflicts with a simultaneous `&mut self.default_context`.
1930    pub(crate) fn program_arc(&self) -> Arc<Program> {
1931        Arc::clone(&self.program)
1932    }
1933
1934    // ── Variable access (host-facing) ───────────────────────────────
1935
1936    /// Read a global variable's current value by name. `None` if no global
1937    /// with that name is declared. Reads the default flow's context.
1938    pub fn variable(&self, name: &str) -> Option<&Value> {
1939        let idx = self.program.global_index(name)?;
1940        Some(ContextAccess::global(&self.default_context, idx))
1941    }
1942
1943    /// Set a global variable by name, returning `false` (no-op) if no global
1944    /// with that name is declared. Ink globals are dynamically typed, so the
1945    /// host is responsible for passing a sensibly-typed value.
1946    pub fn set_variable(&mut self, name: &str, value: Value) -> bool {
1947        match self.program.global_index(name) {
1948            Some(idx) => {
1949                ContextAccess::set_global(&mut self.default_context, idx, value);
1950                true
1951            }
1952            None => false,
1953        }
1954    }
1955
1956    /// Set the RNG seed for the default flow's context. Seeding makes
1957    /// `RANDOM`/shuffle output reproducible — set it before running (or after
1958    /// a reset) so two runs of the same story on different machines match.
1959    pub fn set_rng_seed(&mut self, seed: i32) {
1960        ContextAccess::set_rng_seed(&mut self.default_context, seed);
1961    }
1962
1963    // ── Pausable stepping (async externals) ─────────────────────────
1964
1965    /// Advance the default flow by one step with a custom handler, surfacing a
1966    /// deferred external as [`StepOutcome::AwaitingExternal`] rather than
1967    /// erroring (unlike [`continue_single_with`](Self::continue_single_with)).
1968    ///
1969    /// On `AwaitingExternal`, resolve the pending call
1970    /// ([`resolve_external`](Self::resolve_external), or
1971    /// [`invoke_fallback`](Self::invoke_fallback)) and call `advance_with` again
1972    /// to resume. Inspect the pending call via
1973    /// [`pending_external_name`](Self::pending_external_name) /
1974    /// [`pending_external_args`](Self::pending_external_args).
1975    pub fn advance_with(
1976        &mut self,
1977        handler: &dyn ExternalFnHandler,
1978    ) -> Result<StepOutcome, RuntimeError> {
1979        let resolver = self.resolver.as_deref();
1980        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
1981        self.default.advance::<R>(
1982            &self.program,
1983            &self.line_tables,
1984            &mut view,
1985            handler,
1986            resolver,
1987        )
1988    }
1989
1990    /// Name of the external the default flow is paused on, if any.
1991    #[must_use]
1992    pub fn pending_external_name(&self) -> Option<&str> {
1993        self.default.pending_external_name(&self.program)
1994    }
1995
1996    /// Arguments of the external the default flow is paused on.
1997    #[must_use]
1998    pub fn pending_external_args(&self) -> &[Value] {
1999        self.default.pending_external_args()
2000    }
2001
2002    /// Evaluate an ink function by name from engine code, returning its value.
2003    ///
2004    /// Runs out-of-band on the default flow: output is isolated (the visible
2005    /// story is untouched), and the call completes synchronously. Externals the
2006    /// function calls are resolved inline by `handler`; an external the handler
2007    /// defers ([`ExternalResult::Pending`]) can't be resolved in a synchronous
2008    /// call and yields [`RuntimeError::AsyncExternalInCall`] (the paused eval is
2009    /// cleaned up first).
2010    ///
2011    /// # Errors
2012    /// [`RuntimeError::FunctionNotFound`] for an unknown name;
2013    /// [`RuntimeError::AsyncExternalInCall`] if a called external defers; plus
2014    /// any runtime error raised during evaluation.
2015    pub fn call_function(
2016        &mut self,
2017        name: &str,
2018        args: &[Value],
2019        handler: &dyn ExternalFnHandler,
2020    ) -> Result<Value, RuntimeError> {
2021        let container_idx = self
2022            .program
2023            .find_address(name)
2024            .ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
2025            .0;
2026        // Arity-check against the function's declared parameters (compiler-built
2027        // programs only; converter-built ones record 0 and so accept no args).
2028        let expected = self.program.container(container_idx).param_count;
2029        if args.len() != expected as usize {
2030            return Err(RuntimeError::ArgCountMismatch {
2031                target: name.to_owned(),
2032                expected,
2033                got: args.len(),
2034            });
2035        }
2036        let resolver = self.resolver.as_deref();
2037        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2038        let outcome = self.default.begin_function_eval::<R>(
2039            &self.program,
2040            &self.line_tables,
2041            &mut view,
2042            handler,
2043            container_idx,
2044            args,
2045            resolver,
2046        )?;
2047        match outcome {
2048            FunctionEval::Returned(value) => Ok(value),
2049            FunctionEval::AwaitingExternal => {
2050                let name = self
2051                    .default
2052                    .pending_external_name(&self.program)
2053                    .map_or_else(|| name.to_owned(), ToOwned::to_owned);
2054                self.default
2055                    .abort_eval(&self.program, &self.line_tables, resolver);
2056                Err(RuntimeError::AsyncExternalInCall(name))
2057            }
2058        }
2059    }
2060
2061    /// Fork a [`Speculation`](crate::Speculation) — a sandboxed,
2062    /// side-effect-proof speculative run — from the default flow's
2063    /// current state.
2064    ///
2065    /// The speculation owns an independent snapshot: driving it (via its
2066    /// own `advance`/`choose`/`go_to_path`/`eval_function` verbs) never
2067    /// mutates this `Story`. Dropping it discards everything it did. See
2068    /// [`crate::Speculation`] for the full picture, and
2069    /// [`crate::Speculation::fork_from`] for forking a non-default flow
2070    /// (e.g. a named flow spawned via [`spawn_flow`](Self::spawn_flow)).
2071    #[must_use]
2072    pub fn speculate(&self) -> crate::Speculation<R> {
2073        crate::Speculation::fork_from(
2074            Arc::clone(&self.program),
2075            &self.default_context,
2076            &self.default_local,
2077            &self.default,
2078            &self.line_tables,
2079        )
2080    }
2081
2082    /// Detach story state from the program, consuming the story.
2083    pub fn into_snapshot(self) -> (StorySnapshot<R>, Vec<Vec<brink_format::LineEntry>>) {
2084        let snapshot = StorySnapshot {
2085            default: self.default,
2086            default_context: self.default_context,
2087            default_local: self.default_local,
2088            instances: self.instances,
2089            _rng: PhantomData,
2090        };
2091        (snapshot, self.line_tables)
2092    }
2093
2094    /// Reattach a snapshot to a program with line tables.
2095    pub fn from_snapshot(
2096        program: Arc<Program>,
2097        snapshot: StorySnapshot<R>,
2098        line_tables: Vec<Vec<brink_format::LineEntry>>,
2099    ) -> Self {
2100        Self {
2101            program,
2102            default: snapshot.default,
2103            default_context: snapshot.default_context,
2104            default_local: snapshot.default_local,
2105            line_tables,
2106            instances: snapshot.instances,
2107            // Shared flows are transient (not persisted) — a reattached story
2108            // starts with none.
2109            shared_instances: HashMap::new(),
2110            resolver: None,
2111            _rng: PhantomData,
2112        }
2113    }
2114
2115    // ── Execution API ──────────────────────────────────────────────
2116
2117    /// Execute until one line of content (up to newline), or until a
2118    /// yield point (choices/end) if no newline occurs first.
2119    ///
2120    /// The returned [`Line`] variant tells you what to do next:
2121    /// - [`Line::Text`] — more output may follow, keep calling.
2122    /// - [`Line::Choices`] — call [`choose`](Self::choose) then resume.
2123    /// - [`Line::End`] — the story has permanently ended.
2124    pub fn continue_single(&mut self) -> Result<Line, RuntimeError> {
2125        let resolver = self.resolver.as_deref();
2126        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2127        self.default.step_single_line::<R>(
2128            &self.program,
2129            &self.line_tables,
2130            &mut view,
2131            &FallbackHandler,
2132            resolver,
2133        )
2134    }
2135
2136    /// Like [`continue_single`](Self::continue_single) but with a
2137    /// [`WriteObserver`] that receives notifications for every state mutation.
2138    pub fn continue_single_observed(
2139        &mut self,
2140        observer: &mut dyn WriteObserver,
2141    ) -> Result<Line, RuntimeError> {
2142        use crate::state::ObservedContext;
2143        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2144        let mut obs_ctx = ObservedContext::new(&mut view, observer);
2145        let resolver = self.resolver.as_deref();
2146        self.default.step_single_line::<R>(
2147            &self.program,
2148            &self.line_tables,
2149            &mut obs_ctx,
2150            &FallbackHandler,
2151            resolver,
2152        )
2153    }
2154
2155    /// Like [`continue_single`](Self::continue_single) but with a custom
2156    /// external function handler.
2157    pub fn continue_single_with(
2158        &mut self,
2159        handler: &dyn ExternalFnHandler,
2160    ) -> Result<Line, RuntimeError> {
2161        let resolver = self.resolver.as_deref();
2162        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2163        self.default.step_single_line::<R>(
2164            &self.program,
2165            &self.line_tables,
2166            &mut view,
2167            handler,
2168            resolver,
2169        )
2170    }
2171
2172    /// Execute until the next yield point, collecting all lines.
2173    ///
2174    /// Returns a `Vec<Line>` where the last element is always
2175    /// [`Line::Choices`] or [`Line::End`], and all preceding elements
2176    /// are [`Line::Text`].
2177    pub fn continue_maximally(&mut self) -> Result<Vec<Line>, RuntimeError> {
2178        self.continue_maximally_impl(&FallbackHandler)
2179    }
2180
2181    /// Like [`continue_maximally`](Self::continue_maximally) but with a
2182    /// custom external function handler.
2183    pub fn continue_maximally_with(
2184        &mut self,
2185        handler: &dyn ExternalFnHandler,
2186    ) -> Result<Vec<Line>, RuntimeError> {
2187        self.continue_maximally_impl(handler)
2188    }
2189
2190    fn continue_maximally_impl(
2191        &mut self,
2192        handler: &dyn ExternalFnHandler,
2193    ) -> Result<Vec<Line>, RuntimeError> {
2194        let resolver = self.resolver.as_deref();
2195        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2196        self.default.drive_to_terminal::<R>(
2197            &self.program,
2198            &self.line_tables,
2199            &mut view,
2200            handler,
2201            resolver,
2202        )
2203    }
2204
2205    /// Execute until the next yield point with a [`WriteObserver`] that
2206    /// receives notifications for every state mutation.
2207    pub fn continue_maximally_observed(
2208        &mut self,
2209        observer: &mut dyn WriteObserver,
2210    ) -> Result<Vec<Line>, RuntimeError> {
2211        use crate::state::ObservedContext;
2212        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2213        let mut obs_ctx = ObservedContext::new(&mut view, observer);
2214        let resolver = self.resolver.as_deref();
2215        self.default.drive_to_terminal::<R>(
2216            &self.program,
2217            &self.line_tables,
2218            &mut obs_ctx,
2219            &FallbackHandler,
2220            resolver,
2221        )
2222    }
2223
2224    /// Select a choice by index, then resume with
2225    /// [`continue_single`](Self::continue_single) or
2226    /// [`continue_maximally`](Self::continue_maximally).
2227    pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
2228        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2229        self.default.choose(&mut view, index)
2230    }
2231
2232    /// Move the default flow's play head to a named knot/stitch path — ink's
2233    /// `ChoosePathString` equivalent. The current flow is force-completed
2234    /// (callstack reset, pending choices cleared), the jump counts as a visit
2235    /// to the target exactly like a `-> path` divert, and subsequent
2236    /// [`continue_single`](Self::continue_single) /
2237    /// [`continue_maximally`](Self::continue_maximally) calls run from there.
2238    /// See [`FlowInstance::choose_path_string`] for full semantics.
2239    ///
2240    /// # Errors
2241    /// [`UnknownPath`](RuntimeError::UnknownPath) for an unknown path;
2242    /// [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
2243    /// if the flow is parked on an unresolved external call;
2244    /// [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
2245    /// if an engine→ink function evaluation is in progress.
2246    pub fn choose_path_string(&mut self, path: &str) -> Result<(), RuntimeError> {
2247        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2248        self.default
2249            .choose_path_string(&self.program, &mut view, path)
2250    }
2251
2252    /// Move the default flow's play head to a parameterized knot/stitch,
2253    /// **binding its declared parameters** from `args` — ink's
2254    /// `ChoosePathString` with arguments. Otherwise identical to
2255    /// [`choose_path_string`](Self::choose_path_string). See
2256    /// [`FlowInstance::choose_path_string_with_args`] for full semantics.
2257    ///
2258    /// # Errors
2259    /// As [`choose_path_string`](Self::choose_path_string), plus
2260    /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) when `args.len()`
2261    /// doesn't match the target's declared parameter count.
2262    pub fn choose_path_string_with_args(
2263        &mut self,
2264        path: &str,
2265        args: &[Value],
2266    ) -> Result<(), RuntimeError> {
2267        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2268        self.default
2269            .choose_path_string_with_args(&self.program, &mut view, path, args)
2270    }
2271
2272    /// Read-only access to the default flow's VM statistics.
2273    pub fn stats(&self) -> &Stats {
2274        &self.default.stats
2275    }
2276
2277    /// Returns `true` if the default flow has a pending external call
2278    /// (an `External` frame on top of the call stack).
2279    pub fn has_pending_external(&self) -> bool {
2280        self.default.flow.external_fn_id().is_some()
2281    }
2282
2283    /// Resolve a pending external call on the default flow by providing
2284    /// the return value. For fire-and-forget calls, pass `Value::Null`.
2285    ///
2286    /// After resolving, call [`continue_maximally`](Story::continue_maximally)
2287    /// to continue execution.
2288    pub fn resolve_external(&mut self, value: Value) {
2289        self.default.flow.resolve_external(value);
2290    }
2291
2292    /// Resolve a pending external call on the default flow by invoking
2293    /// the ink-defined fallback body. The fallback is a function call
2294    /// whose output becomes the return value.
2295    ///
2296    /// After invoking, call [`continue_maximally`](Story::continue_maximally)
2297    /// to continue execution.
2298    pub fn invoke_fallback(&mut self) -> Result<(), RuntimeError> {
2299        let fn_id = self
2300            .default
2301            .flow
2302            .external_fn_id()
2303            .ok_or(RuntimeError::CallStackUnderflow)?;
2304        let entry = self.program.external_fn(fn_id);
2305        let fallback_id = entry
2306            .and_then(|e| e.fallback)
2307            .ok_or(RuntimeError::UnresolvedExternalCall(fn_id))?;
2308        let container_idx = self
2309            .program
2310            .resolve_target(fallback_id)
2311            .map(|(idx, _)| idx)
2312            .ok_or(RuntimeError::UnresolvedDefinition(fallback_id))?;
2313        self.default.flow.output.begin_capture();
2314        self.default.flow.invoke_fallback(container_idx);
2315        Ok(())
2316    }
2317
2318    // ── Named flow API ──────────────────────────────────────────────
2319
2320    /// Spawn a new flow instance starting at the given entry point.
2321    ///
2322    /// `entry_point` is the `DefinitionId` of the target container
2323    /// (e.g., a knot). Each flow instance gets its own globals, visit
2324    /// counts, and execution state.
2325    pub fn spawn_flow(
2326        &mut self,
2327        name: &str,
2328        entry_point: DefinitionId,
2329    ) -> Result<(), RuntimeError> {
2330        if self.instances.contains_key(name) {
2331            return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
2332        }
2333        let container_idx = self
2334            .program
2335            .resolve_target(entry_point)
2336            .map(|(idx, _)| idx)
2337            .ok_or(RuntimeError::UnresolvedDefinition(entry_point))?;
2338        let (flow, ctx) = FlowInstance::new_at(&self.program, container_idx);
2339        self.instances
2340            .insert(name.to_owned(), (flow, ctx, FlowLocal::new()));
2341        Ok(())
2342    }
2343
2344    /// Run a named flow instance until the next yield point.
2345    pub fn continue_flow_maximally(&mut self, name: &str) -> Result<Vec<Line>, RuntimeError> {
2346        self.continue_flow_maximally_with(name, &FallbackHandler)
2347    }
2348
2349    /// Run a named flow instance with an external function handler.
2350    pub fn continue_flow_maximally_with(
2351        &mut self,
2352        name: &str,
2353        handler: &dyn ExternalFnHandler,
2354    ) -> Result<Vec<Line>, RuntimeError> {
2355        let (instance, ctx, local) = self
2356            .instances
2357            .get_mut(name)
2358            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2359        let mut view = ContextView::new(ctx, local);
2360        let resolver = self.resolver.as_deref();
2361        instance.drive_to_terminal::<R>(
2362            &self.program,
2363            &self.line_tables,
2364            &mut view,
2365            handler,
2366            resolver,
2367        )
2368    }
2369
2370    /// Select a choice in a named flow.
2371    pub fn choose_flow(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
2372        let (instance, ctx, local) = self
2373            .instances
2374            .get_mut(name)
2375            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2376        let mut view = ContextView::new(ctx, local);
2377        instance.choose(&mut view, index)
2378    }
2379
2380    /// Destroy a named flow instance — isolated or shared (#200).
2381    pub fn destroy_flow(&mut self, name: &str) -> Result<(), RuntimeError> {
2382        if self.shared_instances.remove(name).is_some() || self.instances.remove(name).is_some() {
2383            Ok(())
2384        } else {
2385            Err(RuntimeError::UnknownFlow(name.to_owned()))
2386        }
2387    }
2388
2389    /// List active flow names (isolated + shared), sorted for determinism.
2390    pub fn flow_names(&self) -> Vec<&str> {
2391        let mut names: Vec<&str> = self
2392            .instances
2393            .keys()
2394            .chain(self.shared_instances.keys())
2395            .map(String::as_str)
2396            .collect();
2397        names.sort_unstable();
2398        names
2399    }
2400
2401    // ── Shared flows (#200) ─────────────────────────────────────────
2402    // Spawn a flow that **shares** `default_context` (globals / visit counts /
2403    // rng) with the default flow — true ink concurrent-flow semantics — while
2404    // keeping its own call stack + temps. Distinct from `spawn_flow`, whose
2405    // flows each own an isolated context (bevy-brink's per-entity model).
2406
2407    /// Spawn a shared-context flow at `container_idx` (or the root if `None`).
2408    pub fn spawn_flow_shared(
2409        &mut self,
2410        name: &str,
2411        container_idx: Option<u32>,
2412    ) -> Result<(), RuntimeError> {
2413        if self.shared_instances.contains_key(name) || self.instances.contains_key(name) {
2414            return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
2415        }
2416        // The fresh context the constructor returns is discarded — a shared
2417        // flow runs against `default_context`.
2418        let (flow, _ctx) = match container_idx {
2419            Some(idx) => FlowInstance::new_at(&self.program, idx),
2420            None => FlowInstance::new_at_root(&self.program),
2421        };
2422        self.shared_instances.insert(name.to_owned(), flow);
2423        Ok(())
2424    }
2425
2426    /// Advance a shared flow one line (against the shared context).
2427    pub fn continue_flow_single(&mut self, name: &str) -> Result<Line, RuntimeError> {
2428        self.continue_flow_single_with(name, &FallbackHandler)
2429    }
2430
2431    /// Advance a shared flow one line with an external-function handler.
2432    pub fn continue_flow_single_with(
2433        &mut self,
2434        name: &str,
2435        handler: &dyn ExternalFnHandler,
2436    ) -> Result<Line, RuntimeError> {
2437        let resolver = self.resolver.as_deref();
2438        let instance = self
2439            .shared_instances
2440            .get_mut(name)
2441            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2442        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2443        instance.step_single_line::<R>(
2444            &self.program,
2445            &self.line_tables,
2446            &mut view,
2447            handler,
2448            resolver,
2449        )
2450    }
2451
2452    /// Select a choice in a shared flow (against the shared context).
2453    pub fn choose_flow_shared(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
2454        let instance = self
2455            .shared_instances
2456            .get_mut(name)
2457            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2458        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2459        instance.choose(&mut view, index)
2460    }
2461
2462    /// A structured, name-resolved snapshot of the current runtime state for
2463    /// the studio State View: status, current location, globals, call stack,
2464    /// visit counts, pending choices, and rng. Read-only; built on demand and
2465    /// not on any hot path. See [`DebugSnapshot`](crate::DebugSnapshot).
2466    #[must_use]
2467    pub fn debug_snapshot(&self) -> crate::DebugSnapshot {
2468        self.build_debug_snapshot(&self.default, &self.default_context)
2469    }
2470
2471    /// A debug snapshot of a named shared flow (#200), built against the shared
2472    /// `default_context` — so its globals / visit counts match the default
2473    /// flow's, while its call stack + temps are the flow's own. Falls back to a
2474    /// named isolated flow's own context if `name` is one of those instead.
2475    pub fn debug_snapshot_flow(&self, name: &str) -> Result<crate::DebugSnapshot, RuntimeError> {
2476        if let Some(instance) = self.shared_instances.get(name) {
2477            Ok(self.build_debug_snapshot(instance, &self.default_context))
2478        } else if let Some((instance, ctx, _local)) = self.instances.get(name) {
2479            Ok(self.build_debug_snapshot(instance, ctx))
2480        } else {
2481            Err(RuntimeError::UnknownFlow(name.to_owned()))
2482        }
2483    }
2484
2485    /// Build a debug snapshot from a specific flow instance + context. Backs
2486    /// both [`debug_snapshot`](Self::debug_snapshot) and the per-flow variant.
2487    fn build_debug_snapshot(&self, instance: &FlowInstance, ctx: &World) -> crate::DebugSnapshot {
2488        use crate::debug::{
2489            DebugChoice, DebugFrame, DebugGlobal, DebugRng, DebugSnapshot, DebugVisit, NameResolver,
2490        };
2491
2492        let flow = &instance.flow;
2493        let resolver = NameResolver::new(&self.program);
2494
2495        let status = match instance.status {
2496            StoryStatus::Active => "active",
2497            StoryStatus::WaitingForChoice => "waiting_for_choice",
2498            StoryStatus::Done => "done",
2499            StoryStatus::Ended => "ended",
2500        };
2501
2502        let thread = flow.current_thread();
2503
2504        // Nearest named container the cursor is currently in (innermost-first).
2505        let resolve_frame_location = |frame: &CallFrame| {
2506            frame
2507                .container_stack
2508                .iter()
2509                .rev()
2510                .find_map(|cp| resolver.container_path(cp.container_idx))
2511                .map(str::to_owned)
2512        };
2513
2514        let current_location = thread.call_stack.last().and_then(resolve_frame_location);
2515
2516        // Globals, skipping unnamed slots.
2517        let globals = ctx
2518            .globals
2519            .iter()
2520            .enumerate()
2521            .filter_map(|(i, value)| {
2522                self.program.global_slot_name(i).map(|name| DebugGlobal {
2523                    name: name.to_owned(),
2524                    value: resolver.format_value(value),
2525                })
2526            })
2527            .collect();
2528
2529        // Call stack, innermost (current) frame first.
2530        let depth = thread.call_stack.len();
2531        let mut call_stack = Vec::with_capacity(depth);
2532        for i in (0..depth).rev() {
2533            if let Some(frame) = thread.call_stack.get(i) {
2534                let kind = match frame.frame_type {
2535                    CallFrameType::Root => "root",
2536                    CallFrameType::Function => "function",
2537                    CallFrameType::Tunnel => "tunnel",
2538                    CallFrameType::Thread => "thread",
2539                    CallFrameType::External => "external",
2540                    CallFrameType::FunctionEvalFromGame => "eval",
2541                };
2542                call_stack.push(DebugFrame {
2543                    kind,
2544                    location: resolve_frame_location(frame),
2545                    temps: frame.temps.len(),
2546                });
2547            }
2548        }
2549
2550        // Visit counts, resolved and sorted by path for determinism.
2551        let mut visit_counts: Vec<DebugVisit> = ctx
2552            .visit_counts
2553            .iter()
2554            .filter_map(|(id, &count)| {
2555                resolver.def_path(*id).map(|path| DebugVisit {
2556                    path: path.to_owned(),
2557                    count,
2558                })
2559            })
2560            .collect();
2561        visit_counts.sort_by(|a, b| a.path.cmp(&b.path));
2562
2563        // Pending choices: visible texts (resolved) paired with target paths.
2564        let visible_targets: Vec<DefinitionId> = flow
2565            .pending_choices
2566            .iter()
2567            .filter(|pc| !pc.flags.is_invisible_default)
2568            .map(|pc| pc.target_id)
2569            .collect();
2570        let pending_choices = self
2571            .resolved_choices_for(flow)
2572            .into_iter()
2573            .enumerate()
2574            .map(|(i, ch)| DebugChoice {
2575                text: ch.text,
2576                target: visible_targets
2577                    .get(i)
2578                    .and_then(|id| resolver.def_path(*id))
2579                    .map(str::to_owned),
2580                // `ch.index` is the pre-filter `flow.pending_choices` position
2581                // (see `resolved_choices_for`) — the same index `choose()`
2582                // expects, not the post-filter enumeration position `i`.
2583                index: ch.index,
2584            })
2585            .collect();
2586
2587        DebugSnapshot {
2588            status,
2589            current_location,
2590            turn_index: ctx.turn_index,
2591            globals,
2592            call_stack,
2593            visit_counts,
2594            pending_choices,
2595            rng: DebugRng {
2596                seed: ctx.rng_seed,
2597                previous: ctx.previous_random,
2598            },
2599        }
2600    }
2601
2602    // ── Session support (crate-internal) ────────────────────────────
2603
2604    /// Whether the default flow is in the `Active` status (mid-turn, more
2605    /// content pending). Used by [`StorySession`](crate::StorySession) for the
2606    /// turn-boundary mutation gate.
2607    pub(crate) fn status_is_active(&self) -> bool {
2608        self.default.status == StoryStatus::Active
2609    }
2610
2611    /// Whether the default flow is waiting for a choice selection. Used by
2612    /// [`StorySession`](crate::StorySession) replay.
2613    pub(crate) fn status_is_waiting_for_choice(&self) -> bool {
2614        self.default.status == StoryStatus::WaitingForChoice
2615    }
2616
2617    /// Build a typed [`StateSnapshot`](crate::StateSnapshot) of the default
2618    /// flow's game state — a NEW typed serialization path (globals with list
2619    /// membership, turn counts, callstack summary), distinct from the
2620    /// string-valued [`DebugSnapshot`](crate::DebugSnapshot).
2621    ///
2622    /// Known projection limit (deliberate, not a silent bug): visit/turn-count
2623    /// entries whose scope has no resolvable author path (anonymous counted
2624    /// containers — gathers, choice points — keyed only by hash id) are
2625    /// **omitted** from the snapshot's path-keyed maps. The full id-keyed
2626    /// counts remain available via [`Story::save_state`].
2627    pub(crate) fn state_snapshot(&self) -> crate::session::StateSnapshot {
2628        use std::collections::BTreeMap;
2629
2630        use crate::debug::NameResolver;
2631        use crate::session::{SnapshotFrame, SnapshotList, StateSnapshot};
2632
2633        let flow = &self.default.flow;
2634        let ctx = &self.default_context;
2635        let resolver = NameResolver::new(&self.program);
2636
2637        // Typed globals + resolved list membership.
2638        let mut globals: BTreeMap<String, Value> = BTreeMap::new();
2639        let mut lists: BTreeMap<String, SnapshotList> = BTreeMap::new();
2640        for (i, value) in ctx.globals.iter().enumerate() {
2641            if let Some(name) = self.program.global_slot_name(i) {
2642                if let Value::List(list) = value {
2643                    let mut items: Vec<String> = list
2644                        .items
2645                        .iter()
2646                        .filter_map(|id| self.program.list_item_name(*id).map(str::to_owned))
2647                        .collect();
2648                    items.sort_unstable();
2649                    lists.insert(name.to_owned(), SnapshotList { items });
2650                }
2651                globals.insert(name.to_owned(), value.clone());
2652            }
2653        }
2654
2655        // Visit / turn counts by resolved path (deterministic BTreeMap).
2656        let mut visit_counts: BTreeMap<String, u32> = BTreeMap::new();
2657        for (id, &count) in &ctx.visit_counts {
2658            if let Some(path) = resolver.def_path(*id) {
2659                visit_counts.insert(path.to_owned(), count);
2660            }
2661        }
2662        let mut turn_counts: BTreeMap<String, u32> = BTreeMap::new();
2663        for (id, &count) in &ctx.turn_counts {
2664            if let Some(path) = resolver.def_path(*id) {
2665                turn_counts.insert(path.to_owned(), count);
2666            }
2667        }
2668
2669        // Callstack summary, innermost frame first.
2670        let resolve_frame_location = |frame: &CallFrame| {
2671            frame
2672                .container_stack
2673                .iter()
2674                .rev()
2675                .find_map(|cp| resolver.container_path(cp.container_idx))
2676                .map(str::to_owned)
2677        };
2678        let thread = flow.current_thread();
2679        let depth = thread.call_stack.len();
2680        let mut call_stack = Vec::with_capacity(depth);
2681        for i in (0..depth).rev() {
2682            if let Some(frame) = thread.call_stack.get(i) {
2683                let kind = match frame.frame_type {
2684                    CallFrameType::Root => "root",
2685                    CallFrameType::Function => "function",
2686                    CallFrameType::Tunnel => "tunnel",
2687                    CallFrameType::Thread => "thread",
2688                    CallFrameType::External => "external",
2689                    CallFrameType::FunctionEvalFromGame => "eval",
2690                };
2691                call_stack.push(SnapshotFrame {
2692                    kind: kind.to_owned(),
2693                    location: resolve_frame_location(frame),
2694                    temps: frame.temps.len(),
2695                });
2696            }
2697        }
2698
2699        StateSnapshot {
2700            globals,
2701            lists,
2702            turn_index: ctx.turn_index,
2703            visit_counts,
2704            turn_counts,
2705            call_stack,
2706            status: self.default.status.into(),
2707        }
2708    }
2709
2710    // ── Testing / instrumentation API ───────────────────────────────
2711
2712    /// Dump the current execution state for debugging.
2713    ///
2714    /// Returns a human-readable summary of the call stack, current position,
2715    /// value stack, output buffer, globals, and pending choices.
2716    #[cfg(feature = "testing")]
2717    pub fn debug_state(&self) -> String {
2718        use std::fmt::Write;
2719        let mut out = String::new();
2720        let flow = &self.default.flow;
2721        let ctx = &self.default_context;
2722
2723        let _ = writeln!(out, "=== Story Debug State ===");
2724        let _ = writeln!(out, "status: {:?}", self.default.status);
2725
2726        // Current position
2727        let thread = flow.current_thread();
2728        if let Some(frame) = thread.call_stack.last()
2729            && let Some(cp) = frame.container_stack.last()
2730        {
2731            let id = self.program.container(cp.container_idx).id;
2732            let _ = writeln!(
2733                out,
2734                "position: container_idx={} id={id:?} offset={}",
2735                cp.container_idx, cp.offset,
2736            );
2737        }
2738
2739        // Call stack
2740        let depth = thread.call_stack.len();
2741        let _ = writeln!(out, "\ncall stack ({depth} frames):");
2742        for i in 0..depth {
2743            if let Some(frame) = thread.call_stack.get(i) {
2744                let ret = frame
2745                    .return_address
2746                    .map(|r| format!("idx={} off={}", r.container_idx, r.offset));
2747                let _ = writeln!(
2748                    out,
2749                    "  [{i}] {:?} ret={} temps={} containers={}",
2750                    frame.frame_type,
2751                    ret.as_deref().unwrap_or("none"),
2752                    frame.temps.len(),
2753                    frame.container_stack.len(),
2754                );
2755                for (j, cp) in frame.container_stack.iter().enumerate() {
2756                    let id = self.program.container(cp.container_idx).id;
2757                    let _ = writeln!(
2758                        out,
2759                        "       container_stack[{j}]: idx={} id={id:?} off={}",
2760                        cp.container_idx, cp.offset,
2761                    );
2762                }
2763            }
2764        }
2765
2766        // Value stack
2767        let _ = writeln!(out, "\nvalue stack ({}):", flow.value_stack.len());
2768        for (i, v) in flow.value_stack.iter().enumerate() {
2769            let _ = writeln!(out, "  [{i}] {v:?}");
2770        }
2771
2772        // Output buffer (unread transcript)
2773        let unread_start = flow.output.cursor;
2774        let transcript = &flow.output.transcript[unread_start..];
2775        let _ = writeln!(
2776            out,
2777            "\noutput buffer (cursor={unread_start}, {} unread parts):",
2778            transcript.len(),
2779        );
2780        for (i, part) in transcript.iter().enumerate() {
2781            let _ = writeln!(out, "  [{i}] {part:?}");
2782        }
2783
2784        // Globals
2785        let _ = writeln!(out, "\nglobals:");
2786        for (i, v) in ctx.globals.iter().enumerate() {
2787            #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
2788            if let Some(name) = self.program.global_name(i as u32) {
2789                let _ = writeln!(out, "  {name} = {v:?}");
2790            }
2791        }
2792
2793        // Flow flags
2794        let _ = writeln!(out, "\nskipping_choice: {}", flow.skipping_choice);
2795
2796        // Pending choices
2797        let _ = writeln!(out, "\npending choices ({}):", flow.pending_choices.len());
2798        for (i, c) in flow.pending_choices.iter().enumerate() {
2799            let _ = writeln!(out, "  [{i}] {:?} -> {:?}", c.display, c.target_id);
2800        }
2801
2802        out
2803    }
2804
2805    /// Returns whether the last execution cycle ended with a safe exit
2806    /// (explicit `-> DONE` opcode). If false after a `Done` line, the
2807    /// story ran out of content.
2808    #[cfg(feature = "testing")]
2809    pub fn did_safe_exit(&self) -> bool {
2810        self.default.flow.did_safe_exit
2811    }
2812
2813    /// Returns whether the last execution cycle passed through an empty
2814    /// choice set (a `Yield` opcode with no pending choices).
2815    #[cfg(feature = "testing")]
2816    pub fn did_unsafe_yield(&self) -> bool {
2817        self.default.flow.did_unsafe_yield
2818    }
2819
2820    /// Execute a single VM step and return a debug trace of what happened.
2821    ///
2822    /// Returns `(opcode_description, container_idx, offset_before)` or None
2823    /// if the step didn't decode an opcode (frame exhaustion, thread completion, etc).
2824    #[cfg(feature = "testing")]
2825    pub fn step_once(&mut self) -> Result<Option<(String, u32, usize)>, RuntimeError> {
2826        use brink_format::Opcode;
2827
2828        let flow = &self.default.flow;
2829        let thread = flow.current_thread();
2830
2831        // Capture position before step
2832        let pre_info = thread.call_stack.last().and_then(|frame| {
2833            frame.container_stack.last().map(|pos| {
2834                let container = self.program.container(pos.container_idx);
2835                if pos.offset < container.bytecode.len() {
2836                    let mut off = pos.offset;
2837                    let op = Opcode::decode(&container.bytecode, &mut off).ok();
2838                    (pos.container_idx, pos.offset, op)
2839                } else {
2840                    (pos.container_idx, pos.offset, None)
2841                }
2842            })
2843        });
2844
2845        // Execute one step
2846        let _result = vm::step::<R>(
2847            &mut self.default.flow,
2848            &self.program,
2849            &self.line_tables,
2850            &mut self.default_context,
2851            &mut self.default.stats,
2852            self.resolver.as_deref(),
2853        )?;
2854
2855        match pre_info {
2856            Some((ci, off, Some(op))) => Ok(Some((format!("{op:?}"), ci, off))),
2857            Some((ci, off, None)) => Ok(Some(("(end of container)".to_string(), ci, off))),
2858            None => Ok(None),
2859        }
2860    }
2861}
2862
2863#[cfg(test)]
2864#[expect(clippy::panic)]
2865mod tests {
2866    use super::*;
2867    use crate::link;
2868
2869    fn load_i079_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2870        let json_str = std::fs::read_to_string(
2871            "../../tests/tier1/choices/I079-once-only-choices-can-link-back-to-self/story.ink.json",
2872        )
2873        .unwrap();
2874        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2875        let data = brink_converter::convert(&ink).unwrap();
2876        link(&data).unwrap()
2877    }
2878
2879    /// Step a story until it yields choices, panicking if it ends first.
2880    fn step_until_choices(story: &mut Story) -> Vec<Choice> {
2881        loop {
2882            match story.continue_single().unwrap() {
2883                Line::Choices { choices, .. } => return choices,
2884                Line::Text { .. } => {}
2885                Line::Done { .. } => panic!("story hit Done before presenting choices"),
2886                Line::End { .. } => panic!("story ended before presenting choices"),
2887            }
2888        }
2889    }
2890
2891    /// Step a story, accumulating text, until it stops (choices, done, or
2892    /// end) — returns the accumulated text for content assertions.
2893    fn step_until_choices_or_end(story: &mut Story) -> String {
2894        let mut text = String::new();
2895        loop {
2896            match story.continue_single().unwrap() {
2897                Line::Choices { text: t, .. }
2898                | Line::Done { text: t, .. }
2899                | Line::End { text: t, .. } => {
2900                    text.push_str(&t);
2901                    return text;
2902                }
2903                Line::Text { text: t, .. } => text.push_str(&t),
2904            }
2905        }
2906    }
2907
2908    /// After selecting a once-only choice, the visit count for its target
2909    /// container must be > 0. Without this, the once-only filter in
2910    /// `handle_begin_choice` can never fire.
2911    #[test]
2912    fn select_choice_increments_visit_count_for_target() {
2913        let (program, line_tables) = load_i079_program();
2914        let mut story = Story::new(Arc::new(program), line_tables);
2915        let choices = step_until_choices(&mut story);
2916
2917        assert!(!choices.is_empty(), "expected at least one choice");
2918
2919        // Record the target_id of the first pending choice BEFORE selecting.
2920        let target_id = story.default.flow.pending_choices[0].target_id;
2921        let visit_before = story
2922            .default_context
2923            .visit_counts
2924            .get(&target_id)
2925            .copied()
2926            .unwrap_or(0);
2927
2928        story.choose(0).unwrap();
2929
2930        // After selection, the visit count for this target must have increased.
2931        let visit_after = story
2932            .default_context
2933            .visit_counts
2934            .get(&target_id)
2935            .copied()
2936            .unwrap_or(0);
2937        assert!(
2938            visit_after > visit_before,
2939            "visit count for choice target should increment after selection: \
2940             before={visit_before}, after={visit_after}"
2941        );
2942    }
2943
2944    /// Build a linked `Story` directly from `.ink` source (no fixture file),
2945    /// for cases that need a specific choice shape not already in `tests/`.
2946    fn story_from_source(src: &str) -> Story {
2947        let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
2948        let mut bytes = Vec::new();
2949        brink_format::write_inkb(&out.data, &mut bytes);
2950        let data = brink_format::read_inkb(&bytes).expect("decode");
2951        let (prog, tables) = link(&data).expect("link");
2952        Story::new(Arc::new(prog), tables)
2953    }
2954
2955    /// `Choice.index` (the live, visible choice list) must be the *raw*
2956    /// `pending_choices` position, not the post-filter enumeration position —
2957    /// an invisible-default fallback choice (`* ->`) mixed in with visible
2958    /// choices occupies a `pending_choices` slot but never appears in the
2959    /// visible list, so the visible indices can skip values. This is exactly
2960    /// what `select_choice`/`choose` expects (it indexes `pending_choices`
2961    /// directly) — a caller must never re-derive the index from array
2962    /// position over the visible list alone.
2963    #[test]
2964    fn choice_index_is_raw_pending_choices_position_with_invisible_default_mixed_in() {
2965        let src = "-(start)\n\
2966             * [First] -> a\n\
2967             * -> b\n\
2968             * [Third] -> c\n\
2969             -(a) Went A.\n-> DONE\n\
2970             -(b) Went B.\n-> DONE\n\
2971             -(c) Went C.\n-> DONE\n";
2972        let mut story = story_from_source(src);
2973        let choices = step_until_choices(&mut story);
2974
2975        // The invisible-default fallback (raw index 1) is filtered out of the
2976        // visible list, so the visible choices' indices skip it: 0, then 2.
2977        assert_eq!(
2978            choices.iter().map(|c| c.index).collect::<Vec<_>>(),
2979            vec![0, 2],
2980            "visible choice indices must be the raw pending_choices positions, not 0,1,..: {choices:?}"
2981        );
2982        assert_eq!(story.default.flow.pending_choices.len(), 3);
2983
2984        // Choosing the raw index of the second visible entry must select
2985        // the "Third" branch, not the invisible-default fallback.
2986        story.choose(choices[1].index).expect("choose by raw index");
2987        let text = step_until_choices_or_end(&mut story);
2988        assert!(text.contains("Went C"), "expected the Third branch: {text}");
2989    }
2990
2991    /// `DebugSnapshot.pending_choices[].index` must agree with the live
2992    /// `Choice.index` — both derive from the same pre-filter pass over
2993    /// `pending_choices` (`resolved_choices_for`). A studio consumer restoring
2994    /// a Choice[] from a `DebugSnapshot` (rather than a live `Choice` list)
2995    /// depends on this to dispatch `choose()` correctly.
2996    #[test]
2997    fn debug_snapshot_choice_index_matches_live_choice_index() {
2998        let src = "-(start)\n\
2999             * [First] -> a\n\
3000             * -> b\n\
3001             * [Third] -> c\n\
3002             -(a) Went A.\n-> DONE\n\
3003             -(b) Went B.\n-> DONE\n\
3004             -(c) Went C.\n-> DONE\n";
3005        let mut story = story_from_source(src);
3006        let live_choices = step_until_choices(&mut story);
3007        let snap = story.debug_snapshot();
3008
3009        assert_eq!(snap.pending_choices.len(), live_choices.len());
3010        for (live, dbg) in live_choices.iter().zip(snap.pending_choices.iter()) {
3011            assert_eq!(
3012                dbg.index, live.index,
3013                "DebugChoice.index must match the live Choice.index"
3014            );
3015        }
3016    }
3017
3018    /// On the second pass through a choice set with once-only choices,
3019    /// a choice whose target has already been visited must NOT appear
3020    /// in `pending_choices`.
3021    #[test]
3022    fn once_only_choice_excluded_on_second_pass() {
3023        let (program, line_tables) = load_i079_program();
3024        let mut story = Story::new(Arc::new(program), line_tables);
3025
3026        let first_choices = step_until_choices(&mut story);
3027        assert!(
3028            first_choices
3029                .iter()
3030                .any(|c| c.text.contains("First choice")),
3031            "first pass should contain 'First choice', got: {first_choices:?}"
3032        );
3033
3034        story.choose(0).unwrap();
3035
3036        let second_choices = step_until_choices(&mut story);
3037        assert!(
3038            !second_choices
3039                .iter()
3040                .any(|c| c.text.contains("First choice")),
3041            "second pass should NOT contain 'First choice' (once-only, already visited), \
3042             got: {second_choices:?}"
3043        );
3044    }
3045
3046    // ── Choice thread forking ──────────────────────────────────────────
3047
3048    fn load_i083_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3049        let json_str = std::fs::read_to_string(
3050            "../../tests/tier1/choices/I083-choice-thread-forking/story.ink.json",
3051        )
3052        .unwrap();
3053        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3054        let data = brink_converter::convert(&ink).unwrap();
3055        link(&data).unwrap()
3056    }
3057
3058    /// When a choice is created inside a tunnel, the call stack at that
3059    /// moment (including the tunnel frame with its temps) must be captured.
3060    /// After the tunnel returns and the choice is presented, the snapshot
3061    /// should still reflect the tunnel-era call stack depth (>= 2 frames).
3062    #[test]
3063    fn pending_choice_captures_tunnel_call_stack() {
3064        let (program, line_tables) = load_i083_program();
3065        let mut story = Story::new(Arc::new(program), line_tables);
3066        let _choices = step_until_choices(&mut story);
3067
3068        // At this point the tunnel has returned, so the live call_stack
3069        // has only the root frame.
3070        let current_thread = story.default.flow.current_thread();
3071        assert_eq!(
3072            current_thread.call_stack.len(),
3073            1,
3074            "live call stack should be 1 frame (root) after tunnel return"
3075        );
3076
3077        // But the pending choice's fork should have captured the
3078        // call stack from inside the tunnel (root + tunnel = 2 frames).
3079        assert!(!story.default.flow.pending_choices.is_empty());
3080        let fork = &story.default.flow.pending_choices[0].thread_fork;
3081        assert!(
3082            fork.call_stack.len() >= 2,
3083            "choice fork should have >= 2 frames (root + tunnel), got {}",
3084            fork.call_stack.len()
3085        );
3086    }
3087
3088    /// After selecting a choice that was created inside a tunnel,
3089    /// `select_choice` must restore the tunnel's call frame so that
3090    /// temp variables from the tunnel scope are accessible.
3091    #[test]
3092    fn select_choice_restores_tunnel_frame_with_temps() {
3093        let (program, line_tables) = load_i083_program();
3094        let mut story = Story::new(Arc::new(program), line_tables);
3095        let _choices = step_until_choices(&mut story);
3096
3097        // Before choosing: only root frame, no tunnel temps.
3098        assert_eq!(story.default.flow.current_thread().call_stack.len(), 1);
3099
3100        story.choose(0).unwrap();
3101
3102        // After choosing: the tunnel frame should be restored.
3103        // The call stack should have at least 2 frames (root + tunnel).
3104        let call_stack = &story.default.flow.current_thread().call_stack;
3105        assert!(
3106            call_stack.len() >= 2,
3107            "call stack should be restored to tunnel depth after choice selection, \
3108             got {} frame(s)",
3109            call_stack.len()
3110        );
3111
3112        // The tunnel frame (last frame) should have temp x = Int(1).
3113        let tunnel_frame = call_stack.last().unwrap();
3114        assert!(
3115            !tunnel_frame.temps.is_empty(),
3116            "tunnel frame should have temp variables"
3117        );
3118        assert_eq!(
3119            tunnel_frame.temps[0],
3120            Value::Int(1),
3121            "tunnel frame temps[0] should be Int(1) (the parameter x)"
3122        );
3123    }
3124
3125    // ── Tags ──────────────────────────────────────────────────────────
3126
3127    fn load_tags_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3128        let json_str =
3129            std::fs::read_to_string("../../tests/tier3/tags/tags/story.ink.json").unwrap();
3130        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3131        let data = brink_converter::convert(&ink).unwrap();
3132        link(&data).unwrap()
3133    }
3134
3135    fn load_tags_in_choice_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3136        let json_str =
3137            std::fs::read_to_string("../../tests/tier3/tags/tagsInChoice/story.ink.json").unwrap();
3138        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3139        let data = brink_converter::convert(&ink).unwrap();
3140        link(&data).unwrap()
3141    }
3142
3143    #[test]
3144    fn line_exposes_tags() {
3145        let (program, line_tables) = load_tags_program();
3146        let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
3147        let lines = story.continue_maximally().unwrap();
3148        // The first line should have both tags.
3149        let first = lines.first().expect("expected at least one line");
3150        assert!(
3151            !matches!(first, Line::Choices { .. }),
3152            "expected Text or End, got Choices"
3153        );
3154        assert_eq!(first.tags(), &["author: Joe", "title: My Great Story"],);
3155    }
3156
3157    #[test]
3158    fn choice_exposes_tags() {
3159        let (program, line_tables) = load_tags_in_choice_program();
3160        let mut story = Story::new(Arc::new(program), line_tables);
3161        let choices = step_until_choices(&mut story);
3162        assert!(!choices.is_empty());
3163        // The choice in tagsInChoice has tags "one" and "two"
3164        assert!(
3165            !choices[0].tags.is_empty(),
3166            "choice should have tags, got: {choices:?}"
3167        );
3168    }
3169
3170    // ── Thread support ──────────────────────────────────────────────────
3171
3172    fn load_i091_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3173        let json_str =
3174            std::fs::read_to_string("../../tests/tier1/choices/I091-choice-count/story.ink.json")
3175                .unwrap();
3176        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3177        let data = brink_converter::convert(&ink).unwrap();
3178        link(&data).unwrap()
3179    }
3180
3181    /// `<- choices` (thread) must create choices AND return to the main
3182    /// flow so that `CHOICE_COUNT()` can evaluate. The thread body
3183    /// should be called like a tunnel — when its container stack empties,
3184    /// execution returns to the caller. Non-root frames must always pop
3185    /// back to their caller, even when pending choices exist.
3186    #[test]
3187    fn thread_call_returns_to_main_flow() {
3188        let (program, line_tables) = load_i091_program();
3189        let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
3190
3191        let lines = story.continue_maximally().unwrap();
3192        // I091 should output "2\n" (CHOICE_COUNT) then present 2 choices.
3193        let full_text: String = lines.iter().map(Line::text).collect();
3194        assert!(
3195            full_text.starts_with('2'),
3196            "output should start with '2' from CHOICE_COUNT(), got: {full_text:?}"
3197        );
3198        let last = lines.last().expect("expected at least one line");
3199        match last {
3200            Line::Choices { choices, .. } => {
3201                assert_eq!(choices.len(), 2, "expected 2 choices");
3202            }
3203            other => panic!("expected Choices, got {other:?}"),
3204        }
3205    }
3206
3207    // ── FlowInstance::drive_to_terminal (F6.1a shared drive-to-terminal op) ──
3208
3209    /// Compile `.ink` source directly into a linked `(Program, line_tables)`
3210    /// pair, bypassing `Story` so tests can drive a bare `FlowInstance`
3211    /// directly — the way a `Story`-free consumer (e.g. an engine
3212    /// integration) would.
3213    fn compile_source_for_flow(src: &str) -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3214        let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
3215        let mut bytes = Vec::new();
3216        brink_format::write_inkb(&out.data, &mut bytes);
3217        let data = brink_format::read_inkb(&bytes).expect("decode");
3218        link(&data).expect("link")
3219    }
3220
3221    #[test]
3222    fn drive_to_terminal_stops_at_done() {
3223        let (program, tables) = compile_source_for_flow("Hello.\n-> DONE\n");
3224        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3225        let mut local = FlowLocal::new();
3226        let mut view = ContextView::new(&mut world, &mut local);
3227        let lines = flow
3228            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3229            .expect("drive succeeds");
3230        let (last, rest) = lines.split_last().expect("at least one line");
3231        assert!(
3232            matches!(last, Line::Done { .. }),
3233            "expected Done, got {last:?}"
3234        );
3235        assert!(
3236            rest.iter().all(|l| matches!(l, Line::Text { .. })),
3237            "every line before the terminal one should be Text, got {rest:?}"
3238        );
3239    }
3240
3241    #[test]
3242    fn drive_to_terminal_stops_at_choices() {
3243        let (program, tables) =
3244            compile_source_for_flow("Hello.\n* Pick me\n    Picked.\n    -> DONE\n");
3245        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3246        let mut local = FlowLocal::new();
3247        let mut view = ContextView::new(&mut world, &mut local);
3248        let lines = flow
3249            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3250            .expect("drive succeeds");
3251        let (last, rest) = lines.split_last().expect("at least one line");
3252        assert!(
3253            matches!(last, Line::Choices { .. }),
3254            "expected Choices, got {last:?}"
3255        );
3256        assert!(
3257            rest.iter().all(|l| matches!(l, Line::Text { .. })),
3258            "every line before the terminal one should be Text, got {rest:?}"
3259        );
3260    }
3261
3262    #[test]
3263    fn drive_to_terminal_stops_at_end() {
3264        let (program, tables) = compile_source_for_flow("Hello.\n-> END\n");
3265        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3266        let mut local = FlowLocal::new();
3267        let mut view = ContextView::new(&mut world, &mut local);
3268        let lines = flow
3269            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3270            .expect("drive succeeds");
3271        let (last, rest) = lines.split_last().expect("at least one line");
3272        assert!(
3273            matches!(last, Line::End { .. }),
3274            "expected End, got {last:?}"
3275        );
3276        assert!(
3277            rest.iter().all(|l| matches!(l, Line::Text { .. })),
3278            "every line before the terminal one should be Text, got {rest:?}"
3279        );
3280    }
3281
3282    /// A knot that prints and re-diverts into itself forever never reaches a
3283    /// terminal line, so `drive_to_terminal` must give up at
3284    /// `FlowInstance::LINE_LIMIT` rather than looping forever — proving the
3285    /// extracted op kept `Story::continue_maximally_impl`'s safety cap.
3286    #[test]
3287    fn drive_to_terminal_errors_at_line_limit() {
3288        let (program, tables) =
3289            compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
3290        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3291        let mut local = FlowLocal::new();
3292        let mut view = ContextView::new(&mut world, &mut local);
3293        let err = flow
3294            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3295            .expect_err("infinite content should hit the line limit rather than hang");
3296        match err {
3297            RuntimeError::LineLimitExceeded(n) => {
3298                assert_eq!(n, FlowInstance::LINE_LIMIT);
3299            }
3300            other => panic!("expected LineLimitExceeded, got {other:?}"),
3301        }
3302    }
3303
3304    // ── FlowInstance::drive (F6.2 pausable Layer-2 drive op) ─────────────
3305
3306    /// Defers (`Pending`) its first call, then resolves — mirrors the
3307    /// `DeferOnce` pattern used for the flow-level resume gap elsewhere in
3308    /// the runtime's test suite (`tests/session.rs`, `tests/speculation.rs`).
3309    struct DeferOnce {
3310        deferred: std::cell::Cell<bool>,
3311    }
3312
3313    impl ExternalFnHandler for DeferOnce {
3314        fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
3315            if name == "pause_once" && !self.deferred.get() {
3316                self.deferred.set(true);
3317                ExternalResult::Pending
3318            } else {
3319                ExternalResult::Resolved(Value::Int(2))
3320            }
3321        }
3322    }
3323
3324    /// `drive` pauses cleanly (no error) on a deferred external, and resuming
3325    /// after [`FlowInstance::resolve_external`] continues the *same* logical
3326    /// drive to its terminal line — the pausable sibling of
3327    /// `drive_to_terminal`, which instead errors on a deferred external.
3328    #[test]
3329    fn drive_pauses_on_awaiting_external_then_resumes() {
3330        let (program, tables) = compile_source_for_flow(
3331            "EXTERNAL pause_once(x)\nHello.\nWorld.\nValue: {pause_once(1)}.\n-> DONE\n",
3332        );
3333        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3334        let mut local = FlowLocal::new();
3335        let mut view = ContextView::new(&mut world, &mut local);
3336        let handler = DeferOnce {
3337            deferred: std::cell::Cell::new(false),
3338        };
3339        let mut budget = 10usize;
3340
3341        let outcome = flow
3342            .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
3343            .expect("first drive call succeeds");
3344        let paused_lines = match outcome {
3345            DriveOutcome::AwaitingExternal(lines) => lines,
3346            other @ DriveOutcome::Terminal(_) => panic!("expected AwaitingExternal, got {other:?}"),
3347        };
3348        let paused_text: String = paused_lines.iter().map(Line::text).collect();
3349        assert!(
3350            paused_text.contains("Hello"),
3351            "text produced before the pause should include 'Hello.'; got {paused_text:?}"
3352        );
3353        assert!(
3354            !paused_text.contains("Value"),
3355            "the line calling the deferred external should not have completed yet; got {paused_text:?}"
3356        );
3357        assert_eq!(
3358            budget,
3359            10 - paused_lines.len(),
3360            "budget should decrement by exactly the lines produced before the pause"
3361        );
3362
3363        flow.resolve_external(Value::Int(2));
3364        let outcome = flow
3365            .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
3366            .expect("second drive call resumes and completes");
3367        let resumed_lines = match outcome {
3368            DriveOutcome::Terminal(lines) => lines,
3369            other @ DriveOutcome::AwaitingExternal(_) => panic!("expected Terminal, got {other:?}"),
3370        };
3371        let resumed_text: String = resumed_lines.iter().map(Line::text).collect();
3372        assert!(
3373            resumed_text.contains("Value: 2"),
3374            "the resolved external's value should be inlined; got {resumed_text:?}"
3375        );
3376        assert!(
3377            matches!(resumed_lines.last(), Some(Line::Done { .. })),
3378            "expected the drive to finish at Done, got {resumed_lines:?}"
3379        );
3380        assert_eq!(
3381            budget,
3382            10 - paused_lines.len() - resumed_lines.len(),
3383            "budget must keep decrementing across the resume — not reset to a fresh cap \
3384             (the whole point of the caller-owned budget: one bound per logical drive, not \
3385             per resume)"
3386        );
3387    }
3388
3389    /// A knot that prints and re-diverts into itself forever never reaches a
3390    /// terminal line. `drive` must give up when the caller's `budget` is
3391    /// exhausted — which can be far smaller than
3392    /// [`FlowInstance::LINE_LIMIT`] — rather than looping until the much
3393    /// larger production default, proving the budget is a real per-call
3394    /// parameter and not just a relabeling of the constant.
3395    #[test]
3396    fn drive_errors_when_caller_budget_exhausted() {
3397        let (program, tables) =
3398            compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
3399        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3400        let mut local = FlowLocal::new();
3401        let mut view = ContextView::new(&mut world, &mut local);
3402        let mut budget = 3usize;
3403        let err = flow
3404            .drive::<FastRng>(
3405                &program,
3406                &tables,
3407                &mut view,
3408                &FallbackHandler,
3409                None,
3410                &mut budget,
3411            )
3412            .expect_err("infinite content should hit the caller's small budget");
3413        match err {
3414            RuntimeError::LineLimitExceeded(n) => assert_eq!(
3415                n, 3,
3416                "reported limit should be the caller's budget, not the unrelated LINE_LIMIT constant"
3417            ),
3418            other => panic!("expected LineLimitExceeded, got {other:?}"),
3419        }
3420        assert_eq!(budget, 0, "budget should be fully consumed, not partially");
3421    }
3422
3423    // ── free `save_state`/`load_state` (F6.1b) ───────────────────────────
3424
3425    /// A `Story`-free save/load roundtrip: drive a bare `FlowInstance` +
3426    /// `ContextView` (no `Story` anywhere), capture state via the lifted
3427    /// `save_state` free function, mutate the live context, then restore via
3428    /// `load_state` and confirm the mutation is undone. Proves the lifted
3429    /// functions work for a consumer (e.g. `bevy-brink`) that never
3430    /// constructs a `Story`.
3431    #[test]
3432    fn free_fn_save_load_roundtrip_without_story() {
3433        let (program, tables) = compile_source_for_flow(
3434            "VAR gold = 0\n\
3435             -> shrine\n\
3436             === shrine ===\n\
3437             ~ gold = 5\n\
3438             Shrine text.\n\
3439             -> DONE\n\
3440             === reader ===\n\
3441             {READ_COUNT(-> shrine)}\n\
3442             -> DONE\n",
3443            // `reader` is never entered — it exists only so the compiler's
3444            // counting-flags pass sees a visit-count read of `shrine` and
3445            // sets `CountingFlags::VISITS` on it (a knot with no read of its
3446            // own visit count anywhere in the program has counting disabled
3447            // entirely, an existing compiler optimization).
3448        );
3449        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3450        let mut local = FlowLocal::new();
3451        {
3452            let mut view = ContextView::new(&mut world, &mut local);
3453            flow.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3454                .expect("drive succeeds");
3455        }
3456
3457        let gold_slot = program.global_index("gold").expect("gold declared");
3458        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
3459
3460        let save = {
3461            let view = ContextView::new(&mut world, &mut local);
3462            crate::save_state(&program, &view)
3463        };
3464        assert_eq!(save.globals.get("gold"), Some(&Value::Int(5)));
3465        assert_eq!(
3466            save.visits
3467                .iter()
3468                .find(|e| e.id == shrine_id)
3469                .map(|e| e.count),
3470            Some(1),
3471            "shrine should have a captured visit entry"
3472        );
3473
3474        // Mutate the live context directly through the trait.
3475        {
3476            let mut view = ContextView::new(&mut world, &mut local);
3477            view.set_global(gold_slot, Value::Int(999));
3478            view.set_visit_count(shrine_id, 42);
3479        }
3480        {
3481            let view = ContextView::new(&mut world, &mut local);
3482            assert_eq!(view.global(gold_slot), &Value::Int(999));
3483            assert_eq!(view.visit_count(shrine_id), 42);
3484        }
3485
3486        // Restore via the lifted `load_state` and confirm the mutation is
3487        // undone.
3488        let report = {
3489            let mut view = ContextView::new(&mut world, &mut local);
3490            crate::load_state(&program, &mut view, &save)
3491        };
3492        assert!(report.unknown_globals.is_empty(), "clean load: {report:?}");
3493
3494        let view = ContextView::new(&mut world, &mut local);
3495        assert_eq!(view.global(gold_slot), &Value::Int(5));
3496        assert_eq!(view.visit_count(shrine_id), 1);
3497    }
3498}