Skip to main content

brink_runtime/story/
flow_instance.rs

1//! [`FlowInstance`] — a single independent execution context within a
2//! story, and the low-level orchestration entry points documented in
3//! `CLAUDE.md`'s "Runtime public API" (`advance`/`begin_function_eval`/etc.).
4
5use alloc::borrow::ToOwned;
6use alloc::collections::BTreeMap;
7use alloc::format;
8use alloc::string::{String, ToString};
9use alloc::vec;
10use alloc::vec::Vec;
11
12use brink_format::{DefinitionId, PluralResolver, Value};
13
14use crate::error::{RanOutOfContentCause, RuntimeError};
15use crate::output::OutputBuffer;
16use crate::program::Program;
17use crate::rng::StoryRng;
18use crate::state::ContextAccess;
19use crate::vm;
20use crate::world::{ResolvedPolicy, World};
21
22use super::call_stack::{
23    CallFrame, CallFrameType, CallStack, ChoiceDisplay, ContainerPosition, ExecMode, Flow,
24    PendingTerminal, Thread,
25};
26use super::external::{ExternalFnHandler, ExternalResult, FunctionEval};
27use super::types::{BlockId, Choice, Element, OutputLine, Stats, Step, StepOutcome, StoryStatus};
28
29// ── FlowInstance ────────────────────────────────────────────────────────────
30
31/// A single independent execution context within a story. The default flow
32/// runs from the root container; named flows can be spawned at arbitrary
33/// entry points via [`FlowInstance::new_at`].
34///
35/// A `FlowInstance` is opaque from outside the crate: its internal fields
36/// (`flow`, `status`, `stats`) are crate-private, but consumers can hold,
37/// clone, serialize, and pass `&mut FlowInstance` to the runtime's step
38/// functions. Use the inherent methods ([`step_single_line`](Self::step_single_line),
39/// [`choose`](Self::choose), [`transcript`](Self::transcript),
40/// [`status`](Self::status), etc.) for all interaction.
41#[derive(Clone, Debug)]
42pub struct FlowInstance {
43    pub(crate) flow: Flow,
44    pub(crate) status: StoryStatus,
45    pub(crate) stats: Stats,
46    /// Transient state for an in-progress engine→ink function evaluation
47    /// ([`begin_function_eval`](Self::begin_function_eval)). `Some` only
48    /// while a from-game call is mid-flight (possibly paused on an
49    /// external); `None` during normal play. Not meaningful to persist.
50    pub(crate) eval: Option<EvalState>,
51    /// Whether host **semantic** access to `#@private` definitions is
52    /// refused on this flow instance (M-2b, `docs/modules-spec.md` §4
53    /// boundary rule 2/3). `true` by default. Mirrors
54    /// [`Story`]'s own flag for consumers — `bevy-brink`'s per-entity
55    /// orchestration, [`crate::Speculation`] — that drive a `FlowInstance`
56    /// directly, bypassing `Story` entirely. [`Story`] keeps every
57    /// `FlowInstance` it owns (`default`, named, shared) synced to its own
58    /// flag via [`Story::set_visibility_enforcement`](crate::Story::set_visibility_enforcement),
59    /// so the two never diverge for story-owned flows.
60    pub(crate) enforce_visibility: bool,
61}
62
63/// Bookkeeping for an in-progress engine→ink function evaluation.
64#[derive(Debug, Clone)]
65pub(crate) struct EvalState {
66    /// Value-stack length recorded before arguments were pushed, so the
67    /// return value (and any leftover args) can be reclaimed on return.
68    pub value_floor: usize,
69    /// Pending-choice count when the eval began. A function that *grows*
70    /// this presented a choice — illegal, and distinct from choices the
71    /// main story may already have waiting.
72    pub choice_floor: usize,
73}
74
75/// Outcome of a single [`FlowInstance::drive`] call: either the drive
76/// reached a terminal step, or it paused on a deferred external mid-drive.
77/// Both variants carry every [`Step`] produced during *this* call — for
78/// `AwaitingExternal`, that's the (possibly empty) run of `Step::Line`
79/// produced before the pause; for `Terminal`, the terminal step is always
80/// the last element (see [`FlowInstance::drive`]).
81#[derive(Debug, Clone)]
82pub enum DriveOutcome {
83    /// Reached a terminal step ([`Step::Done`], [`Step::Choices`], or
84    /// [`Step::End`]) — always the last element of the `Vec`.
85    Terminal(Vec<Step>),
86    /// Paused on a deferred external
87    /// ([`ExternalResult::Pending`](crate::ExternalResult::Pending)).
88    /// Resolve it ([`FlowInstance::resolve_external`]) and call
89    /// [`FlowInstance::drive`] again — with the **same** `budget` — to
90    /// resume.
91    AwaitingExternal(Vec<Step>),
92}
93
94impl FlowInstance {
95    /// Create a new flow instance starting at the program's root container,
96    /// along with a fresh [`World`] initialized from the program's global
97    /// defaults.
98    pub fn new_at_root(program: &Program) -> (Self, World) {
99        Self::new_at(program, program.root_idx())
100    }
101
102    /// Create a new flow instance starting at an arbitrary container index,
103    /// along with a fresh [`World`]. Use this to spawn a named flow at a
104    /// specific entry point. The caller is responsible for deciding whether
105    /// to share the returned `World` with other flows or discard it and
106    /// reuse an existing one.
107    pub fn new_at(program: &Program, container_idx: u32) -> (Self, World) {
108        let globals = program.global_defaults();
109        let initial_frame = CallFrame {
110            return_address: None,
111            temps: Vec::new(),
112            container_stack: vec![ContainerPosition {
113                container_idx,
114                offset: 0,
115            }],
116            frame_type: CallFrameType::Root,
117            external_fn_id: None,
118            function_output_start: None,
119        };
120        let initial_thread = Thread {
121            call_stack: CallStack::new(initial_frame),
122        };
123        let flow_instance = Self {
124            flow: Flow {
125                threads: vec![initial_thread],
126                value_stack: Vec::new(),
127                output: OutputBuffer::new(),
128                pending_choices: Vec::new(),
129                current_tags: Vec::new(),
130                in_tag: false,
131                skipping_choice: false,
132                did_safe_exit: false,
133                did_unsafe_yield: false,
134                ran_out_of_content_cause: RanOutOfContentCause::default(),
135                exec_mode: ExecMode::default(),
136                pure_callback: crate::story::PureCallbackState::default(),
137                next_block_id: 0,
138                pending_terminal: PendingTerminal::default(),
139            },
140            status: StoryStatus::Active,
141            stats: Stats::default(),
142            eval: None,
143            enforce_visibility: true,
144        };
145        // All existing construction paths default to the all-`World`
146        // policy (see `docs/scoped-flow-state-spec.md` "The policy") — this
147        // is the fast path that needs no `Program` symbol lookups and
148        // can't fail, so `new_at`/`new_at_root` keep their infallible
149        // `(Self, World)` signature.
150        let world = World::from_globals(globals, ResolvedPolicy::all_world());
151        (flow_instance, world)
152    }
153
154    /// Enable or disable host visibility enforcement on this flow instance
155    /// (M-2b, `docs/modules-spec.md` §4 boundary rule 3). Enforcement is
156    /// **on** by default: [`choose_path_string`](Self::choose_path_string)/
157    /// [`choose_path_string_with_args`](Self::choose_path_string_with_args)
158    /// into a `#@private` knot/stitch, and
159    /// [`begin_function_eval`](Self::begin_function_eval)/
160    /// [`begin_function_value_eval`](Self::begin_function_value_eval) of a
161    /// `#@private` function, return [`RuntimeError::PrivateAccess`].
162    ///
163    /// This mirrors [`Story::set_visibility_enforcement`](crate::Story::set_visibility_enforcement)
164    /// for consumers that drive a `FlowInstance` directly — `bevy-brink`'s
165    /// per-entity orchestration and [`crate::Speculation`] — rather than
166    /// through a [`Story`](crate::Story). A `Story` keeps every
167    /// `FlowInstance` it owns synced to its own flag when this is called on
168    /// the `Story`, so callers that only ever go through `Story` never need
169    /// to call this directly.
170    pub fn set_visibility_enforcement(&mut self, enforce: bool) {
171        self.enforce_visibility = enforce;
172    }
173
174    /// Whether host visibility enforcement is currently on for this flow
175    /// instance (default `true`).
176    #[must_use]
177    pub fn visibility_enforced(&self) -> bool {
178        self.enforce_visibility
179    }
180
181    /// Set the dev/prod execution mode on this flow instance (NS-A4,
182    /// [`ExecMode`] — see its docs for the §4b doctrine). Mirrors
183    /// [`Story::set_exec_mode`](crate::Story::set_exec_mode) for consumers
184    /// that drive a `FlowInstance` directly (`bevy-brink`,
185    /// [`crate::Speculation`]). Takes effect immediately — the mode is
186    /// consulted at each ordering-verb execution.
187    pub fn set_exec_mode(&mut self, mode: ExecMode) {
188        self.flow.exec_mode = mode;
189    }
190
191    /// The current dev/prod execution mode (NS-A4, [`ExecMode`]).
192    #[must_use]
193    pub fn exec_mode(&self) -> ExecMode {
194        self.flow.exec_mode
195    }
196
197    /// Maximum VM steps per `continue_maximally` call before erroring.
198    /// Prevents infinite loops from malformed bytecode.
199    const STEP_LIMIT: u64 = 1_000_000;
200
201    /// Execute until one complete line of output is available, or until a
202    /// yield point (choices/done/ended) if no newline occurs first.
203    ///
204    /// Returns a [`Step`] telling the caller what happened (`Line`/`Done`/
205    /// `Choices`/`End`). This is the simple API for consumers whose
206    /// external handler never defers: if the handler returns
207    /// [`ExternalResult::Pending`], this errors with
208    /// [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall).
209    /// For pausable world-access bindings, use [`advance`](Self::advance).
210    pub fn step_single_line<R: StoryRng>(
211        &mut self,
212        program: &Program,
213        line_tables: &[Vec<brink_format::LineEntry>],
214        context: &mut (impl ContextAccess + ?Sized),
215        handler: &dyn ExternalFnHandler,
216        resolver: Option<&dyn PluralResolver>,
217    ) -> Result<Step, RuntimeError> {
218        match self.advance::<R>(program, line_tables, context, handler, resolver)? {
219            StepOutcome::Step(step) => Ok(step),
220            StepOutcome::AwaitingExternal => {
221                // Preserve historical behavior for consumers using this
222                // (non-pausing) API: a deferred external they can't resolve
223                // is an error.
224                let id = self
225                    .flow
226                    .external_fn_id()
227                    .ok_or(RuntimeError::CallStackUnderflow)?;
228                Err(RuntimeError::UnresolvedExternalCall(id))
229            }
230        }
231    }
232
233    /// Maximum lines produced by a single [`drive_to_terminal`](Self::drive_to_terminal)
234    /// call before erroring. Safety net against infinite loops from
235    /// malformed bytecode.
236    pub const LINE_LIMIT: usize = 10_000;
237
238    /// Step this flow forward until the next terminal step (`Done`,
239    /// `Choices`, or `End`), collecting every [`Step`] produced along the
240    /// way.
241    ///
242    /// This is the single Layer-2 "drive to terminal" loop: [`Story`]'s
243    /// `continue_maximally*` family is a thin wrapper over it, and any other
244    /// holder of a `FlowInstance` (e.g. an engine integration like
245    /// `bevy-brink`) should reach for this instead of hand-rolling the same
246    /// loop. Semantics:
247    ///
248    /// - Steps via [`step_single_line`](Self::step_single_line): a deferred
249    ///   external ([`ExternalResult::Pending`]) is **not** paused on here —
250    ///   it errors with [`RuntimeError::UnresolvedExternalCall`], exactly as
251    ///   `step_single_line` does. Callers that need to pause on world-access
252    ///   externals mid-drive should drive [`advance`](Self::advance)
253    ///   themselves rather than use this method.
254    /// - Stops at the first [`Step`] for which [`Step::is_terminal`] returns
255    ///   `true`; that step is always the last element of the returned
256    ///   `Vec`, and every element before it is a [`Step::Line`].
257    /// - Bounded by [`Self::LINE_LIMIT`] (10,000) lines produced in a single
258    ///   call; exceeding it returns [`RuntimeError::LineLimitExceeded`]
259    ///   rather than looping forever.
260    ///
261    /// # Errors
262    /// Any error [`step_single_line`](Self::step_single_line) itself can
263    /// produce, plus [`RuntimeError::LineLimitExceeded`] if the drive
264    /// produces [`Self::LINE_LIMIT`] lines without reaching a terminal one.
265    pub fn drive_to_terminal<R: StoryRng>(
266        &mut self,
267        program: &Program,
268        line_tables: &[Vec<brink_format::LineEntry>],
269        context: &mut (impl ContextAccess + ?Sized),
270        handler: &dyn ExternalFnHandler,
271        resolver: Option<&dyn PluralResolver>,
272    ) -> Result<Vec<Step>, RuntimeError> {
273        let mut steps = Vec::new();
274        loop {
275            let step =
276                self.step_single_line::<R>(program, line_tables, context, handler, resolver)?;
277            let terminal = step.is_terminal();
278            steps.push(step);
279            if terminal {
280                return Ok(steps);
281            }
282            if steps.len() >= Self::LINE_LIMIT {
283                return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
284            }
285        }
286    }
287
288    /// The pausable Layer-2 "drive to terminal or external pause" op:
289    /// [`drive_to_terminal`](Self::drive_to_terminal)'s sibling for callers
290    /// (e.g. `bevy-brink`) whose external bindings need to pause mid-drive
291    /// for out-of-band (world-access) resolution rather than erroring.
292    ///
293    /// Steps via [`advance`](Self::advance) instead of
294    /// [`step_single_line`](Self::step_single_line): a deferred external
295    /// yields [`DriveOutcome::AwaitingExternal`] (carrying every line
296    /// produced so far this call) instead of
297    /// [`RuntimeError::UnresolvedExternalCall`]. Resolve it and call `drive`
298    /// again to continue — the drive is logically one operation spanning
299    /// however many pauses it takes.
300    ///
301    /// `budget` is the caller-owned line budget for that whole logical
302    /// operation: each line `drive` produces (whether the call ends in
303    /// `Terminal` or `AwaitingExternal`) decrements it by one, and it is
304    /// **not** reset between calls — the caller passes the same `&mut
305    /// usize` back in on resume, so a drive spanning many external pauses
306    /// still has exactly one bound on total output, not a fresh
307    /// [`Self::LINE_LIMIT`] per resume (see the "guard against unbounded
308    /// growth" rule). Start a fresh logical drive with a fresh
309    /// `budget = FlowInstance::LINE_LIMIT` (or any caller-chosen cap).
310    ///
311    /// Like `drive_to_terminal`, the terminal step is always the last
312    /// element of the returned `Vec` and every step before it is
313    /// [`Step::Line`].
314    ///
315    /// # Errors
316    /// Any error [`advance`](Self::advance) itself can produce, plus
317    /// [`RuntimeError::LineLimitExceeded`] if `budget` reaches zero before a
318    /// terminal step is produced.
319    pub fn drive<R: StoryRng>(
320        &mut self,
321        program: &Program,
322        line_tables: &[Vec<brink_format::LineEntry>],
323        context: &mut (impl ContextAccess + ?Sized),
324        handler: &dyn ExternalFnHandler,
325        resolver: Option<&dyn PluralResolver>,
326        budget: &mut usize,
327    ) -> Result<DriveOutcome, RuntimeError> {
328        // Captured only to report a meaningful number on exhaustion: the
329        // remaining budget *this call* started with, not the (possibly
330        // already-partially-spent, across earlier resumes) original cap.
331        let starting_budget = *budget;
332        let mut steps = Vec::new();
333        loop {
334            if *budget == 0 {
335                return Err(RuntimeError::LineLimitExceeded(starting_budget));
336            }
337            match self.advance::<R>(program, line_tables, context, handler, resolver)? {
338                StepOutcome::AwaitingExternal => return Ok(DriveOutcome::AwaitingExternal(steps)),
339                StepOutcome::Step(step) => {
340                    let terminal = step.is_terminal();
341                    *budget -= 1;
342                    steps.push(step);
343                    if terminal {
344                        return Ok(DriveOutcome::Terminal(steps));
345                    }
346                }
347            }
348        }
349    }
350
351    /// Like [`step_single_line`](Self::step_single_line), but surfaces a
352    /// deferred external ([`ExternalResult::Pending`]) as
353    /// [`StepOutcome::AwaitingExternal`] instead of an error — so a
354    /// world-access binding hit during normal playback can pause cleanly.
355    /// Resolve the pending external and call `advance` again to continue.
356    pub fn advance<R: StoryRng>(
357        &mut self,
358        program: &Program,
359        line_tables: &[Vec<brink_format::LineEntry>],
360        context: &mut (impl ContextAccess + ?Sized),
361        handler: &dyn ExternalFnHandler,
362        resolver: Option<&dyn PluralResolver>,
363    ) -> Result<StepOutcome, RuntimeError> {
364        self.advance_with_limit::<R>(
365            program,
366            line_tables,
367            context,
368            handler,
369            resolver,
370            Self::STEP_LIMIT,
371        )
372    }
373
374    /// Like [`advance`](Self::advance), but the per-call VM step budget is
375    /// `step_limit` rather than the hardcoded [`Self::STEP_LIMIT`].
376    ///
377    /// This is what lets [`crate::Speculation::advance`] cap a single
378    /// visible-line drive at a small, caller-supplied budget instead of the
379    /// production 1,000,000-step ceiling, so a runaway speculative probe
380    /// errors quickly instead of burning a huge step budget before giving
381    /// up. `advance` itself is a thin wrapper over this with
382    /// `step_limit: Self::STEP_LIMIT` — every existing call site keeps its
383    /// exact prior behavior.
384    #[expect(clippy::too_many_lines)]
385    pub(crate) fn advance_with_limit<R: StoryRng>(
386        &mut self,
387        program: &Program,
388        line_tables: &[Vec<brink_format::LineEntry>],
389        context: &mut (impl ContextAccess + ?Sized),
390        handler: &dyn ExternalFnHandler,
391        resolver: Option<&dyn PluralResolver>,
392        step_limit: u64,
393    ) -> Result<StepOutcome, RuntimeError> {
394        // 0. A terminal was computed on the previous call but held back
395        //    because its trailing content had to go out first as its own
396        //    `Step::Line` (terminals carry no text — §7). Deliver it now,
397        //    bare, with no VM stepping — but only if no new run has begun
398        //    since it was stashed (`PendingTerminal`'s invalidation
399        //    invariant, #2104): a host-directed jump or choice between the
400        //    two calls bumps `next_block_id`, and `take_if_current` silently
401        //    drops a stash stamped with a now-stale block id instead of
402        //    replaying it.
403        if let Some(pending) = self
404            .flow
405            .pending_terminal
406            .take_if_current(self.flow.next_block_id)
407        {
408            return Ok(StepOutcome::Step(pending));
409        }
410
411        // 1. If buffer already has a completed line from a previous step,
412        //    take it immediately (no VM stepping needed).
413        if self.flow.output.has_completed_line()
414            && let Some((text, tags, element)) =
415                self.flow
416                    .output
417                    .take_first_line(program, line_tables, resolver)
418        {
419            return Ok(StepOutcome::Step(make_output_line(
420                &self.flow, text, tags, element,
421            )));
422        }
423
424        // 2. If buffer has partial content but VM has already yielded
425        //    (any non-Active state), flush it. At a yield point, no more
426        //    output is coming, so trailing Newlines are committed.
427        if self.flow.output.has_unread() && self.status != StoryStatus::Active {
428            let (text, tags, element) =
429                flush_remaining(&mut self.flow, program, line_tables, resolver);
430            return Ok(StepOutcome::Step(yield_step(
431                self.status,
432                text,
433                tags,
434                element,
435                &mut self.flow,
436                program,
437                line_tables,
438                resolver,
439            )));
440        }
441
442        // 3. Status checks.
443        if self.status == StoryStatus::Ended {
444            return Err(RuntimeError::StoryEnded);
445        }
446        if self.status == StoryStatus::WaitingForChoice {
447            return Err(RuntimeError::NotWaitingForChoice);
448        }
449
450        // 4. Reset Done → Active (resuming after output).
451        //    If the previous cycle ended without a safe exit (no explicit
452        //    -> DONE opcode), the story ran out of content. The previous
453        //    call delivered the text — error now.
454        if self.status == StoryStatus::Done {
455            if !self.flow.did_safe_exit {
456                return Err(RuntimeError::RanOutOfContent(
457                    self.flow.ran_out_of_content_cause,
458                ));
459            }
460            self.status = StoryStatus::Active;
461            // A fresh run begins wherever the story resumes from `Done`.
462            self.flow.next_block_id += 1;
463        }
464
465        // Clear flags — will be set during this cycle if relevant.
466        self.flow.did_safe_exit = false;
467        self.flow.did_unsafe_yield = false;
468
469        // 5. Step VM loop.
470        let Self {
471            flow,
472            status,
473            stats,
474            ..
475        } = self;
476        let step_start = stats.steps;
477
478        loop {
479            stats.steps += 1;
480
481            if stats.steps - step_start > step_limit {
482                return Err(RuntimeError::StepLimitExceeded(step_limit));
483            }
484
485            let stepped = vm::step::<R>(flow, program, line_tables, context, stats, resolver)?;
486            stats.materializations += flow.drain_materializations();
487
488            match stepped {
489                vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {
490                    if flow.output.has_completed_line()
491                        && let Some((text, tags, element)) =
492                            flow.output.take_first_line(program, line_tables, resolver)
493                    {
494                        return Ok(StepOutcome::Step(make_output_line(
495                            flow, text, tags, element,
496                        )));
497                    }
498                }
499
500                vm::Stepped::ExternalCall => {
501                    // `false` means the handler deferred (Pending): pause
502                    // cleanly so the caller can resolve it out-of-band.
503                    if !resolve_external_call(flow, program, handler)? {
504                        return Ok(StepOutcome::AwaitingExternal);
505                    }
506                    if flow.output.has_completed_line()
507                        && let Some((text, tags, element)) =
508                            flow.output.take_first_line(program, line_tables, resolver)
509                    {
510                        return Ok(StepOutcome::Step(make_output_line(
511                            flow, text, tags, element,
512                        )));
513                    }
514                }
515
516                vm::Stepped::Done => {
517                    context.increment_turn_index();
518
519                    // Handle invisible default choices: auto-select and keep running.
520                    if !flow.pending_choices.is_empty() {
521                        let all_invisible = flow
522                            .pending_choices
523                            .iter()
524                            .all(|pc| pc.flags.is_invisible_default);
525                        if all_invisible {
526                            select_choice(flow, context, status, stats, 0)?;
527                            if flow.output.has_completed_line()
528                                && let Some((text, tags, element)) =
529                                    flow.output.take_first_line(program, line_tables, resolver)
530                            {
531                                return Ok(StepOutcome::Step(make_output_line(
532                                    flow, text, tags, element,
533                                )));
534                            }
535                            continue;
536                        }
537                    }
538
539                    // Set status based on remaining choices.
540                    if flow.pending_choices.is_empty() {
541                        *status = StoryStatus::Done;
542                    } else {
543                        *status = StoryStatus::WaitingForChoice;
544                        stats.choices_presented += 1;
545                    }
546
547                    if flow.output.has_completed_line()
548                        && let Some((text, tags, element)) =
549                            flow.output.take_first_line(program, line_tables, resolver)
550                    {
551                        return Ok(StepOutcome::Step(make_output_line(
552                            flow, text, tags, element,
553                        )));
554                    }
555
556                    let (text, tags, element) =
557                        flush_remaining(flow, program, line_tables, resolver);
558                    return Ok(StepOutcome::Step(yield_step(
559                        *status,
560                        text,
561                        tags,
562                        element,
563                        flow,
564                        program,
565                        line_tables,
566                        resolver,
567                    )));
568                }
569
570                vm::Stepped::Ended => {
571                    context.increment_turn_index();
572                    *status = StoryStatus::Ended;
573
574                    if flow.output.has_completed_line()
575                        && let Some((text, tags, element)) =
576                            flow.output.take_first_line(program, line_tables, resolver)
577                    {
578                        return Ok(StepOutcome::Step(make_output_line(
579                            flow, text, tags, element,
580                        )));
581                    }
582
583                    let (text, tags, element) =
584                        flush_remaining(flow, program, line_tables, resolver);
585                    return Ok(StepOutcome::Step(yield_step(
586                        *status,
587                        text,
588                        tags,
589                        element,
590                        flow,
591                        program,
592                        line_tables,
593                        resolver,
594                    )));
595                }
596            }
597        }
598    }
599
600    /// Select a choice by index. Call [`step_single_line`](Self::step_single_line)
601    /// afterward to continue execution from the chosen branch.
602    pub fn choose(
603        &mut self,
604        context: &mut (impl ContextAccess + ?Sized),
605        index: usize,
606    ) -> Result<(), RuntimeError> {
607        if self.status != StoryStatus::WaitingForChoice {
608            return Err(RuntimeError::NotWaitingForChoice);
609        }
610        select_choice(
611            &mut self.flow,
612            context,
613            &mut self.status,
614            &mut self.stats,
615            index,
616        )
617    }
618
619    /// Move the play head to a named knot/stitch path — the equivalent of
620    /// ink's `Story.ChoosePathString(path)` (with its default
621    /// `resetCallstack: true`). Call [`step_single_line`](Self::step_single_line)
622    /// (or any continue method) afterward to run from there.
623    ///
624    /// `path` is a dot-separated runtime path: a knot (`intro`), a qualified
625    /// stitch (`intro.dock`), or — for programs compiled by `brink-compiler` —
626    /// an author label (`knot.label`, `knot.stitch.label`; an extension over
627    /// C#, which cannot address labels).
628    ///
629    /// Mirroring the C# reference (`Story.ChoosePathString` →
630    /// `ResetCallstack`/`ForceEnd` → `ChoosePath` → `state.SetChosenPath` +
631    /// `VisitChangedContainersDueToDivert`):
632    ///
633    /// - The current flow is **force-completed** first: the call stack
634    ///   collapses to a single fresh root frame (abandoning any tunnels,
635    ///   threads, or in-progress weave), pending choices are cleared, and
636    ///   the jump counts as a safe exit (as if the story had hit `-> DONE`).
637    /// - The jump **counts as a visit** to the target, with exactly the
638    ///   semantics of an in-story `-> path` divert (it goes through the same
639    ///   goto machinery, so counting flags are honored identically).
640    /// - Output already produced but not yet consumed is **kept** (C# leaves
641    ///   the output stream untouched); it is delivered before content from
642    ///   the new location. The value stack is likewise left as-is.
643    /// - A permanently **ended** story (`-> END`) may be re-entered by
644    ///   jumping, matching C# where `ChoosePathString` + `Continue` works
645    ///   after the story has ended.
646    ///
647    /// # Errors
648    /// - [`UnknownPath`](RuntimeError::UnknownPath) if `path` resolves to no
649    ///   target (the message names the path).
650    /// - [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
651    ///   if the flow is parked on an unresolved external call — a pending
652    ///   host call must be resolved, not silently abandoned.
653    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
654    ///   if an engine→ink function evaluation is in progress (C# likewise
655    ///   refuses to redirect mid-function).
656    pub fn choose_path_string(
657        &mut self,
658        program: &Program,
659        context: &mut (impl ContextAccess + ?Sized),
660        path: &str,
661    ) -> Result<(), RuntimeError> {
662        self.choose_path_string_with_args(program, context, path, &[])
663    }
664
665    /// Like [`choose_path_string`](Self::choose_path_string) but **binds the
666    /// target knot's declared parameters** from `args` — host-directed entry
667    /// into a parameterized knot/stitch (`=== call(action, present) ===`),
668    /// which a plain path jump can't reach with its params bound.
669    ///
670    /// Semantics are otherwise identical to `choose_path_string` (force-ends
671    /// the current flow, counts as a visit, etc.). The args are pushed onto the
672    /// value stack in declaration order and bound by the target's prologue —
673    /// exactly as an in-story `-> call(a, b)` divert binds them, so this enters
674    /// at the container start (where the prologue runs).
675    ///
676    /// # Errors
677    /// In addition to [`choose_path_string`](Self::choose_path_string)'s errors:
678    /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) if `args.len()`
679    /// differs from the target container's declared parameter count. (Programs
680    /// built by the converter record no param counts, so they report `0` — pass
681    /// no args.)
682    pub fn choose_path_string_with_args(
683        &mut self,
684        program: &Program,
685        context: &mut (impl ContextAccess + ?Sized),
686        path: &str,
687        args: &[Value],
688    ) -> Result<(), RuntimeError> {
689        // M-2b: refuse host-driven entry into a `#@private` knot/stitch
690        // while visibility enforcement is on (`docs/modules-spec.md` §4
691        // boundary rule 2). Mirrors `Story`'s own `check_entry_visibility`
692        // for callers that drive this `FlowInstance` directly — `bevy-brink`,
693        // `Speculation` — without going through `Story`. Checked before any
694        // other error path so a private name reports as private, not as
695        // "not found" or "awaiting external".
696        if self.enforce_visibility && program.has_private_defs() && program.path_is_private(path) {
697            return Err(RuntimeError::PrivateAccess {
698                name: path.to_owned(),
699            });
700        }
701        // A parked host call cannot be silently abandoned: erroring is the
702        // strictest safe behavior (brink-specific — C# has no pausable
703        // externals during normal playback).
704        if let Some(id) = self.flow.external_fn_id() {
705            let external = program
706                .external_fn(id)
707                .map_or_else(|| format!("{id}"), |e| program.name(e.name).to_owned());
708            return Err(RuntimeError::JumpWhileAwaitingExternal {
709                path: path.to_owned(),
710                external,
711            });
712        }
713        // An in-flight engine→ink evaluation (possibly paused on an external)
714        // must finish or be aborted before the flow can be redirected.
715        if self.eval.is_some() {
716            return Err(RuntimeError::AlreadyEvaluatingFunction);
717        }
718
719        let target_id = program
720            .find_path_target(path)
721            .ok_or_else(|| RuntimeError::UnknownPath(path.to_owned()))?;
722
723        // Arity-check before mutating any state. The target container's
724        // declared param count is what its prologue's `DeclareTemp`s will pop.
725        let expected = program.path_param_count(path).unwrap_or(0);
726        if args.len() != expected as usize {
727            return Err(RuntimeError::ArgCountMismatch {
728                target: path.to_owned(),
729                expected,
730                got: args.len(),
731            });
732        }
733
734        // Force-end the current flow, mirroring C# `ResetCallstack` →
735        // `StoryState.ForceEnd`: a single fresh root frame (callStack.Reset),
736        // cleared choices, null pointers (the empty container stack), and
737        // didSafeExit = true. The output buffer and value stack are
738        // deliberately left untouched — C# `ForceEnd` does not clear the
739        // output stream or the evaluation stack.
740        let root_frame = CallFrame {
741            return_address: None,
742            temps: Vec::new(),
743            container_stack: Vec::new(),
744            frame_type: CallFrameType::Root,
745            external_fn_id: None,
746            function_output_start: None,
747        };
748        self.flow.threads = vec![Thread {
749            call_stack: CallStack::new(root_frame),
750        }];
751        self.flow.pending_choices.clear();
752        // No explicit pending-terminal clear needed here: `next_block_id`'s
753        // bump below (a fresh run begins at the jump target) is exactly
754        // what invalidates any stash from before the jump — see
755        // `PendingTerminal`'s doc comment. The host explicitly redirected
756        // execution, so the next `advance`/`step_single_line` call correctly
757        // steps the VM at the new target rather than handing back a stale
758        // `Done`/`Choices`/`End` left over from before the jump.
759        // Transient intra-step flags. Both are false at any point a host can
760        // observe (between lines / at a yield), but the jump abandons whatever
761        // produced them, so clear defensively.
762        self.flow.skipping_choice = false;
763        self.flow.in_tag = false;
764        self.flow.did_safe_exit = true;
765        // The jump force-completes the current flow like `-> DONE` (see
766        // this method's own doc comment) — a fresh run begins at the
767        // target (`BlockId`, §3.7/§8d.2).
768        self.flow.next_block_id += 1;
769
770        // Push the arguments in declaration order; the target's prologue
771        // (`DeclareTemp`) binds them, exactly as `begin_function_eval` and an
772        // in-story `-> call(a, b)` divert do.
773        self.flow.value_stack.extend_from_slice(args);
774
775        // Jump via the same divert machinery as an in-story `-> path`
776        // (mirrors C# `ChoosePath` → `SetChosenPath` +
777        // `VisitChangedContainersDueToDivert`): sets the position and
778        // increments the target's visit/turn counts per its counting flags.
779        vm::goto_target(&mut self.flow, program, context, target_id)?;
780
781        self.status = StoryStatus::Active;
782        Ok(())
783    }
784
785    /// The current execution status of this flow.
786    #[must_use]
787    pub fn status(&self) -> StoryStatus {
788        self.status
789    }
790
791    /// Whether the most recent execution cycle ended with a *safe exit* —
792    /// an explicit `-> DONE` opcode — as opposed to falling off the end of
793    /// its content with nothing left to run.
794    ///
795    /// Both cases deliver a terminal [`Step::Done`]; this is the only way
796    /// to tell them apart without issuing an extra `advance`/
797    /// `step_single_line` call and observing whether it returns
798    /// [`RuntimeError::RanOutOfContent`](crate::RuntimeError::RanOutOfContent).
799    /// Read it right after receiving a `Step::Done` — it is cleared at the
800    /// start of the *next* execution cycle, so a value read before a
801    /// terminal step is not meaningful.
802    ///
803    /// `true`: the story chose to stop (a knot/stitch reached `-> DONE`);
804    /// resuming later is well-formed. `false`: the flow ran out of
805    /// content; the trailing text was still delivered, but resuming will
806    /// fault.
807    #[must_use]
808    pub fn did_safe_exit(&self) -> bool {
809        self.flow.did_safe_exit
810    }
811
812    /// Runtime statistics (instructions, materialization counts, etc.)
813    /// accumulated over this flow's execution.
814    #[must_use]
815    pub fn stats(&self) -> &Stats {
816        &self.stats
817    }
818
819    /// The full append-only transcript of all output parts produced so far.
820    ///
821    /// The transcript stores structural references (e.g. `LineRef`) rather
822    /// than resolved strings, so it can be re-rendered in any locale by
823    /// passing a different set of line tables to
824    /// [`transcript::render_transcript`](crate::transcript::render_transcript).
825    #[must_use]
826    pub fn transcript(&self) -> &[crate::output::OutputPart] {
827        self.flow.output.transcript()
828    }
829
830    /// Number of parts in the transcript.
831    #[must_use]
832    pub fn transcript_len(&self) -> usize {
833        self.flow.output.transcript_len()
834    }
835
836    /// Reset the transcript read cursor to the beginning (for re-rendering,
837    /// e.g. after a locale swap).
838    pub fn reset_cursor(&mut self) {
839        self.flow.output.reset_cursor();
840    }
841
842    /// The fragments captured during execution (for re-rendering choice
843    /// display text and computed substrings in a different locale).
844    #[must_use]
845    pub fn fragments(&self) -> &[crate::output::Fragment] {
846        self.flow.output.fragments()
847    }
848
849    // ── External calls (ink → engine) ────────────────────────────────
850
851    /// Returns `true` if this flow is frozen on an unresolved external
852    /// call — i.e. the VM hit a `CallExternal` opcode and the handler
853    /// returned [`ExternalResult::Pending`], leaving the `External` frame
854    /// on top of the call stack.
855    ///
856    /// The orchestration layer (e.g. a Bevy resolver system) polls this to
857    /// decide whether the flow needs an external resolved before it can be
858    /// driven further. Resolve via [`resolve_external`](Self::resolve_external).
859    #[must_use]
860    pub fn has_pending_external(&self) -> bool {
861        self.flow.external_fn_id().is_some()
862    }
863
864    /// The [`DefinitionId`] of the pending external function, if this flow
865    /// is frozen on one. Returns `None` otherwise.
866    #[must_use]
867    pub fn pending_external_fn_id(&self) -> Option<DefinitionId> {
868        self.flow.external_fn_id()
869    }
870
871    /// The arguments to the pending external call, in declaration order.
872    /// Empty if no external call is pending.
873    #[must_use]
874    pub fn pending_external_args(&self) -> &[Value] {
875        self.flow.external_args()
876    }
877
878    /// The ink-declared name of the pending external function, resolved
879    /// against `program`'s name table. Returns `None` if no external is
880    /// pending (or the entry is missing, which would indicate a malformed
881    /// program).
882    ///
883    /// The orchestration layer uses this to look up the binding registered
884    /// for this name.
885    #[must_use]
886    pub fn pending_external_name<'p>(&self, program: &'p Program) -> Option<&'p str> {
887        let id = self.flow.external_fn_id()?;
888        let entry = program.external_fn(id)?;
889        Some(program.name(entry.name))
890    }
891
892    /// Resolve a pending external call by supplying its return value. Pops
893    /// the `External` frame and pushes `value` onto the value stack so the
894    /// VM can resume. For fire-and-forget externals, pass [`Value::Null`].
895    ///
896    /// No-op if no external call is pending. After resolving, drive the
897    /// flow forward with [`step_single_line`](Self::step_single_line).
898    pub fn resolve_external(&mut self, value: Value) {
899        self.flow.resolve_external(value);
900    }
901
902    // ── Engine → ink calls ───────────────────────────────────────────
903
904    /// Evaluate an ink function from engine code, returning its value.
905    ///
906    /// This does **not** advance the player-visible story: a
907    /// `FunctionEvalFromGame` boundary frame is pushed, `args` are passed
908    /// in declaration order (exactly as a normal call site would), output
909    /// is captured and discarded, and the function runs until it returns.
910    ///
911    /// If the function calls an external whose handler returns
912    /// [`ExternalResult::Pending`] (e.g. a binding that needs Bevy World
913    /// access), evaluation pauses and returns
914    /// [`FunctionEval::AwaitingExternal`]; the caller resolves the
915    /// external (see [`resolve_external`](Self::resolve_external)) and
916    /// calls [`resume_function_eval`](Self::resume_function_eval).
917    ///
918    /// `container_idx` is the function's container, typically obtained from
919    /// [`Program::find_address`](crate::Program::find_address) on the
920    /// function name. Unlike a normal `Call`, this does not increment the
921    /// function's visit count — an engine query is out-of-band, matching
922    /// C#'s `EvaluateFunction`.
923    ///
924    /// # Errors
925    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
926    ///   if a function evaluation is already in progress on this flow.
927    /// - [`FunctionYielded`](RuntimeError::FunctionYielded) if the function
928    ///   presents choices or ends the story (functions must not yield).
929    /// - [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall)
930    ///   if an external has neither a binding nor a fallback.
931    #[expect(
932        clippy::too_many_arguments,
933        reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target and args"
934    )]
935    pub fn begin_function_eval<R: StoryRng>(
936        &mut self,
937        program: &Program,
938        line_tables: &[Vec<brink_format::LineEntry>],
939        context: &mut (impl ContextAccess + ?Sized),
940        handler: &dyn ExternalFnHandler,
941        container_idx: u32,
942        args: &[Value],
943        resolver: Option<&dyn PluralResolver>,
944    ) -> Result<FunctionEval, RuntimeError> {
945        self.begin_function_eval_with_limit::<R>(
946            program,
947            line_tables,
948            context,
949            handler,
950            container_idx,
951            args,
952            resolver,
953            Self::STEP_LIMIT,
954        )
955    }
956
957    /// Like [`begin_function_eval`](Self::begin_function_eval), but the VM
958    /// step budget for the whole evaluation is `step_limit` rather than the
959    /// hardcoded [`Self::STEP_LIMIT`] (#1868).
960    ///
961    /// This is what lets a caller give an engine→ink evaluation its own,
962    /// appropriately scoped budget — e.g. a compile-time registry walk,
963    /// which wants a small ceiling of its own rather than the 1,000,000-step
964    /// production default — mirroring how [`advance_with_limit`](Self::advance_with_limit)
965    /// already lets [`crate::Speculation`] cap the line-stepping path.
966    /// `begin_function_eval` itself is a thin wrapper over this with
967    /// `step_limit: Self::STEP_LIMIT` — every existing call site keeps its
968    /// exact prior behavior.
969    ///
970    /// # Errors
971    /// Same as [`begin_function_eval`](Self::begin_function_eval), plus
972    /// [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now bounded
973    /// by the caller-supplied `step_limit` rather than the fixed default.
974    #[expect(
975        clippy::too_many_arguments,
976        reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target, args, and step_limit"
977    )]
978    pub fn begin_function_eval_with_limit<R: StoryRng>(
979        &mut self,
980        program: &Program,
981        line_tables: &[Vec<brink_format::LineEntry>],
982        context: &mut (impl ContextAccess + ?Sized),
983        handler: &dyn ExternalFnHandler,
984        container_idx: u32,
985        args: &[Value],
986        resolver: Option<&dyn PluralResolver>,
987        step_limit: u64,
988    ) -> Result<FunctionEval, RuntimeError> {
989        // M-2b: refuse host-driven evaluation of a `#@private` function
990        // while visibility enforcement is on (`docs/modules-spec.md` §4
991        // boundary rule 2). Mirrors `Story::call_function`'s own check for
992        // callers that drive this `FlowInstance` directly — `bevy-brink`,
993        // `Speculation` — without going through `Story`. The caller
994        // resolves `container_idx` itself (typically via
995        // [`Program::find_address`](crate::Program::find_address) on the
996        // function name), so the error names the definition by its compiled
997        // id rather than the original name string, which isn't available
998        // here.
999        if self.enforce_visibility
1000            && program.has_private_defs()
1001            && program.container_is_private(container_idx)
1002        {
1003            return Err(RuntimeError::PrivateAccess {
1004                name: format!("{}", program.container(container_idx).id),
1005            });
1006        }
1007        if self.eval.is_some() {
1008            return Err(RuntimeError::AlreadyEvaluatingFunction);
1009        }
1010
1011        // Record floors BEFORE pushing args: the value-stack length (so the
1012        // return value and any leftover args can be reclaimed), and the
1013        // pending-choice count (so we can tell a choice the function
1014        // presents from choices the main story already has waiting).
1015        let value_floor = self.flow.value_stack.len();
1016        let choice_floor = self.flow.pending_choices.len();
1017
1018        // Isolate output: anything the function emits routes to the
1019        // capture scratch space and never reaches the transcript.
1020        self.flow.output.begin_capture();
1021
1022        let output_start = self.flow.output.target_len();
1023        let boundary = CallFrame {
1024            return_address: None,
1025            temps: Vec::new(),
1026            container_stack: vec![ContainerPosition {
1027                container_idx,
1028                offset: 0,
1029            }],
1030            frame_type: CallFrameType::FunctionEvalFromGame,
1031            external_fn_id: None,
1032            function_output_start: Some(output_start),
1033        };
1034        self.flow.current_thread_mut().call_stack.push(boundary);
1035        self.stats.frames_pushed += 1;
1036
1037        // Pass arguments onto the value stack in declaration order — the
1038        // function's prologue (`DeclareTemp`) binds them exactly as it
1039        // would for an in-story call.
1040        self.flow.value_stack.extend_from_slice(args);
1041
1042        self.eval = Some(EvalState {
1043            value_floor,
1044            choice_floor,
1045        });
1046        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1047    }
1048
1049    /// Evaluate an ink **function value** (`FnRef`/`Closure`) from engine
1050    /// code — the host callback-invocation surface (T1c-3,
1051    /// `docs/t1c-spec.md` §6). A function value crosses to the host as an
1052    /// opaque token `{DefinitionId, env}`; the host never dereferences the
1053    /// env — invocation always re-enters the VM here and is journaled
1054    /// exactly like [`begin_function_eval`](Self::begin_function_eval).
1055    ///
1056    /// `callee` must be a [`Value::FnRef`] / [`Value::Closure`]; `args`
1057    /// supply the remaining (val-only) params after the value's bound
1058    /// prefix. The same fault set as in-story dispatch applies — non-function
1059    /// callee, wrong arity, rehydration mismatch, cross-flow ref-`#@local`
1060    /// (`docs/t1c-spec.md` §3/§6) — surfaced as the `Err` here rather than as
1061    /// a turn-terminating story fault, since this is out-of-band evaluation.
1062    ///
1063    /// Like [`begin_function_eval`](Self::begin_function_eval) this does not
1064    /// advance the player-visible story (output isolated, transcript
1065    /// untouched, no visit-count increment) and pauses on world-access
1066    /// externals — resume with
1067    /// [`resume_function_eval`](Self::resume_function_eval).
1068    ///
1069    /// # Errors
1070    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1071    ///   if an evaluation is already in progress on this flow.
1072    /// - The function-value dispatch faults above (via
1073    ///   `vm::prepare_fn_value_call`), before any frame is pushed.
1074    /// - The same evaluation errors as
1075    ///   [`begin_function_eval`](Self::begin_function_eval).
1076    #[expect(
1077        clippy::too_many_arguments,
1078        reason = "mirrors begin_function_eval: the VM environment plus the callee value and args"
1079    )]
1080    pub fn begin_function_value_eval<R: StoryRng>(
1081        &mut self,
1082        program: &Program,
1083        line_tables: &[Vec<brink_format::LineEntry>],
1084        context: &mut (impl ContextAccess + ?Sized),
1085        handler: &dyn ExternalFnHandler,
1086        callee: &Value,
1087        args: &[Value],
1088        resolver: Option<&dyn PluralResolver>,
1089    ) -> Result<FunctionEval, RuntimeError> {
1090        self.begin_function_value_eval_with_limit::<R>(
1091            program,
1092            line_tables,
1093            context,
1094            handler,
1095            callee,
1096            args,
1097            resolver,
1098            Self::STEP_LIMIT,
1099        )
1100    }
1101
1102    /// Like [`begin_function_value_eval`](Self::begin_function_value_eval),
1103    /// but the VM step budget for the whole evaluation is `step_limit`
1104    /// rather than the hardcoded [`Self::STEP_LIMIT`] (#1868) — the function-value
1105    /// sibling of [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit).
1106    ///
1107    /// # Errors
1108    /// Same as [`begin_function_value_eval`](Self::begin_function_value_eval),
1109    /// plus [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now
1110    /// bounded by the caller-supplied `step_limit` rather than the fixed
1111    /// default.
1112    #[expect(
1113        clippy::too_many_arguments,
1114        reason = "mirrors begin_function_eval_with_limit: the VM environment plus the callee value, args, and step_limit"
1115    )]
1116    pub fn begin_function_value_eval_with_limit<R: StoryRng>(
1117        &mut self,
1118        program: &Program,
1119        line_tables: &[Vec<brink_format::LineEntry>],
1120        context: &mut (impl ContextAccess + ?Sized),
1121        handler: &dyn ExternalFnHandler,
1122        callee: &Value,
1123        args: &[Value],
1124        resolver: Option<&dyn PluralResolver>,
1125        step_limit: u64,
1126    ) -> Result<FunctionEval, RuntimeError> {
1127        if self.eval.is_some() {
1128            return Err(RuntimeError::AlreadyEvaluatingFunction);
1129        }
1130
1131        // Validate + assemble the full arg row (bound prefix then supplied)
1132        // through the shared dispatch path, so a bad callee faults *before*
1133        // any boundary frame or capture scope is set up — no partial state.
1134        let (container_idx, _target, full_args) =
1135            vm::prepare_fn_value_call(program, callee, args.to_vec())?;
1136
1137        // M-2b: refuse a `#@private` function value the same way
1138        // `begin_function_eval` refuses a `#@private` name — this is the
1139        // sibling engine→ink call-dispatch path (T1c function values), sharing
1140        // the same `container_idx` resolve-then-enter shape, so it shares the
1141        // same gap and the same fix. Checked before any boundary frame or
1142        // capture scope is set up, same as the `eval.is_some()` check above.
1143        if self.enforce_visibility
1144            && program.has_private_defs()
1145            && program.container_is_private(container_idx)
1146        {
1147            return Err(RuntimeError::PrivateAccess {
1148                name: format!("{}", program.container(container_idx).id),
1149            });
1150        }
1151
1152        let value_floor = self.flow.value_stack.len();
1153        let choice_floor = self.flow.pending_choices.len();
1154
1155        self.flow.output.begin_capture();
1156        let output_start = self.flow.output.target_len();
1157        let boundary = CallFrame {
1158            return_address: None,
1159            temps: Vec::new(),
1160            container_stack: vec![ContainerPosition {
1161                container_idx,
1162                offset: 0,
1163            }],
1164            frame_type: CallFrameType::FunctionEvalFromGame,
1165            external_fn_id: None,
1166            function_output_start: Some(output_start),
1167        };
1168        self.flow.current_thread_mut().call_stack.push(boundary);
1169        self.stats.frames_pushed += 1;
1170
1171        // Pass the full arg row (bound prefix then supplied) onto the value
1172        // stack in declaration order — the prologue binds it exactly as an
1173        // in-story call would.
1174        self.flow.value_stack.extend_from_slice(&full_args);
1175
1176        self.eval = Some(EvalState {
1177            value_floor,
1178            choice_floor,
1179        });
1180        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1181    }
1182
1183    /// Resume a function evaluation that paused on
1184    /// [`FunctionEval::AwaitingExternal`], after the pending external has
1185    /// been resolved via [`resolve_external`](Self::resolve_external).
1186    ///
1187    /// # Errors
1188    /// - [`NotEvaluatingFunction`](RuntimeError::NotEvaluatingFunction) if
1189    ///   no evaluation is in progress.
1190    /// - Same evaluation errors as
1191    ///   [`begin_function_eval`](Self::begin_function_eval).
1192    pub fn resume_function_eval<R: StoryRng>(
1193        &mut self,
1194        program: &Program,
1195        line_tables: &[Vec<brink_format::LineEntry>],
1196        context: &mut (impl ContextAccess + ?Sized),
1197        handler: &dyn ExternalFnHandler,
1198        resolver: Option<&dyn PluralResolver>,
1199    ) -> Result<FunctionEval, RuntimeError> {
1200        self.resume_function_eval_with_limit::<R>(
1201            program,
1202            line_tables,
1203            context,
1204            handler,
1205            resolver,
1206            Self::STEP_LIMIT,
1207        )
1208    }
1209
1210    /// Like [`resume_function_eval`](Self::resume_function_eval), but the VM
1211    /// step budget for the remainder of the evaluation is `step_limit`
1212    /// rather than the hardcoded [`Self::STEP_LIMIT`] (#1868). A caller that
1213    /// began the evaluation with
1214    /// [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit)/
1215    /// [`begin_function_value_eval_with_limit`](Self::begin_function_value_eval_with_limit)
1216    /// should resume with the same `step_limit` to keep one consistent
1217    /// budget across pauses — this call's step count starts fresh (mirrors
1218    /// [`advance_with_limit`](Self::advance_with_limit): each call gets its
1219    /// own `step_limit`-sized allowance, not a running total).
1220    ///
1221    /// # Errors
1222    /// Same as [`resume_function_eval`](Self::resume_function_eval), plus
1223    /// [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now bounded
1224    /// by the caller-supplied `step_limit` rather than the fixed default.
1225    pub fn resume_function_eval_with_limit<R: StoryRng>(
1226        &mut self,
1227        program: &Program,
1228        line_tables: &[Vec<brink_format::LineEntry>],
1229        context: &mut (impl ContextAccess + ?Sized),
1230        handler: &dyn ExternalFnHandler,
1231        resolver: Option<&dyn PluralResolver>,
1232        step_limit: u64,
1233    ) -> Result<FunctionEval, RuntimeError> {
1234        if self.eval.is_none() {
1235            return Err(RuntimeError::NotEvaluatingFunction);
1236        }
1237        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1238    }
1239
1240    /// Returns `true` if a function evaluation is in progress (possibly
1241    /// paused awaiting an external).
1242    #[must_use]
1243    pub fn is_evaluating_function(&self) -> bool {
1244        self.eval.is_some()
1245    }
1246
1247    /// Step the VM until the in-progress function evaluation returns or
1248    /// pauses on a pending external. Shared by `begin`/`resume`. `step_limit`
1249    /// bounds this call's own step loop (#1868) — see
1250    /// [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit)
1251    /// for why a caller-supplied budget matters here.
1252    fn drive_function_eval<R: StoryRng>(
1253        &mut self,
1254        program: &Program,
1255        line_tables: &[Vec<brink_format::LineEntry>],
1256        context: &mut (impl ContextAccess + ?Sized),
1257        handler: &dyn ExternalFnHandler,
1258        resolver: Option<&dyn PluralResolver>,
1259        step_limit: u64,
1260    ) -> Result<FunctionEval, RuntimeError> {
1261        let step_start = self.stats.steps;
1262        loop {
1263            self.stats.steps += 1;
1264            if self.stats.steps - step_start > step_limit {
1265                self.abort_eval(program, line_tables, resolver);
1266                return Err(RuntimeError::StepLimitExceeded(step_limit));
1267            }
1268
1269            let stepped = vm::step::<R>(
1270                &mut self.flow,
1271                program,
1272                line_tables,
1273                context,
1274                &mut self.stats,
1275                resolver,
1276            )?;
1277            self.stats.materializations += self.flow.drain_materializations();
1278
1279            match stepped {
1280                vm::Stepped::Done | vm::Stepped::Ended => {
1281                    // A function reached `-> DONE`/`-> END` — illegal.
1282                    self.abort_eval(program, line_tables, resolver);
1283                    return Err(RuntimeError::FunctionYielded);
1284                }
1285                vm::Stepped::ExternalCall => {
1286                    if let Some(pending) =
1287                        self.resolve_eval_external(program, line_tables, resolver, handler)?
1288                    {
1289                        return Ok(pending);
1290                    }
1291                }
1292                vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {}
1293            }
1294
1295            // Did the boundary frame pop? Then the function has returned
1296            // (via `~ return` or implicit exhaustion).
1297            if !self.flow.has_eval_boundary() {
1298                let _captured = self.flow.output.end_capture(program, line_tables, resolver);
1299                let floor = self.eval.take().map_or(0, |e| e.value_floor);
1300                let mut ret: Option<Value> = None;
1301                while self.flow.value_stack.len() > floor {
1302                    let v = self.flow.value_stack.pop();
1303                    if ret.is_none() {
1304                        ret = v; // first popped = top of stack = the return value
1305                    }
1306                }
1307                return Ok(FunctionEval::Returned(ret.unwrap_or(Value::Null)));
1308            }
1309
1310            // A function must not present choices. Compare against the
1311            // count when the eval began — the main story may already have
1312            // choices waiting, which are none of our concern.
1313            let choice_floor = self.eval.as_ref().map_or(0, |e| e.choice_floor);
1314            if self.flow.pending_choices.len() > choice_floor {
1315                self.abort_eval(program, line_tables, resolver);
1316                return Err(RuntimeError::FunctionYielded);
1317            }
1318        }
1319    }
1320
1321    /// Resolve an external hit during function evaluation, mirroring the
1322    /// normal step path but surfacing [`ExternalResult::Pending`] as
1323    /// [`FunctionEval::AwaitingExternal`] (returned as `Some`) rather than
1324    /// an error. Returns `None` when the external resolved and stepping
1325    /// should continue.
1326    fn resolve_eval_external(
1327        &mut self,
1328        program: &Program,
1329        line_tables: &[Vec<brink_format::LineEntry>],
1330        resolver: Option<&dyn PluralResolver>,
1331        handler: &dyn ExternalFnHandler,
1332    ) -> Result<Option<FunctionEval>, RuntimeError> {
1333        let fn_id = self
1334            .flow
1335            .external_fn_id()
1336            .ok_or(RuntimeError::CallStackUnderflow)?;
1337        let entry = program.external_fn(fn_id);
1338        let fn_name = entry.map_or("?", |e| program.name(e.name));
1339        match handler.call(fn_name, self.flow.external_args()) {
1340            ExternalResult::Resolved(value) => {
1341                self.flow.resolve_external(value);
1342                Ok(None)
1343            }
1344            ExternalResult::Fallback => {
1345                if let Some(fb_id) = entry.and_then(|e| e.fallback) {
1346                    let container_idx = program
1347                        .resolve_target(fb_id)
1348                        .map(|(idx, _)| idx)
1349                        .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1350                    self.flow.invoke_fallback(container_idx);
1351                    Ok(None)
1352                } else {
1353                    self.abort_eval(program, line_tables, resolver);
1354                    Err(RuntimeError::UnresolvedExternalCall(fn_id))
1355                }
1356            }
1357            ExternalResult::Pending => Ok(Some(FunctionEval::AwaitingExternal)),
1358        }
1359    }
1360
1361    /// Tear down an aborted/failed evaluation: end the output capture and
1362    /// clear the eval marker. Leaves the call stack as-is (the caller is
1363    /// erroring out).
1364    pub(crate) fn abort_eval(
1365        &mut self,
1366        program: &Program,
1367        line_tables: &[Vec<brink_format::LineEntry>],
1368        resolver: Option<&dyn PluralResolver>,
1369    ) {
1370        if self.eval.take().is_some() {
1371            let _ = self.flow.output.end_capture(program, line_tables, resolver);
1372        }
1373    }
1374}
1375
1376/// Internal: set execution position to the given choice target, clear
1377/// pending choices, and set status to Active. No status precondition.
1378#[expect(clippy::similar_names)]
1379/// Returns the `DefinitionId` of the selected choice target, so the
1380/// caller can notify observers if needed.
1381fn select_choice(
1382    flow: &mut Flow,
1383    context: &mut (impl ContextAccess + ?Sized),
1384    status: &mut StoryStatus,
1385    stats: &mut Stats,
1386    index: usize,
1387) -> Result<(), RuntimeError> {
1388    let available = flow.pending_choices.len();
1389    if index >= available {
1390        return Err(RuntimeError::InvalidChoiceIndex { index, available });
1391    }
1392
1393    let choice = flow.pending_choices.swap_remove(index);
1394    let target_id = choice.target_id;
1395
1396    // Increment visit count for the choice target container so that
1397    // once-only choices can be filtered on subsequent passes.
1398    context.increment_visit(target_id);
1399    context.set_turn_count(target_id, context.turn_index());
1400
1401    // Replace the current thread with the fork from choice creation
1402    // time. By selection time, all spawned threads should have
1403    // completed — only the main thread remains.
1404    let current = flow.current_thread_mut();
1405    *current = choice.thread_fork;
1406
1407    // Set execution position to the choice target. We reset the top
1408    // frame's container_stack to just the target — the snapshot may
1409    // have captured stale nesting from inside the choice eval block.
1410    let frame = current
1411        .call_stack
1412        .last_mut()
1413        .ok_or(RuntimeError::CallStackUnderflow)?;
1414
1415    frame.container_stack.clear();
1416    frame.container_stack.push(ContainerPosition {
1417        container_idx: choice.target_idx,
1418        offset: choice.target_offset,
1419    });
1420
1421    flow.pending_choices.clear();
1422    // No explicit pending-terminal clear needed here either (same reasoning
1423    // as `choose_path_string_with_args`): `next_block_id`'s bump below moves
1424    // this choice to a fresh run, which is exactly what `PendingTerminal`
1425    // uses to invalidate a stash from before the choice — so the next step
1426    // correctly runs the VM at the chosen target rather than replaying
1427    // whatever `Done`/`Choices`/`End` was pending before selection.
1428    *status = StoryStatus::Active;
1429    stats.choices_selected += 1;
1430    // A fresh run begins at the chosen branch (`BlockId`, §3.7/§8d.2).
1431    flow.next_block_id += 1;
1432
1433    Ok(())
1434}
1435
1436/// Resolve an external function call using the handler and program metadata.
1437///
1438/// Returns `Ok(true)` if the call was resolved (a value was supplied or the
1439/// in-story fallback was invoked) and stepping should continue; `Ok(false)`
1440/// if the handler deferred ([`ExternalResult::Pending`]), leaving the
1441/// `External` frame intact for the caller to resolve out-of-band. Errors
1442/// only when the handler declined and no fallback exists.
1443fn resolve_external_call(
1444    flow: &mut Flow,
1445    program: &Program,
1446    handler: &dyn ExternalFnHandler,
1447) -> Result<bool, RuntimeError> {
1448    let fn_id = flow
1449        .external_fn_id()
1450        .ok_or(RuntimeError::CallStackUnderflow)?;
1451
1452    let entry = program.external_fn(fn_id);
1453    let fn_name = entry.map_or("?", |e| program.name(e.name));
1454
1455    let result = handler.call(fn_name, flow.external_args());
1456    match result {
1457        ExternalResult::Resolved(value) => {
1458            flow.resolve_external(value);
1459            Ok(true)
1460        }
1461        ExternalResult::Fallback => {
1462            let fallback_id = entry.and_then(|e| e.fallback);
1463            if let Some(fb_id) = fallback_id {
1464                let container_idx = program
1465                    .resolve_target(fb_id)
1466                    .map(|(idx, _)| idx)
1467                    .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1468
1469                flow.invoke_fallback(container_idx);
1470                Ok(true)
1471            } else {
1472                Err(RuntimeError::UnresolvedExternalCall(fn_id))
1473            }
1474        }
1475        ExternalResult::Pending => {
1476            // Leave the External frame intact — the caller resolves it
1477            // out-of-band (via resolve_external) before continuing.
1478            Ok(false)
1479        }
1480    }
1481}
1482
1483/// Flush remaining output buffer content into `(text, tags, element_data)`.
1484///
1485/// At a yield point (Done/Choices/Ended), no more output is coming, so
1486/// trailing newlines are committed. Lines are joined with `\n`, tags are
1487/// flattened into a single vec, and element-attachment data (issue #2108) is
1488/// merged the same way — later lines' keys win on conflict, matching the
1489/// existing "just flatten" precision this function already had for tags:
1490/// multiple flushed-at-once lines belonging to genuinely different attach
1491/// runs is a pre-existing imprecision this fix does not newly introduce.
1492fn flush_remaining(
1493    flow: &mut Flow,
1494    program: &Program,
1495    line_tables: &[Vec<brink_format::LineEntry>],
1496    resolver: Option<&dyn brink_format::PluralResolver>,
1497) -> (String, Vec<String>, BTreeMap<String, String>) {
1498    let lines = flow.output.flush_lines(program, line_tables, resolver);
1499    let mut text = String::new();
1500    let mut tags = Vec::new();
1501    let mut element = BTreeMap::new();
1502    for (i, (line_text, line_tags, line_element)) in lines.iter().enumerate() {
1503        if i > 0 {
1504            text.push('\n');
1505        }
1506        text.push_str(line_text);
1507        tags.extend_from_slice(line_tags);
1508        element.extend(line_element.iter().map(|(k, v)| (k.clone(), v.clone())));
1509    }
1510    (text, tags, element)
1511}
1512
1513/// Build a [`Step::Line`] stamped with the flow's current [`BlockId`] and
1514/// its [`Element`] classification.
1515///
1516/// Issue #2108 (`docs/decision-log.md` 2026-08-03 "The element output
1517/// model") populates `element.data` from `data` — the per-line element-
1518/// attachment snapshot [`OutputBuffer::take_first_line`]/`flush_lines`
1519/// already resolved from the output buffer's own transcript (see
1520/// `OutputPart::ElementAttach`'s doc for why it lives there rather than on
1521/// `Flow`). The common case — no attach convention preceded this line —
1522/// passes an empty map, falling back to the always-correct
1523/// [`Element::narrative`] default, unchanged from #1683.
1524///
1525/// `element.kind` stays [`Element::NARRATIVE`] either way: only *data* is
1526/// populated here. Classifying `kind` itself for a non-attach single-line
1527/// handler (`heading`/`transition` reporting their own handler name) is a
1528/// distinct, separately-tractable gap this PR does not close — see
1529/// `docs/decision-log.md`/this issue's follow-up notes.
1530fn make_output_line(
1531    flow: &Flow,
1532    text: String,
1533    tags: Vec<String>,
1534    data: BTreeMap<String, String>,
1535) -> Step {
1536    let element = if data.is_empty() {
1537        Element::narrative()
1538    } else {
1539        Element {
1540            kind: Element::NARRATIVE.to_string(),
1541            data,
1542        }
1543    };
1544    Step::Line(OutputLine {
1545        text,
1546        tags,
1547        block_id: BlockId(flow.next_block_id),
1548        element,
1549    })
1550}
1551
1552/// Collect the currently pending choices into the public [`Choice`] shape,
1553/// resolving each display text (trimming spaces/tabs, matching C#:
1554/// `choice.text = (startText + choiceOnlyText).Trim(' ', '\t')`).
1555fn collect_choices(
1556    flow: &Flow,
1557    program: &Program,
1558    line_tables: &[Vec<brink_format::LineEntry>],
1559    resolver: Option<&dyn brink_format::PluralResolver>,
1560) -> Vec<Choice> {
1561    flow.pending_choices
1562        .iter()
1563        .enumerate()
1564        .filter(|(_, pc)| !pc.flags.is_invisible_default)
1565        .map(|(i, pc)| {
1566            let display_text = match &pc.display {
1567                ChoiceDisplay::Text(s) => s.clone(),
1568                ChoiceDisplay::Fragment(idx) => {
1569                    flow.output
1570                        .resolve_fragment(*idx, program, line_tables, resolver)
1571                }
1572            };
1573            let display_text = display_text
1574                .trim_matches(|c: char| c == ' ' || c == '\t')
1575                .to_string();
1576            Choice {
1577                text: display_text,
1578                index: i,
1579                tags: pc.tags.clone(),
1580            }
1581        })
1582        .collect()
1583}
1584
1585/// Build the terminal [`Step`] for a yield point (`WaitingForChoice`/
1586/// `Done`/`Ended`) based on the current story status.
1587///
1588/// Terminals carry no text (`docs/prose-dialect-spec.md` §7, RULED) —
1589/// `Line`'s old fused shape no longer exists. If `text`/`tags` is
1590/// non-empty, it's delivered first as its own `Step::Line`, and the bare
1591/// terminal is stashed on `flow.pending_terminal` for the very next
1592/// `advance` call to return with no further VM stepping. If there's
1593/// nothing to flush, the bare terminal is returned immediately.
1594#[expect(
1595    clippy::too_many_arguments,
1596    reason = "issue #2108's `element` param pushed this past 7; each param is \
1597              a distinct piece of the terminal/line it builds, not a natural group"
1598)]
1599fn yield_step(
1600    status: StoryStatus,
1601    text: String,
1602    tags: Vec<String>,
1603    element: BTreeMap<String, String>,
1604    flow: &mut Flow,
1605    program: &Program,
1606    line_tables: &[Vec<brink_format::LineEntry>],
1607    resolver: Option<&dyn brink_format::PluralResolver>,
1608) -> Step {
1609    let terminal = match status {
1610        StoryStatus::WaitingForChoice => {
1611            Step::Choices(collect_choices(flow, program, line_tables, resolver))
1612        }
1613        StoryStatus::Ended => Step::End,
1614        StoryStatus::Done => Step::Done,
1615        // Defensive fallback — `yield_step` is only ever called once
1616        // `status` has transitioned away from `Active` at a genuine yield
1617        // point (see call sites), so this arm is unreachable in practice.
1618        StoryStatus::Active => return make_output_line(flow, text, tags, element),
1619    };
1620
1621    if text.is_empty() && tags.is_empty() {
1622        terminal
1623    } else {
1624        flow.pending_terminal.stash(flow.next_block_id, terminal);
1625        make_output_line(flow, text, tags, element)
1626    }
1627}