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