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    /// The current execution status of this flow.
995    #[must_use]
996    pub fn status(&self) -> StoryStatus {
997        self.status
998    }
999
1000    /// Runtime statistics (instructions, materialization counts, etc.)
1001    /// accumulated over this flow's execution.
1002    #[must_use]
1003    pub fn stats(&self) -> &Stats {
1004        &self.stats
1005    }
1006
1007    /// The full append-only transcript of all output parts produced so far.
1008    ///
1009    /// The transcript stores structural references (e.g. `LineRef`) rather
1010    /// than resolved strings, so it can be re-rendered in any locale by
1011    /// passing a different set of line tables to
1012    /// [`transcript::render_transcript`](crate::transcript::render_transcript).
1013    #[must_use]
1014    pub fn transcript(&self) -> &[crate::output::OutputPart] {
1015        self.flow.output.transcript()
1016    }
1017
1018    /// Number of parts in the transcript.
1019    #[must_use]
1020    pub fn transcript_len(&self) -> usize {
1021        self.flow.output.transcript_len()
1022    }
1023
1024    /// Reset the transcript read cursor to the beginning (for re-rendering,
1025    /// e.g. after a locale swap).
1026    pub fn reset_cursor(&mut self) {
1027        self.flow.output.reset_cursor();
1028    }
1029
1030    /// The fragments captured during execution (for re-rendering choice
1031    /// display text and computed substrings in a different locale).
1032    #[must_use]
1033    pub fn fragments(&self) -> &[crate::output::Fragment] {
1034        self.flow.output.fragments()
1035    }
1036
1037    // ── External calls (ink → engine) ────────────────────────────────
1038
1039    /// Returns `true` if this flow is frozen on an unresolved external
1040    /// call — i.e. the VM hit a `CallExternal` opcode and the handler
1041    /// returned [`ExternalResult::Pending`], leaving the `External` frame
1042    /// on top of the call stack.
1043    ///
1044    /// The orchestration layer (e.g. a Bevy resolver system) polls this to
1045    /// decide whether the flow needs an external resolved before it can be
1046    /// driven further. Resolve via [`resolve_external`](Self::resolve_external).
1047    #[must_use]
1048    pub fn has_pending_external(&self) -> bool {
1049        self.flow.external_fn_id().is_some()
1050    }
1051
1052    /// The [`DefinitionId`] of the pending external function, if this flow
1053    /// is frozen on one. Returns `None` otherwise.
1054    #[must_use]
1055    pub fn pending_external_fn_id(&self) -> Option<DefinitionId> {
1056        self.flow.external_fn_id()
1057    }
1058
1059    /// The arguments to the pending external call, in declaration order.
1060    /// Empty if no external call is pending.
1061    #[must_use]
1062    pub fn pending_external_args(&self) -> &[Value] {
1063        self.flow.external_args()
1064    }
1065
1066    /// The ink-declared name of the pending external function, resolved
1067    /// against `program`'s name table. Returns `None` if no external is
1068    /// pending (or the entry is missing, which would indicate a malformed
1069    /// program).
1070    ///
1071    /// The orchestration layer uses this to look up the binding registered
1072    /// for this name.
1073    #[must_use]
1074    pub fn pending_external_name<'p>(&self, program: &'p Program) -> Option<&'p str> {
1075        let id = self.flow.external_fn_id()?;
1076        let entry = program.external_fn(id)?;
1077        Some(program.name(entry.name))
1078    }
1079
1080    /// Resolve a pending external call by supplying its return value. Pops
1081    /// the `External` frame and pushes `value` onto the value stack so the
1082    /// VM can resume. For fire-and-forget externals, pass [`Value::Null`].
1083    ///
1084    /// No-op if no external call is pending. After resolving, drive the
1085    /// flow forward with [`step_single_line`](Self::step_single_line).
1086    pub fn resolve_external(&mut self, value: Value) {
1087        self.flow.resolve_external(value);
1088    }
1089
1090    // ── Engine → ink calls ───────────────────────────────────────────
1091
1092    /// Evaluate an ink function from engine code, returning its value.
1093    ///
1094    /// This does **not** advance the player-visible story: a
1095    /// `FunctionEvalFromGame` boundary frame is pushed, `args` are passed
1096    /// in declaration order (exactly as a normal call site would), output
1097    /// is captured and discarded, and the function runs until it returns.
1098    ///
1099    /// If the function calls an external whose handler returns
1100    /// [`ExternalResult::Pending`] (e.g. a binding that needs Bevy World
1101    /// access), evaluation pauses and returns
1102    /// [`FunctionEval::AwaitingExternal`]; the caller resolves the
1103    /// external (see [`resolve_external`](Self::resolve_external)) and
1104    /// calls [`resume_function_eval`](Self::resume_function_eval).
1105    ///
1106    /// `container_idx` is the function's container, typically obtained from
1107    /// [`Program::find_address`](crate::Program::find_address) on the
1108    /// function name. Unlike a normal `Call`, this does not increment the
1109    /// function's visit count — an engine query is out-of-band, matching
1110    /// C#'s `EvaluateFunction`.
1111    ///
1112    /// # Errors
1113    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1114    ///   if a function evaluation is already in progress on this flow.
1115    /// - [`FunctionYielded`](RuntimeError::FunctionYielded) if the function
1116    ///   presents choices or ends the story (functions must not yield).
1117    /// - [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall)
1118    ///   if an external has neither a binding nor a fallback.
1119    #[expect(
1120        clippy::too_many_arguments,
1121        reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target and args"
1122    )]
1123    pub fn begin_function_eval<R: StoryRng>(
1124        &mut self,
1125        program: &Program,
1126        line_tables: &[Vec<brink_format::LineEntry>],
1127        context: &mut (impl ContextAccess + ?Sized),
1128        handler: &dyn ExternalFnHandler,
1129        container_idx: u32,
1130        args: &[Value],
1131        resolver: Option<&dyn PluralResolver>,
1132    ) -> Result<FunctionEval, RuntimeError> {
1133        if self.eval.is_some() {
1134            return Err(RuntimeError::AlreadyEvaluatingFunction);
1135        }
1136
1137        // Record floors BEFORE pushing args: the value-stack length (so the
1138        // return value and any leftover args can be reclaimed), and the
1139        // pending-choice count (so we can tell a choice the function
1140        // presents from choices the main story already has waiting).
1141        let value_floor = self.flow.value_stack.len();
1142        let choice_floor = self.flow.pending_choices.len();
1143
1144        // Isolate output: anything the function emits routes to the
1145        // capture scratch space and never reaches the transcript.
1146        self.flow.output.begin_capture();
1147
1148        let output_start = self.flow.output.target_len();
1149        let boundary = CallFrame {
1150            return_address: None,
1151            temps: Vec::new(),
1152            container_stack: vec![ContainerPosition {
1153                container_idx,
1154                offset: 0,
1155            }],
1156            frame_type: CallFrameType::FunctionEvalFromGame,
1157            external_fn_id: None,
1158            function_output_start: Some(output_start),
1159        };
1160        self.flow.current_thread_mut().call_stack.push(boundary);
1161        self.stats.frames_pushed += 1;
1162
1163        // Pass arguments onto the value stack in declaration order — the
1164        // function's prologue (`DeclareTemp`) binds them exactly as it
1165        // would for an in-story call.
1166        self.flow.value_stack.extend_from_slice(args);
1167
1168        self.eval = Some(EvalState {
1169            value_floor,
1170            choice_floor,
1171        });
1172        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
1173    }
1174
1175    /// Resume a function evaluation that paused on
1176    /// [`FunctionEval::AwaitingExternal`], after the pending external has
1177    /// been resolved via [`resolve_external`](Self::resolve_external).
1178    ///
1179    /// # Errors
1180    /// - [`NotEvaluatingFunction`](RuntimeError::NotEvaluatingFunction) if
1181    ///   no evaluation is in progress.
1182    /// - Same evaluation errors as
1183    ///   [`begin_function_eval`](Self::begin_function_eval).
1184    pub fn resume_function_eval<R: StoryRng>(
1185        &mut self,
1186        program: &Program,
1187        line_tables: &[Vec<brink_format::LineEntry>],
1188        context: &mut (impl ContextAccess + ?Sized),
1189        handler: &dyn ExternalFnHandler,
1190        resolver: Option<&dyn PluralResolver>,
1191    ) -> Result<FunctionEval, RuntimeError> {
1192        if self.eval.is_none() {
1193            return Err(RuntimeError::NotEvaluatingFunction);
1194        }
1195        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
1196    }
1197
1198    /// Returns `true` if a function evaluation is in progress (possibly
1199    /// paused awaiting an external).
1200    #[must_use]
1201    pub fn is_evaluating_function(&self) -> bool {
1202        self.eval.is_some()
1203    }
1204
1205    /// Step the VM until the in-progress function evaluation returns or
1206    /// pauses on a pending external. Shared by `begin`/`resume`.
1207    fn drive_function_eval<R: StoryRng>(
1208        &mut self,
1209        program: &Program,
1210        line_tables: &[Vec<brink_format::LineEntry>],
1211        context: &mut (impl ContextAccess + ?Sized),
1212        handler: &dyn ExternalFnHandler,
1213        resolver: Option<&dyn PluralResolver>,
1214    ) -> Result<FunctionEval, RuntimeError> {
1215        let step_start = self.stats.steps;
1216        loop {
1217            self.stats.steps += 1;
1218            if self.stats.steps - step_start > Self::STEP_LIMIT {
1219                self.abort_eval(program, line_tables, resolver);
1220                return Err(RuntimeError::StepLimitExceeded(Self::STEP_LIMIT));
1221            }
1222
1223            let stepped = vm::step::<R>(
1224                &mut self.flow,
1225                program,
1226                line_tables,
1227                context,
1228                &mut self.stats,
1229                resolver,
1230            )?;
1231            self.stats.materializations += self.flow.drain_materializations();
1232
1233            match stepped {
1234                vm::Stepped::Done | vm::Stepped::Ended => {
1235                    // A function reached `-> DONE`/`-> END` — illegal.
1236                    self.abort_eval(program, line_tables, resolver);
1237                    return Err(RuntimeError::FunctionYielded);
1238                }
1239                vm::Stepped::ExternalCall => {
1240                    if let Some(pending) =
1241                        self.resolve_eval_external(program, line_tables, resolver, handler)?
1242                    {
1243                        return Ok(pending);
1244                    }
1245                }
1246                vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {}
1247            }
1248
1249            // Did the boundary frame pop? Then the function has returned
1250            // (via `~ return` or implicit exhaustion).
1251            if !self.flow.has_eval_boundary() {
1252                let _captured = self.flow.output.end_capture(program, line_tables, resolver);
1253                let floor = self.eval.take().map_or(0, |e| e.value_floor);
1254                let mut ret: Option<Value> = None;
1255                while self.flow.value_stack.len() > floor {
1256                    let v = self.flow.value_stack.pop();
1257                    if ret.is_none() {
1258                        ret = v; // first popped = top of stack = the return value
1259                    }
1260                }
1261                return Ok(FunctionEval::Returned(ret.unwrap_or(Value::Null)));
1262            }
1263
1264            // A function must not present choices. Compare against the
1265            // count when the eval began — the main story may already have
1266            // choices waiting, which are none of our concern.
1267            let choice_floor = self.eval.as_ref().map_or(0, |e| e.choice_floor);
1268            if self.flow.pending_choices.len() > choice_floor {
1269                self.abort_eval(program, line_tables, resolver);
1270                return Err(RuntimeError::FunctionYielded);
1271            }
1272        }
1273    }
1274
1275    /// Resolve an external hit during function evaluation, mirroring the
1276    /// normal step path but surfacing [`ExternalResult::Pending`] as
1277    /// [`FunctionEval::AwaitingExternal`] (returned as `Some`) rather than
1278    /// an error. Returns `None` when the external resolved and stepping
1279    /// should continue.
1280    fn resolve_eval_external(
1281        &mut self,
1282        program: &Program,
1283        line_tables: &[Vec<brink_format::LineEntry>],
1284        resolver: Option<&dyn PluralResolver>,
1285        handler: &dyn ExternalFnHandler,
1286    ) -> Result<Option<FunctionEval>, RuntimeError> {
1287        let fn_id = self
1288            .flow
1289            .external_fn_id()
1290            .ok_or(RuntimeError::CallStackUnderflow)?;
1291        let entry = program.external_fn(fn_id);
1292        let fn_name = entry.map_or("?", |e| program.name(e.name));
1293        match handler.call(fn_name, self.flow.external_args()) {
1294            ExternalResult::Resolved(value) => {
1295                self.flow.resolve_external(value);
1296                Ok(None)
1297            }
1298            ExternalResult::Fallback => {
1299                if let Some(fb_id) = entry.and_then(|e| e.fallback) {
1300                    let container_idx = program
1301                        .resolve_target(fb_id)
1302                        .map(|(idx, _)| idx)
1303                        .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1304                    self.flow.invoke_fallback(container_idx);
1305                    Ok(None)
1306                } else {
1307                    self.abort_eval(program, line_tables, resolver);
1308                    Err(RuntimeError::UnresolvedExternalCall(fn_id))
1309                }
1310            }
1311            ExternalResult::Pending => Ok(Some(FunctionEval::AwaitingExternal)),
1312        }
1313    }
1314
1315    /// Tear down an aborted/failed evaluation: end the output capture and
1316    /// clear the eval marker. Leaves the call stack as-is (the caller is
1317    /// erroring out).
1318    fn abort_eval(
1319        &mut self,
1320        program: &Program,
1321        line_tables: &[Vec<brink_format::LineEntry>],
1322        resolver: Option<&dyn PluralResolver>,
1323    ) {
1324        if self.eval.take().is_some() {
1325            let _ = self.flow.output.end_capture(program, line_tables, resolver);
1326        }
1327    }
1328}
1329
1330/// Internal: set execution position to the given choice target, clear
1331/// pending choices, and set status to Active. No status precondition.
1332#[expect(clippy::similar_names)]
1333/// Returns the `DefinitionId` of the selected choice target, so the
1334/// caller can notify observers if needed.
1335fn select_choice(
1336    flow: &mut Flow,
1337    context: &mut (impl ContextAccess + ?Sized),
1338    status: &mut StoryStatus,
1339    stats: &mut Stats,
1340    index: usize,
1341) -> Result<(), RuntimeError> {
1342    let available = flow.pending_choices.len();
1343    if index >= available {
1344        return Err(RuntimeError::InvalidChoiceIndex { index, available });
1345    }
1346
1347    let choice = flow.pending_choices.swap_remove(index);
1348    let target_id = choice.target_id;
1349
1350    // Increment visit count for the choice target container so that
1351    // once-only choices can be filtered on subsequent passes.
1352    context.increment_visit(target_id);
1353    context.set_turn_count(target_id, context.turn_index());
1354
1355    // Replace the current thread with the fork from choice creation
1356    // time. By selection time, all spawned threads should have
1357    // completed — only the main thread remains.
1358    let current = flow.current_thread_mut();
1359    *current = choice.thread_fork;
1360
1361    // Set execution position to the choice target. We reset the top
1362    // frame's container_stack to just the target — the snapshot may
1363    // have captured stale nesting from inside the choice eval block.
1364    let frame = current
1365        .call_stack
1366        .last_mut()
1367        .ok_or(RuntimeError::CallStackUnderflow)?;
1368
1369    frame.container_stack.clear();
1370    frame.container_stack.push(ContainerPosition {
1371        container_idx: choice.target_idx,
1372        offset: choice.target_offset,
1373    });
1374
1375    flow.pending_choices.clear();
1376    *status = StoryStatus::Active;
1377    stats.choices_selected += 1;
1378
1379    Ok(())
1380}
1381
1382/// Resolve an external function call using the handler and program metadata.
1383///
1384/// Returns `Ok(true)` if the call was resolved (a value was supplied or the
1385/// in-story fallback was invoked) and stepping should continue; `Ok(false)`
1386/// if the handler deferred ([`ExternalResult::Pending`]), leaving the
1387/// `External` frame intact for the caller to resolve out-of-band. Errors
1388/// only when the handler declined and no fallback exists.
1389fn resolve_external_call(
1390    flow: &mut Flow,
1391    program: &Program,
1392    handler: &dyn ExternalFnHandler,
1393) -> Result<bool, RuntimeError> {
1394    let fn_id = flow
1395        .external_fn_id()
1396        .ok_or(RuntimeError::CallStackUnderflow)?;
1397
1398    let entry = program.external_fn(fn_id);
1399    let fn_name = entry.map_or("?", |e| program.name(e.name));
1400
1401    let result = handler.call(fn_name, flow.external_args());
1402    match result {
1403        ExternalResult::Resolved(value) => {
1404            flow.resolve_external(value);
1405            Ok(true)
1406        }
1407        ExternalResult::Fallback => {
1408            let fallback_id = entry.and_then(|e| e.fallback);
1409            if let Some(fb_id) = fallback_id {
1410                let container_idx = program
1411                    .resolve_target(fb_id)
1412                    .map(|(idx, _)| idx)
1413                    .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1414
1415                flow.invoke_fallback(container_idx);
1416                Ok(true)
1417            } else {
1418                Err(RuntimeError::UnresolvedExternalCall(fn_id))
1419            }
1420        }
1421        ExternalResult::Pending => {
1422            // Leave the External frame intact — the caller resolves it
1423            // out-of-band (via resolve_external) before continuing.
1424            Ok(false)
1425        }
1426    }
1427}
1428
1429/// Flush remaining output buffer content into `(text, tags)`.
1430///
1431/// At a yield point (Done/Choices/Ended), no more output is coming, so
1432/// trailing newlines are committed. Lines are joined with `\n` and tags
1433/// are flattened into a single vec.
1434fn flush_remaining(
1435    flow: &mut Flow,
1436    program: &Program,
1437    line_tables: &[Vec<brink_format::LineEntry>],
1438    resolver: Option<&dyn brink_format::PluralResolver>,
1439) -> (String, Vec<String>) {
1440    let lines = flow.output.flush_lines(program, line_tables, resolver);
1441    let mut text = String::new();
1442    let mut tags = Vec::new();
1443    for (i, (line_text, line_tags)) in lines.iter().enumerate() {
1444        if i > 0 {
1445            text.push('\n');
1446        }
1447        text.push_str(line_text);
1448        tags.extend_from_slice(line_tags);
1449    }
1450    (text, tags)
1451}
1452
1453/// Build the appropriate [`Line`] variant for a yield point based on
1454/// the current story status.
1455fn make_yield_line(
1456    status: StoryStatus,
1457    text: String,
1458    tags: Vec<String>,
1459    flow: &Flow,
1460    program: &Program,
1461    line_tables: &[Vec<brink_format::LineEntry>],
1462    resolver: Option<&dyn brink_format::PluralResolver>,
1463) -> Line {
1464    match status {
1465        StoryStatus::WaitingForChoice => {
1466            let choices = flow
1467                .pending_choices
1468                .iter()
1469                .enumerate()
1470                .filter(|(_, pc)| !pc.flags.is_invisible_default)
1471                .map(|(i, pc)| {
1472                    let display_text = match &pc.display {
1473                        ChoiceDisplay::Text(s) => s.clone(),
1474                        ChoiceDisplay::Fragment(idx) => {
1475                            flow.output
1476                                .resolve_fragment(*idx, program, line_tables, resolver)
1477                        }
1478                    };
1479                    // Trim spaces/tabs from choice display text, matching C#:
1480                    // choice.text = (startText + choiceOnlyText).Trim(' ', '\t');
1481                    let display_text = display_text
1482                        .trim_matches(|c: char| c == ' ' || c == '\t')
1483                        .to_string();
1484                    Choice {
1485                        text: display_text,
1486                        index: i,
1487                        tags: pc.tags.clone(),
1488                    }
1489                })
1490                .collect();
1491            Line::Choices {
1492                text,
1493                tags,
1494                choices,
1495            }
1496        }
1497        StoryStatus::Ended => Line::End { text, tags },
1498        StoryStatus::Done => Line::Done { text, tags },
1499        StoryStatus::Active => Line::Text { text, tags },
1500    }
1501}
1502
1503// ── Story ───────────────────────────────────────────────────────────────────
1504
1505/// Per-instance mutable state for executing stories.
1506///
1507/// Created from a [`Program`] via [`Story::new`]. Holds all mutable state
1508/// (stacks, globals, output buffer) while the immutable program data lives
1509/// in [`Program`].
1510///
1511/// Generic over `R: StoryRng` — defaults to [`FastRng`]. Use
1512/// [`DotNetRng`](crate::DotNetRng) for .NET-compatible deterministic output.
1513pub struct Story<'p, R: StoryRng = FastRng> {
1514    program: &'p Program,
1515    pub(crate) default: FlowInstance,
1516    pub(crate) default_context: Context,
1517    line_tables: Vec<Vec<brink_format::LineEntry>>,
1518    instances: HashMap<String, (FlowInstance, Context)>,
1519    resolver: Option<Box<dyn PluralResolver>>,
1520    _rng: PhantomData<R>,
1521}
1522
1523impl<R: StoryRng> Clone for Story<'_, R> {
1524    fn clone(&self) -> Self {
1525        Self {
1526            program: self.program,
1527            default: self.default.clone(),
1528            default_context: self.default_context.clone(),
1529            line_tables: self.line_tables.clone(),
1530            instances: self.instances.clone(),
1531            resolver: None,
1532            _rng: PhantomData,
1533        }
1534    }
1535}
1536
1537/// Owned story state that can be detached from a `Program` and reattached later.
1538///
1539/// Created by [`Story::into_snapshot`], consumed by [`Story::from_snapshot`].
1540/// This enables locale hot-swapping: detach state, mutate the program's line
1541/// tables, then reattach.
1542pub struct StorySnapshot<R: StoryRng = FastRng> {
1543    default: FlowInstance,
1544    default_context: Context,
1545    instances: HashMap<String, (FlowInstance, Context)>,
1546    _rng: PhantomData<R>,
1547}
1548
1549impl<'p, R: StoryRng> Story<'p, R> {
1550    /// Create a new story instance from a linked program and its line tables.
1551    pub fn new(program: &'p Program, line_tables: Vec<Vec<brink_format::LineEntry>>) -> Self {
1552        let (default, default_context) = FlowInstance::new_at_root(program);
1553        Self {
1554            program,
1555            default,
1556            default_context,
1557            line_tables,
1558            instances: HashMap::new(),
1559            resolver: None,
1560            _rng: PhantomData,
1561        }
1562    }
1563
1564    /// Set the plural resolver for Select resolution in localized lines.
1565    pub fn set_plural_resolver(&mut self, resolver: Box<dyn PluralResolver>) {
1566        self.resolver = Some(resolver);
1567    }
1568
1569    /// Replace the active line tables (e.g. for locale swapping).
1570    pub fn set_line_tables(&mut self, tables: Vec<Vec<brink_format::LineEntry>>) {
1571        self.line_tables = tables;
1572    }
1573
1574    /// Read-only access to the current line tables.
1575    pub fn line_tables(&self) -> &[Vec<brink_format::LineEntry>] {
1576        &self.line_tables
1577    }
1578
1579    /// The full append-only transcript of all output parts produced so far.
1580    pub fn transcript(&self) -> &[crate::output::OutputPart] {
1581        self.default.flow.output.transcript()
1582    }
1583
1584    /// Number of parts in the transcript.
1585    pub fn transcript_len(&self) -> usize {
1586        self.default.flow.output.transcript_len()
1587    }
1588
1589    /// Reset the transcript read cursor to the beginning (for re-rendering).
1590    pub fn reset_cursor(&mut self) {
1591        self.default.flow.output.reset_cursor();
1592    }
1593
1594    /// Resolve a slice of the transcript against the current line tables.
1595    /// Returns `(text, tags)` tuples — one per line in the resolved output.
1596    pub fn resolve_transcript_slice(
1597        &self,
1598        range: std::ops::Range<usize>,
1599    ) -> Vec<(String, Vec<String>)> {
1600        let transcript = self.default.flow.output.transcript();
1601        let end = range.end.min(transcript.len());
1602        let start = range.start.min(end);
1603        let slice = &transcript[start..end];
1604        let fragments = self.default.flow.output.fragments();
1605        crate::output::resolve_lines(
1606            slice,
1607            self.program,
1608            &self.line_tables,
1609            self.resolver.as_deref(),
1610            fragments,
1611        )
1612    }
1613
1614    /// Re-resolve all pending choices against the current line tables.
1615    /// Returns the same choices that would appear in `Line::Choices`,
1616    /// but freshly resolved (useful after locale switch).
1617    pub fn pending_choices(&self) -> Vec<Choice> {
1618        self.default
1619            .flow
1620            .pending_choices
1621            .iter()
1622            .enumerate()
1623            .filter(|(_, pc)| !pc.flags.is_invisible_default)
1624            .map(|(i, pc)| {
1625                let display_text = match &pc.display {
1626                    ChoiceDisplay::Text(s) => s.clone(),
1627                    ChoiceDisplay::Fragment(idx) => self.default.flow.output.resolve_fragment(
1628                        *idx,
1629                        self.program,
1630                        &self.line_tables,
1631                        self.resolver.as_deref(),
1632                    ),
1633                };
1634                let display_text = display_text
1635                    .trim_matches(|c: char| c == ' ' || c == '\t')
1636                    .to_string();
1637                Choice {
1638                    text: display_text,
1639                    index: i,
1640                    tags: pc.tags.clone(),
1641                }
1642            })
1643            .collect()
1644    }
1645
1646    /// Resolve a fragment against the current line tables.
1647    pub fn resolve_fragment(&self, idx: u32) -> String {
1648        self.default.flow.output.resolve_fragment(
1649            idx,
1650            self.program,
1651            &self.line_tables,
1652            self.resolver.as_deref(),
1653        )
1654    }
1655
1656    /// Get the fragment index for a pending choice's display text, if any.
1657    pub fn choice_fragment_idx(&self, choice_index: usize) -> Option<u32> {
1658        self.default
1659            .flow
1660            .pending_choices
1661            .get(choice_index)
1662            .and_then(|pc| match &pc.display {
1663                ChoiceDisplay::Fragment(idx) => Some(*idx),
1664                ChoiceDisplay::Text(_) => None,
1665            })
1666    }
1667
1668    /// Read-only access to the fragment store (for transcript serialization).
1669    pub fn fragments(&self) -> &[crate::output::Fragment] {
1670        self.default.flow.output.fragments()
1671    }
1672
1673    /// Read-only access to the program.
1674    pub fn program(&self) -> &Program {
1675        self.program
1676    }
1677
1678    /// Detach story state from the program, consuming the story.
1679    pub fn into_snapshot(self) -> (StorySnapshot<R>, Vec<Vec<brink_format::LineEntry>>) {
1680        let snapshot = StorySnapshot {
1681            default: self.default,
1682            default_context: self.default_context,
1683            instances: self.instances,
1684            _rng: PhantomData,
1685        };
1686        (snapshot, self.line_tables)
1687    }
1688
1689    /// Reattach a snapshot to a program with line tables.
1690    pub fn from_snapshot(
1691        program: &'p Program,
1692        snapshot: StorySnapshot<R>,
1693        line_tables: Vec<Vec<brink_format::LineEntry>>,
1694    ) -> Self {
1695        Self {
1696            program,
1697            default: snapshot.default,
1698            default_context: snapshot.default_context,
1699            line_tables,
1700            instances: snapshot.instances,
1701            resolver: None,
1702            _rng: PhantomData,
1703        }
1704    }
1705
1706    // ── Execution API ──────────────────────────────────────────────
1707
1708    /// Execute until one line of content (up to newline), or until a
1709    /// yield point (choices/end) if no newline occurs first.
1710    ///
1711    /// The returned [`Line`] variant tells you what to do next:
1712    /// - [`Line::Text`] — more output may follow, keep calling.
1713    /// - [`Line::Choices`] — call [`choose`](Self::choose) then resume.
1714    /// - [`Line::End`] — the story has permanently ended.
1715    pub fn continue_single(&mut self) -> Result<Line, RuntimeError> {
1716        let resolver = self.resolver.as_deref();
1717        self.default.step_single_line::<R>(
1718            self.program,
1719            &self.line_tables,
1720            &mut self.default_context,
1721            &FallbackHandler,
1722            resolver,
1723        )
1724    }
1725
1726    /// Like [`continue_single`](Self::continue_single) but with a
1727    /// [`WriteObserver`] that receives notifications for every state mutation.
1728    pub fn continue_single_observed(
1729        &mut self,
1730        observer: &mut dyn WriteObserver,
1731    ) -> Result<Line, RuntimeError> {
1732        use crate::state::ObservedContext;
1733        let mut obs_ctx = ObservedContext::new(&mut self.default_context, observer);
1734        let resolver = self.resolver.as_deref();
1735        self.default.step_single_line::<R>(
1736            self.program,
1737            &self.line_tables,
1738            &mut obs_ctx,
1739            &FallbackHandler,
1740            resolver,
1741        )
1742    }
1743
1744    /// Like [`continue_single`](Self::continue_single) but with a custom
1745    /// external function handler.
1746    pub fn continue_single_with(
1747        &mut self,
1748        handler: &dyn ExternalFnHandler,
1749    ) -> Result<Line, RuntimeError> {
1750        let resolver = self.resolver.as_deref();
1751        self.default.step_single_line::<R>(
1752            self.program,
1753            &self.line_tables,
1754            &mut self.default_context,
1755            handler,
1756            resolver,
1757        )
1758    }
1759
1760    /// Execute until the next yield point, collecting all lines.
1761    ///
1762    /// Returns a `Vec<Line>` where the last element is always
1763    /// [`Line::Choices`] or [`Line::End`], and all preceding elements
1764    /// are [`Line::Text`].
1765    pub fn continue_maximally(&mut self) -> Result<Vec<Line>, RuntimeError> {
1766        self.continue_maximally_impl(&FallbackHandler)
1767    }
1768
1769    /// Like [`continue_maximally`](Self::continue_maximally) but with a
1770    /// custom external function handler.
1771    pub fn continue_maximally_with(
1772        &mut self,
1773        handler: &dyn ExternalFnHandler,
1774    ) -> Result<Vec<Line>, RuntimeError> {
1775        self.continue_maximally_impl(handler)
1776    }
1777
1778    /// Maximum lines per `continue_maximally` call. Safety net against
1779    /// infinite loops from malformed bytecode.
1780    const LINE_LIMIT: usize = 10_000;
1781
1782    fn continue_maximally_impl(
1783        &mut self,
1784        handler: &dyn ExternalFnHandler,
1785    ) -> Result<Vec<Line>, RuntimeError> {
1786        let mut lines = Vec::new();
1787        loop {
1788            let resolver = self.resolver.as_deref();
1789            let line = self.default.step_single_line::<R>(
1790                self.program,
1791                &self.line_tables,
1792                &mut self.default_context,
1793                handler,
1794                resolver,
1795            )?;
1796            let terminal = line.is_terminal();
1797            lines.push(line);
1798            if terminal {
1799                return Ok(lines);
1800            }
1801            if lines.len() >= Self::LINE_LIMIT {
1802                return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
1803            }
1804        }
1805    }
1806
1807    /// Execute until the next yield point with a [`WriteObserver`] that
1808    /// receives notifications for every state mutation.
1809    pub fn continue_maximally_observed(
1810        &mut self,
1811        observer: &mut dyn WriteObserver,
1812    ) -> Result<Vec<Line>, RuntimeError> {
1813        use crate::state::ObservedContext;
1814        let mut obs_ctx = ObservedContext::new(&mut self.default_context, observer);
1815        let mut lines = Vec::new();
1816        loop {
1817            let resolver = self.resolver.as_deref();
1818            let line = self.default.step_single_line::<R>(
1819                self.program,
1820                &self.line_tables,
1821                &mut obs_ctx,
1822                &FallbackHandler,
1823                resolver,
1824            )?;
1825            let terminal = line.is_terminal();
1826            lines.push(line);
1827            if terminal {
1828                return Ok(lines);
1829            }
1830            if lines.len() >= Self::LINE_LIMIT {
1831                return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
1832            }
1833        }
1834    }
1835
1836    /// Select a choice by index, then resume with
1837    /// [`continue_single`](Self::continue_single) or
1838    /// [`continue_maximally`](Self::continue_maximally).
1839    pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
1840        self.default.choose(&mut self.default_context, index)
1841    }
1842
1843    /// Read-only access to the default flow's VM statistics.
1844    pub fn stats(&self) -> &Stats {
1845        &self.default.stats
1846    }
1847
1848    /// Returns `true` if the default flow has a pending external call
1849    /// (an `External` frame on top of the call stack).
1850    pub fn has_pending_external(&self) -> bool {
1851        self.default.flow.external_fn_id().is_some()
1852    }
1853
1854    /// Resolve a pending external call on the default flow by providing
1855    /// the return value. For fire-and-forget calls, pass `Value::Null`.
1856    ///
1857    /// After resolving, call [`continue_maximally`](Story::continue_maximally)
1858    /// to continue execution.
1859    pub fn resolve_external(&mut self, value: Value) {
1860        self.default.flow.resolve_external(value);
1861    }
1862
1863    /// Resolve a pending external call on the default flow by invoking
1864    /// the ink-defined fallback body. The fallback is a function call
1865    /// whose output becomes the return value.
1866    ///
1867    /// After invoking, call [`continue_maximally`](Story::continue_maximally)
1868    /// to continue execution.
1869    pub fn invoke_fallback(&mut self) -> Result<(), RuntimeError> {
1870        let fn_id = self
1871            .default
1872            .flow
1873            .external_fn_id()
1874            .ok_or(RuntimeError::CallStackUnderflow)?;
1875        let entry = self.program.external_fn(fn_id);
1876        let fallback_id = entry
1877            .and_then(|e| e.fallback)
1878            .ok_or(RuntimeError::UnresolvedExternalCall(fn_id))?;
1879        let container_idx = self
1880            .program
1881            .resolve_target(fallback_id)
1882            .map(|(idx, _)| idx)
1883            .ok_or(RuntimeError::UnresolvedDefinition(fallback_id))?;
1884        self.default.flow.output.begin_capture();
1885        self.default.flow.invoke_fallback(container_idx);
1886        Ok(())
1887    }
1888
1889    // ── Named flow API ──────────────────────────────────────────────
1890
1891    /// Spawn a new flow instance starting at the given entry point.
1892    ///
1893    /// `entry_point` is the `DefinitionId` of the target container
1894    /// (e.g., a knot). Each flow instance gets its own globals, visit
1895    /// counts, and execution state.
1896    pub fn spawn_flow(
1897        &mut self,
1898        name: &str,
1899        entry_point: DefinitionId,
1900    ) -> Result<(), RuntimeError> {
1901        if self.instances.contains_key(name) {
1902            return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
1903        }
1904        let container_idx = self
1905            .program
1906            .resolve_target(entry_point)
1907            .map(|(idx, _)| idx)
1908            .ok_or(RuntimeError::UnresolvedDefinition(entry_point))?;
1909        let (flow, ctx) = FlowInstance::new_at(self.program, container_idx);
1910        self.instances.insert(name.to_owned(), (flow, ctx));
1911        Ok(())
1912    }
1913
1914    /// Run a named flow instance until the next yield point.
1915    pub fn continue_flow_maximally(&mut self, name: &str) -> Result<Vec<Line>, RuntimeError> {
1916        self.continue_flow_maximally_with(name, &FallbackHandler)
1917    }
1918
1919    /// Run a named flow instance with an external function handler.
1920    pub fn continue_flow_maximally_with(
1921        &mut self,
1922        name: &str,
1923        handler: &dyn ExternalFnHandler,
1924    ) -> Result<Vec<Line>, RuntimeError> {
1925        let (instance, ctx) = self
1926            .instances
1927            .get_mut(name)
1928            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1929        let mut lines = Vec::new();
1930        loop {
1931            let resolver = self.resolver.as_deref();
1932            let line = instance.step_single_line::<R>(
1933                self.program,
1934                &self.line_tables,
1935                ctx,
1936                handler,
1937                resolver,
1938            )?;
1939            let terminal = line.is_terminal();
1940            lines.push(line);
1941            if terminal {
1942                return Ok(lines);
1943            }
1944            if lines.len() >= Self::LINE_LIMIT {
1945                return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
1946            }
1947        }
1948    }
1949
1950    /// Select a choice in a named flow.
1951    pub fn choose_flow(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
1952        let (instance, ctx) = self
1953            .instances
1954            .get_mut(name)
1955            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1956        instance.choose(ctx, index)
1957    }
1958
1959    /// Destroy a named flow instance.
1960    pub fn destroy_flow(&mut self, name: &str) -> Result<(), RuntimeError> {
1961        if self.instances.remove(name).is_none() {
1962            return Err(RuntimeError::UnknownFlow(name.to_owned()));
1963        }
1964        Ok(())
1965    }
1966
1967    /// List active flow names.
1968    pub fn flow_names(&self) -> Vec<&str> {
1969        self.instances.keys().map(String::as_str).collect()
1970    }
1971
1972    // ── Testing / instrumentation API ───────────────────────────────
1973
1974    /// Dump the current execution state for debugging.
1975    ///
1976    /// Returns a human-readable summary of the call stack, current position,
1977    /// value stack, output buffer, globals, and pending choices.
1978    #[cfg(feature = "testing")]
1979    pub fn debug_state(&self) -> String {
1980        use std::fmt::Write;
1981        let mut out = String::new();
1982        let flow = &self.default.flow;
1983        let ctx = &self.default_context;
1984
1985        let _ = writeln!(out, "=== Story Debug State ===");
1986        let _ = writeln!(out, "status: {:?}", self.default.status);
1987
1988        // Current position
1989        let thread = flow.current_thread();
1990        if let Some(frame) = thread.call_stack.last()
1991            && let Some(cp) = frame.container_stack.last()
1992        {
1993            let id = self.program.container(cp.container_idx).id;
1994            let _ = writeln!(
1995                out,
1996                "position: container_idx={} id={id:?} offset={}",
1997                cp.container_idx, cp.offset,
1998            );
1999        }
2000
2001        // Call stack
2002        let depth = thread.call_stack.len();
2003        let _ = writeln!(out, "\ncall stack ({depth} frames):");
2004        for i in 0..depth {
2005            if let Some(frame) = thread.call_stack.get(i) {
2006                let ret = frame
2007                    .return_address
2008                    .map(|r| format!("idx={} off={}", r.container_idx, r.offset));
2009                let _ = writeln!(
2010                    out,
2011                    "  [{i}] {:?} ret={} temps={} containers={}",
2012                    frame.frame_type,
2013                    ret.as_deref().unwrap_or("none"),
2014                    frame.temps.len(),
2015                    frame.container_stack.len(),
2016                );
2017                for (j, cp) in frame.container_stack.iter().enumerate() {
2018                    let id = self.program.container(cp.container_idx).id;
2019                    let _ = writeln!(
2020                        out,
2021                        "       container_stack[{j}]: idx={} id={id:?} off={}",
2022                        cp.container_idx, cp.offset,
2023                    );
2024                }
2025            }
2026        }
2027
2028        // Value stack
2029        let _ = writeln!(out, "\nvalue stack ({}):", flow.value_stack.len());
2030        for (i, v) in flow.value_stack.iter().enumerate() {
2031            let _ = writeln!(out, "  [{i}] {v:?}");
2032        }
2033
2034        // Output buffer (unread transcript)
2035        let unread_start = flow.output.cursor;
2036        let transcript = &flow.output.transcript[unread_start..];
2037        let _ = writeln!(
2038            out,
2039            "\noutput buffer (cursor={unread_start}, {} unread parts):",
2040            transcript.len(),
2041        );
2042        for (i, part) in transcript.iter().enumerate() {
2043            let _ = writeln!(out, "  [{i}] {part:?}");
2044        }
2045
2046        // Globals
2047        let _ = writeln!(out, "\nglobals:");
2048        for (i, v) in ctx.globals.iter().enumerate() {
2049            #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
2050            if let Some(name) = self.program.global_name(i as u32) {
2051                let _ = writeln!(out, "  {name} = {v:?}");
2052            }
2053        }
2054
2055        // Flow flags
2056        let _ = writeln!(out, "\nskipping_choice: {}", flow.skipping_choice);
2057
2058        // Pending choices
2059        let _ = writeln!(out, "\npending choices ({}):", flow.pending_choices.len());
2060        for (i, c) in flow.pending_choices.iter().enumerate() {
2061            let _ = writeln!(out, "  [{i}] {:?} -> {:?}", c.display, c.target_id);
2062        }
2063
2064        out
2065    }
2066
2067    /// Returns whether the last execution cycle ended with a safe exit
2068    /// (explicit `-> DONE` opcode). If false after a `Done` line, the
2069    /// story ran out of content.
2070    #[cfg(feature = "testing")]
2071    pub fn did_safe_exit(&self) -> bool {
2072        self.default.flow.did_safe_exit
2073    }
2074
2075    /// Returns whether the last execution cycle passed through an empty
2076    /// choice set (a `Yield` opcode with no pending choices).
2077    #[cfg(feature = "testing")]
2078    pub fn did_unsafe_yield(&self) -> bool {
2079        self.default.flow.did_unsafe_yield
2080    }
2081
2082    /// Execute a single VM step and return a debug trace of what happened.
2083    ///
2084    /// Returns `(opcode_description, container_idx, offset_before)` or None
2085    /// if the step didn't decode an opcode (frame exhaustion, thread completion, etc).
2086    #[cfg(feature = "testing")]
2087    pub fn step_once(&mut self) -> Result<Option<(String, u32, usize)>, RuntimeError> {
2088        use brink_format::Opcode;
2089
2090        let flow = &self.default.flow;
2091        let thread = flow.current_thread();
2092
2093        // Capture position before step
2094        let pre_info = thread.call_stack.last().and_then(|frame| {
2095            frame.container_stack.last().map(|pos| {
2096                let container = self.program.container(pos.container_idx);
2097                if pos.offset < container.bytecode.len() {
2098                    let mut off = pos.offset;
2099                    let op = Opcode::decode(&container.bytecode, &mut off).ok();
2100                    (pos.container_idx, pos.offset, op)
2101                } else {
2102                    (pos.container_idx, pos.offset, None)
2103                }
2104            })
2105        });
2106
2107        // Execute one step
2108        let _result = vm::step::<R>(
2109            &mut self.default.flow,
2110            self.program,
2111            &self.line_tables,
2112            &mut self.default_context,
2113            &mut self.default.stats,
2114            self.resolver.as_deref(),
2115        )?;
2116
2117        match pre_info {
2118            Some((ci, off, Some(op))) => Ok(Some((format!("{op:?}"), ci, off))),
2119            Some((ci, off, None)) => Ok(Some(("(end of container)".to_string(), ci, off))),
2120            None => Ok(None),
2121        }
2122    }
2123}
2124
2125#[cfg(test)]
2126#[expect(clippy::panic)]
2127mod tests {
2128    use super::*;
2129    use crate::link;
2130
2131    fn load_i079_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2132        let json_str = std::fs::read_to_string(
2133            "../../tests/tier1/choices/I079-once-only-choices-can-link-back-to-self/story.ink.json",
2134        )
2135        .unwrap();
2136        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2137        let data = brink_converter::convert(&ink).unwrap();
2138        link(&data).unwrap()
2139    }
2140
2141    /// Step a story until it yields choices, panicking if it ends first.
2142    fn step_until_choices(story: &mut Story) -> Vec<Choice> {
2143        loop {
2144            match story.continue_single().unwrap() {
2145                Line::Choices { choices, .. } => return choices,
2146                Line::Text { .. } => {}
2147                Line::Done { .. } => panic!("story hit Done before presenting choices"),
2148                Line::End { .. } => panic!("story ended before presenting choices"),
2149            }
2150        }
2151    }
2152
2153    /// After selecting a once-only choice, the visit count for its target
2154    /// container must be > 0. Without this, the once-only filter in
2155    /// `handle_begin_choice` can never fire.
2156    #[test]
2157    fn select_choice_increments_visit_count_for_target() {
2158        let (program, line_tables) = load_i079_program();
2159        let mut story = Story::new(&program, line_tables);
2160        let choices = step_until_choices(&mut story);
2161
2162        assert!(!choices.is_empty(), "expected at least one choice");
2163
2164        // Record the target_id of the first pending choice BEFORE selecting.
2165        let target_id = story.default.flow.pending_choices[0].target_id;
2166        let visit_before = story
2167            .default_context
2168            .visit_counts
2169            .get(&target_id)
2170            .copied()
2171            .unwrap_or(0);
2172
2173        story.choose(0).unwrap();
2174
2175        // After selection, the visit count for this target must have increased.
2176        let visit_after = story
2177            .default_context
2178            .visit_counts
2179            .get(&target_id)
2180            .copied()
2181            .unwrap_or(0);
2182        assert!(
2183            visit_after > visit_before,
2184            "visit count for choice target should increment after selection: \
2185             before={visit_before}, after={visit_after}"
2186        );
2187    }
2188
2189    /// On the second pass through a choice set with once-only choices,
2190    /// a choice whose target has already been visited must NOT appear
2191    /// in `pending_choices`.
2192    #[test]
2193    fn once_only_choice_excluded_on_second_pass() {
2194        let (program, line_tables) = load_i079_program();
2195        let mut story = Story::new(&program, line_tables);
2196
2197        let first_choices = step_until_choices(&mut story);
2198        assert!(
2199            first_choices
2200                .iter()
2201                .any(|c| c.text.contains("First choice")),
2202            "first pass should contain 'First choice', got: {first_choices:?}"
2203        );
2204
2205        story.choose(0).unwrap();
2206
2207        let second_choices = step_until_choices(&mut story);
2208        assert!(
2209            !second_choices
2210                .iter()
2211                .any(|c| c.text.contains("First choice")),
2212            "second pass should NOT contain 'First choice' (once-only, already visited), \
2213             got: {second_choices:?}"
2214        );
2215    }
2216
2217    // ── Choice thread forking ──────────────────────────────────────────
2218
2219    fn load_i083_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2220        let json_str = std::fs::read_to_string(
2221            "../../tests/tier1/choices/I083-choice-thread-forking/story.ink.json",
2222        )
2223        .unwrap();
2224        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2225        let data = brink_converter::convert(&ink).unwrap();
2226        link(&data).unwrap()
2227    }
2228
2229    /// When a choice is created inside a tunnel, the call stack at that
2230    /// moment (including the tunnel frame with its temps) must be captured.
2231    /// After the tunnel returns and the choice is presented, the snapshot
2232    /// should still reflect the tunnel-era call stack depth (>= 2 frames).
2233    #[test]
2234    fn pending_choice_captures_tunnel_call_stack() {
2235        let (program, line_tables) = load_i083_program();
2236        let mut story = Story::new(&program, line_tables);
2237        let _choices = step_until_choices(&mut story);
2238
2239        // At this point the tunnel has returned, so the live call_stack
2240        // has only the root frame.
2241        let current_thread = story.default.flow.current_thread();
2242        assert_eq!(
2243            current_thread.call_stack.len(),
2244            1,
2245            "live call stack should be 1 frame (root) after tunnel return"
2246        );
2247
2248        // But the pending choice's fork should have captured the
2249        // call stack from inside the tunnel (root + tunnel = 2 frames).
2250        assert!(!story.default.flow.pending_choices.is_empty());
2251        let fork = &story.default.flow.pending_choices[0].thread_fork;
2252        assert!(
2253            fork.call_stack.len() >= 2,
2254            "choice fork should have >= 2 frames (root + tunnel), got {}",
2255            fork.call_stack.len()
2256        );
2257    }
2258
2259    /// After selecting a choice that was created inside a tunnel,
2260    /// `select_choice` must restore the tunnel's call frame so that
2261    /// temp variables from the tunnel scope are accessible.
2262    #[test]
2263    fn select_choice_restores_tunnel_frame_with_temps() {
2264        let (program, line_tables) = load_i083_program();
2265        let mut story = Story::new(&program, line_tables);
2266        let _choices = step_until_choices(&mut story);
2267
2268        // Before choosing: only root frame, no tunnel temps.
2269        assert_eq!(story.default.flow.current_thread().call_stack.len(), 1);
2270
2271        story.choose(0).unwrap();
2272
2273        // After choosing: the tunnel frame should be restored.
2274        // The call stack should have at least 2 frames (root + tunnel).
2275        let call_stack = &story.default.flow.current_thread().call_stack;
2276        assert!(
2277            call_stack.len() >= 2,
2278            "call stack should be restored to tunnel depth after choice selection, \
2279             got {} frame(s)",
2280            call_stack.len()
2281        );
2282
2283        // The tunnel frame (last frame) should have temp x = Int(1).
2284        let tunnel_frame = call_stack.last().unwrap();
2285        assert!(
2286            !tunnel_frame.temps.is_empty(),
2287            "tunnel frame should have temp variables"
2288        );
2289        assert_eq!(
2290            tunnel_frame.temps[0],
2291            Value::Int(1),
2292            "tunnel frame temps[0] should be Int(1) (the parameter x)"
2293        );
2294    }
2295
2296    // ── Tags ──────────────────────────────────────────────────────────
2297
2298    fn load_tags_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2299        let json_str =
2300            std::fs::read_to_string("../../tests/tier3/tags/tags/story.ink.json").unwrap();
2301        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2302        let data = brink_converter::convert(&ink).unwrap();
2303        link(&data).unwrap()
2304    }
2305
2306    fn load_tags_in_choice_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2307        let json_str =
2308            std::fs::read_to_string("../../tests/tier3/tags/tagsInChoice/story.ink.json").unwrap();
2309        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2310        let data = brink_converter::convert(&ink).unwrap();
2311        link(&data).unwrap()
2312    }
2313
2314    #[test]
2315    fn line_exposes_tags() {
2316        let (program, line_tables) = load_tags_program();
2317        let mut story = Story::<crate::FastRng>::new(&program, line_tables);
2318        let lines = story.continue_maximally().unwrap();
2319        // The first line should have both tags.
2320        let first = lines.first().expect("expected at least one line");
2321        assert!(
2322            !matches!(first, Line::Choices { .. }),
2323            "expected Text or End, got Choices"
2324        );
2325        assert_eq!(first.tags(), &["author: Joe", "title: My Great Story"],);
2326    }
2327
2328    #[test]
2329    fn choice_exposes_tags() {
2330        let (program, line_tables) = load_tags_in_choice_program();
2331        let mut story = Story::new(&program, line_tables);
2332        let choices = step_until_choices(&mut story);
2333        assert!(!choices.is_empty());
2334        // The choice in tagsInChoice has tags "one" and "two"
2335        assert!(
2336            !choices[0].tags.is_empty(),
2337            "choice should have tags, got: {choices:?}"
2338        );
2339    }
2340
2341    // ── Thread support ──────────────────────────────────────────────────
2342
2343    fn load_i091_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2344        let json_str =
2345            std::fs::read_to_string("../../tests/tier1/choices/I091-choice-count/story.ink.json")
2346                .unwrap();
2347        let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2348        let data = brink_converter::convert(&ink).unwrap();
2349        link(&data).unwrap()
2350    }
2351
2352    /// `<- choices` (thread) must create choices AND return to the main
2353    /// flow so that `CHOICE_COUNT()` can evaluate. The thread body
2354    /// should be called like a tunnel — when its container stack empties,
2355    /// execution returns to the caller. Non-root frames must always pop
2356    /// back to their caller, even when pending choices exist.
2357    #[test]
2358    fn thread_call_returns_to_main_flow() {
2359        let (program, line_tables) = load_i091_program();
2360        let mut story = Story::<crate::FastRng>::new(&program, line_tables);
2361
2362        let lines = story.continue_maximally().unwrap();
2363        // I091 should output "2\n" (CHOICE_COUNT) then present 2 choices.
2364        let full_text: String = lines.iter().map(Line::text).collect();
2365        assert!(
2366            full_text.starts_with('2'),
2367            "output should start with '2' from CHOICE_COUNT(), got: {full_text:?}"
2368        );
2369        let last = lines.last().expect("expected at least one line");
2370        match last {
2371            Line::Choices { choices, .. } => {
2372                assert_eq!(choices.len(), 2, "expected 2 choices");
2373            }
2374            other => panic!("expected Choices, got {other:?}"),
2375        }
2376    }
2377}