Skip to main content

brink_runtime/
story.rs

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