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::new(CallFrameType::Root, None, None);
110        let initial_thread = Thread {
111            // The root thread: no parent frames below it (issue #3561).
112            base_depth: 0,
113            call_stack: CallStack::new(
114                initial_frame,
115                Some(ContainerPosition {
116                    container_idx,
117                    offset: 0,
118                }),
119            ),
120        };
121        let flow_instance = Self {
122            flow: Flow {
123                threads: vec![initial_thread],
124                value_stack: Vec::new(),
125                output: OutputBuffer::new(),
126                pending_choices: Vec::new(),
127                current_tags: Vec::new(),
128                in_tag: false,
129                skipping_choice: false,
130                did_safe_exit: false,
131                did_unsafe_yield: false,
132                line_delivered_this_turn: false,
133                ran_out_of_content_cause: RanOutOfContentCause::default(),
134                exec_mode: ExecMode::default(),
135                pure_callback: crate::story::PureCallbackState::default(),
136                next_block_id: 0,
137                pending_terminal: PendingTerminal::default(),
138                warnings: Vec::new(),
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_else(|| 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, source)) =
415                self.flow
416                    .output
417                    .take_first_line(program, line_tables, resolver)
418        {
419            return Ok(StepOutcome::Step(make_output_line(
420                &mut self.flow,
421                text,
422                tags,
423                element,
424                source,
425            )));
426        }
427
428        // 2. If buffer has partial content but VM has already yielded
429        //    (any non-Active state), flush it. At a yield point, no more
430        //    output is coming, so trailing Newlines are committed.
431        if self.flow.output.has_unread() && self.status != StoryStatus::Active {
432            let (text, tags, element, source) =
433                flush_remaining(&mut self.flow, program, line_tables, resolver);
434            return Ok(StepOutcome::Step(yield_step(
435                self.status,
436                text,
437                tags,
438                element,
439                source,
440                &mut self.flow,
441                program,
442                line_tables,
443                resolver,
444            )));
445        }
446
447        // 3. Status checks.
448        if self.status == StoryStatus::Ended {
449            return Err(RuntimeError::StoryEnded);
450        }
451        if self.status == StoryStatus::WaitingForChoice {
452            return Err(RuntimeError::NotWaitingForChoice);
453        }
454
455        // 4. Reset Done → Active (resuming after output).
456        //    If the previous cycle ended without a safe exit (no explicit
457        //    -> DONE opcode), the story ran out of content. The previous
458        //    call delivered the text — error now.
459        if self.status == StoryStatus::Done {
460            if !self.flow.did_safe_exit {
461                return Err(RuntimeError::RanOutOfContent(
462                    self.flow.ran_out_of_content_cause,
463                ));
464            }
465            self.status = StoryStatus::Active;
466            // A fresh run begins wherever the story resumes from `Done`.
467            self.flow.next_block_id += 1;
468            self.flow.line_delivered_this_turn = false;
469        }
470
471        // Clear flags — will be set during this cycle if relevant.
472        self.flow.did_safe_exit = false;
473        self.flow.did_unsafe_yield = false;
474
475        // 5. Step VM loop.
476        let Self {
477            flow,
478            status,
479            stats,
480            ..
481        } = self;
482        let step_start = stats.steps;
483
484        loop {
485            stats.steps += 1;
486
487            if stats.steps - step_start > step_limit {
488                return Err(RuntimeError::StepLimitExceeded(step_limit));
489            }
490
491            let stepped = vm::step::<R>(flow, program, line_tables, context, stats, resolver)?;
492
493            match stepped {
494                vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {
495                    if flow.output.has_completed_line()
496                        && let Some((text, tags, element, source)) =
497                            flow.output.take_first_line(program, line_tables, resolver)
498                    {
499                        return Ok(StepOutcome::Step(make_output_line(
500                            flow, text, tags, element, source,
501                        )));
502                    }
503                }
504
505                vm::Stepped::ExternalCall => {
506                    // `false` means the handler deferred (Pending): pause
507                    // cleanly so the caller can resolve it out-of-band.
508                    if !resolve_external_call(flow, program, handler)? {
509                        return Ok(StepOutcome::AwaitingExternal);
510                    }
511                    if flow.output.has_completed_line()
512                        && let Some((text, tags, element, source)) =
513                            flow.output.take_first_line(program, line_tables, resolver)
514                    {
515                        return Ok(StepOutcome::Step(make_output_line(
516                            flow, text, tags, element, source,
517                        )));
518                    }
519                }
520
521                vm::Stepped::Done => {
522                    context.increment_turn_index();
523
524                    // Handle invisible default choices: auto-select and keep running.
525                    if !flow.pending_choices.is_empty() {
526                        let all_invisible = flow
527                            .pending_choices
528                            .iter()
529                            .all(|pc| pc.flags.is_invisible_default);
530                        if all_invisible {
531                            select_choice(flow, context, status, stats, 0)?;
532                            if flow.output.has_completed_line()
533                                && let Some((text, tags, element, source)) =
534                                    flow.output.take_first_line(program, line_tables, resolver)
535                            {
536                                return Ok(StepOutcome::Step(make_output_line(
537                                    flow, text, tags, element, source,
538                                )));
539                            }
540                            continue;
541                        }
542                    }
543
544                    // Set status based on remaining choices.
545                    if flow.pending_choices.is_empty() {
546                        *status = StoryStatus::Done;
547                    } else {
548                        *status = StoryStatus::WaitingForChoice;
549                        stats.choices_presented += 1;
550                    }
551
552                    if flow.output.has_completed_line()
553                        && let Some((text, tags, element, source)) =
554                            flow.output.take_first_line(program, line_tables, resolver)
555                    {
556                        return Ok(StepOutcome::Step(make_output_line(
557                            flow, text, tags, element, source,
558                        )));
559                    }
560
561                    let (text, tags, element, source) =
562                        flush_remaining(flow, program, line_tables, resolver);
563                    return Ok(StepOutcome::Step(yield_step(
564                        *status,
565                        text,
566                        tags,
567                        element,
568                        source,
569                        flow,
570                        program,
571                        line_tables,
572                        resolver,
573                    )));
574                }
575
576                vm::Stepped::Ended => {
577                    context.increment_turn_index();
578                    *status = StoryStatus::Ended;
579
580                    if flow.output.has_completed_line()
581                        && let Some((text, tags, element, source)) =
582                            flow.output.take_first_line(program, line_tables, resolver)
583                    {
584                        return Ok(StepOutcome::Step(make_output_line(
585                            flow, text, tags, element, source,
586                        )));
587                    }
588
589                    let (text, tags, element, source) =
590                        flush_remaining(flow, program, line_tables, resolver);
591                    return Ok(StepOutcome::Step(yield_step(
592                        *status,
593                        text,
594                        tags,
595                        element,
596                        source,
597                        flow,
598                        program,
599                        line_tables,
600                        resolver,
601                    )));
602                }
603            }
604        }
605    }
606
607    /// Select a choice by index. Call [`step_single_line`](Self::step_single_line)
608    /// afterward to continue execution from the chosen branch.
609    pub fn choose(
610        &mut self,
611        context: &mut (impl ContextAccess + ?Sized),
612        index: usize,
613    ) -> Result<(), RuntimeError> {
614        if self.status != StoryStatus::WaitingForChoice {
615            return Err(RuntimeError::NotWaitingForChoice);
616        }
617        // `index` numbers the VISIBLE choices (what `Step::Choices` hands
618        // out and what C#'s `ChooseChoiceIndex` takes); an invisible
619        // fallback ahead of a visible choice — a thread's `+ ->` merged in
620        // front of the main flow's choices — sits in `pending_choices`
621        // but never in that numbering (issue #3527).
622        let position = self
623            .flow
624            .pending_choices
625            .iter()
626            .enumerate()
627            .filter(|(_, pc)| !pc.flags.is_invisible_default)
628            .nth(index)
629            .map(|(position, _)| position);
630        let Some(position) = position else {
631            let available = self
632                .flow
633                .pending_choices
634                .iter()
635                .filter(|pc| !pc.flags.is_invisible_default)
636                .count();
637            return Err(RuntimeError::InvalidChoiceIndex { index, available });
638        };
639        select_choice(
640            &mut self.flow,
641            context,
642            &mut self.status,
643            &mut self.stats,
644            position,
645        )
646    }
647
648    /// Move the play head to a named knot/stitch path — the equivalent of
649    /// ink's `Story.ChoosePathString(path)` (with its default
650    /// `resetCallstack: true`). Call [`step_single_line`](Self::step_single_line)
651    /// (or any continue method) afterward to run from there.
652    ///
653    /// `path` is a dot-separated runtime path: a knot (`intro`), a qualified
654    /// stitch (`intro.dock`), or — for programs compiled by `brink-compiler` —
655    /// an author label (`knot.label`, `knot.stitch.label`; an extension over
656    /// C#, which cannot address labels).
657    ///
658    /// Mirroring the C# reference (`Story.ChoosePathString` →
659    /// `ResetCallstack`/`ForceEnd` → `ChoosePath` → `state.SetChosenPath` +
660    /// `VisitChangedContainersDueToDivert`):
661    ///
662    /// - The current flow is **force-completed** first: the call stack
663    ///   collapses to a single fresh root frame (abandoning any tunnels,
664    ///   threads, or in-progress weave), pending choices are cleared, and
665    ///   the jump counts as a safe exit (as if the story had hit `-> DONE`).
666    /// - The jump **counts as a visit** to the target, with exactly the
667    ///   semantics of an in-story `-> path` divert (it goes through the same
668    ///   goto machinery, so counting flags are honored identically).
669    /// - Output already produced but not yet consumed is **kept** (C# leaves
670    ///   the output stream untouched); it is delivered before content from
671    ///   the new location. The value stack is likewise left as-is.
672    /// - A permanently **ended** story (`-> END`) may be re-entered by
673    ///   jumping, matching C# where `ChoosePathString` + `Continue` works
674    ///   after the story has ended.
675    ///
676    /// # Errors
677    /// - [`UnknownPath`](RuntimeError::UnknownPath) if `path` resolves to no
678    ///   target (the message names the path).
679    /// - [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
680    ///   if the flow is parked on an unresolved external call — a pending
681    ///   host call must be resolved, not silently abandoned.
682    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
683    ///   if an engine→ink function evaluation is in progress (C# likewise
684    ///   refuses to redirect mid-function).
685    pub fn choose_path_string(
686        &mut self,
687        program: &Program,
688        context: &mut (impl ContextAccess + ?Sized),
689        path: &str,
690    ) -> Result<(), RuntimeError> {
691        self.choose_path_string_with_args(program, context, path, &[])
692    }
693
694    /// Like [`choose_path_string`](Self::choose_path_string) but **binds the
695    /// target knot's declared parameters** from `args` — host-directed entry
696    /// into a parameterized knot/stitch (`=== call(action, present) ===`),
697    /// which a plain path jump can't reach with its params bound.
698    ///
699    /// Semantics are otherwise identical to `choose_path_string` (force-ends
700    /// the current flow, counts as a visit, etc.). The args are pushed onto the
701    /// value stack in declaration order and bound by the target's prologue —
702    /// exactly as an in-story `-> call(a, b)` divert binds them, so this enters
703    /// at the container start (where the prologue runs).
704    ///
705    /// # Errors
706    /// In addition to [`choose_path_string`](Self::choose_path_string)'s errors:
707    /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) if `args.len()`
708    /// differs from the target container's declared parameter count. (Programs
709    /// built by the converter record no param counts, so they report `0` — pass
710    /// no args.)
711    pub fn choose_path_string_with_args(
712        &mut self,
713        program: &Program,
714        context: &mut (impl ContextAccess + ?Sized),
715        path: &str,
716        args: &[Value],
717    ) -> Result<(), RuntimeError> {
718        // M-2b: refuse host-driven entry into a `#@private` knot/stitch
719        // while visibility enforcement is on (`docs/modules-spec.md` §4
720        // boundary rule 2). Mirrors `Story`'s own `check_entry_visibility`
721        // for callers that drive this `FlowInstance` directly — `bevy-brink`,
722        // `Speculation` — without going through `Story`. Checked before any
723        // other error path so a private name reports as private, not as
724        // "not found" or "awaiting external".
725        if self.enforce_visibility && program.has_private_defs() && program.path_is_private(path) {
726            return Err(RuntimeError::PrivateAccess {
727                name: path.to_owned(),
728            });
729        }
730        // A parked host call cannot be silently abandoned: erroring is the
731        // strictest safe behavior (brink-specific — C# has no pausable
732        // externals during normal playback).
733        if let Some(id) = self.flow.external_fn_id() {
734            let external = program
735                .external_fn(id)
736                .map_or_else(|| format!("{id}"), |e| program.name(e.name).to_owned());
737            return Err(RuntimeError::JumpWhileAwaitingExternal {
738                path: path.to_owned(),
739                external,
740            });
741        }
742        // An in-flight engine→ink evaluation (possibly paused on an external)
743        // must finish or be aborted before the flow can be redirected.
744        if self.eval.is_some() {
745            return Err(RuntimeError::AlreadyEvaluatingFunction);
746        }
747
748        let target_id = program
749            .find_path_target(path)
750            .ok_or_else(|| RuntimeError::UnknownPath(path.to_owned()))?;
751
752        // Arity-check before mutating any state. The target container's
753        // declared param count is what its prologue's `DeclareTemp`s will pop.
754        let expected = program.path_param_count(path).unwrap_or(0);
755        if args.len() != expected as usize {
756            return Err(RuntimeError::ArgCountMismatch {
757                target: path.to_owned(),
758                expected,
759                got: args.len(),
760            });
761        }
762
763        // Force-end the current flow, mirroring C# `ResetCallstack` →
764        // `StoryState.ForceEnd`: a single fresh root frame (callStack.Reset),
765        // cleared choices, null pointers (the empty container stack), and
766        // didSafeExit = true. The output buffer and value stack are
767        // deliberately left untouched — C# `ForceEnd` does not clear the
768        // output stream or the evaluation stack.
769        let root_frame = CallFrame::new(CallFrameType::Root, None, None);
770        self.flow.threads = vec![Thread {
771            // A reset drops every spawned thread; what is left is the root
772            // thread, which has no parent frames below it (issue #3561).
773            base_depth: 0,
774            call_stack: CallStack::new(root_frame, None),
775        }];
776        self.flow.pending_choices.clear();
777        // No explicit pending-terminal clear needed here: `next_block_id`'s
778        // bump below (a fresh run begins at the jump target) is exactly
779        // what invalidates any stash from before the jump — see
780        // `PendingTerminal`'s doc comment. The host explicitly redirected
781        // execution, so the next `advance`/`step_single_line` call correctly
782        // steps the VM at the new target rather than handing back a stale
783        // `Done`/`Choices`/`End` left over from before the jump.
784        // Transient intra-step flags. Both are false at any point a host can
785        // observe (between lines / at a yield), but the jump abandons whatever
786        // produced them, so clear defensively.
787        self.flow.skipping_choice = false;
788        self.flow.in_tag = false;
789        self.flow.did_safe_exit = true;
790        // The jump force-completes the current flow like `-> DONE` (see
791        // this method's own doc comment) — a fresh run begins at the
792        // target (`BlockId`, §3.7/§8d.2).
793        self.flow.next_block_id += 1;
794        self.flow.line_delivered_this_turn = false;
795
796        // Push the arguments in declaration order; the target's prologue
797        // (`DeclareTemp`) binds them, exactly as `begin_function_eval` and an
798        // in-story `-> call(a, b)` divert do.
799        self.flow.value_stack.extend_from_slice(args);
800
801        // Jump via the same divert machinery as an in-story `-> path`
802        // (mirrors C# `ChoosePath` → `SetChosenPath` +
803        // `VisitChangedContainersDueToDivert`): sets the position and
804        // increments the target's visit/turn counts per its counting flags.
805        vm::goto_target(&mut self.flow, program, context, target_id)?;
806
807        self.status = StoryStatus::Active;
808        Ok(())
809    }
810
811    /// The current execution status of this flow.
812    #[must_use]
813    pub fn status(&self) -> StoryStatus {
814        self.status
815    }
816
817    /// Whether the most recent execution cycle ended with a *safe exit* —
818    /// an explicit `-> DONE` opcode — as opposed to falling off the end of
819    /// its content with nothing left to run.
820    ///
821    /// Both cases deliver a terminal [`Step::Done`]; this is the only way
822    /// to tell them apart without issuing an extra `advance`/
823    /// `step_single_line` call and observing whether it returns
824    /// [`RuntimeError::RanOutOfContent`](crate::RuntimeError::RanOutOfContent).
825    /// Read it right after receiving a `Step::Done` — it is cleared at the
826    /// start of the *next* execution cycle, so a value read before a
827    /// terminal step is not meaningful.
828    ///
829    /// `true`: the story chose to stop (a knot/stitch reached `-> DONE`);
830    /// resuming later is well-formed. `false`: the flow ran out of
831    /// content; the trailing text was still delivered, but resuming will
832    /// fault.
833    #[must_use]
834    pub fn did_safe_exit(&self) -> bool {
835        self.flow.did_safe_exit
836    }
837
838    /// The knot or `knot.stitch` this flow is executing in — see
839    /// [`Story::current_path`](super::Story::current_path). Hosts that
840    /// drive instances directly (`bevy-brink`) pass the program they run.
841    #[must_use]
842    pub fn current_path(&self, program: &Program) -> Option<String> {
843        let stack = &self.flow.current_thread().call_stack;
844        stack
845            .top_depth()
846            .and_then(|depth| super::frame_path(program, stack, depth))
847            // The root scope's empty path is "no named container".
848            .filter(|path| !path.is_empty())
849    }
850
851    /// Runtime statistics (instructions, materialization counts, etc.)
852    /// accumulated over this flow's execution.
853    #[must_use]
854    pub fn stats(&self) -> &Stats {
855        &self.stats
856    }
857
858    /// Take every non-fatal [`crate::RuntimeWarning`] this flow has raised
859    /// since the last drain, leaving the list empty (issue #3354).
860    ///
861    /// Draining rather than borrowing is deliberate: a host that prints
862    /// warnings as it plays wants each one once, and a host that ignores
863    /// them wants the list not to grow. Accumulation between drains is
864    /// capped at [`crate::RUNTIME_WARNING_CAP`].
865    pub fn take_runtime_warnings(&mut self) -> Vec<crate::RuntimeWarning> {
866        core::mem::take(&mut self.flow.warnings)
867    }
868
869    /// The full append-only transcript of all output parts produced so far.
870    ///
871    /// The transcript stores structural references (e.g. `LineRef`) rather
872    /// than resolved strings, so it can be re-rendered in any locale by
873    /// passing a different set of line tables to
874    /// [`transcript::render_transcript`](crate::transcript::render_transcript).
875    #[must_use]
876    pub fn transcript(&self) -> &[crate::output::OutputPart] {
877        self.flow.output.transcript()
878    }
879
880    /// Number of parts in the transcript.
881    #[must_use]
882    pub fn transcript_len(&self) -> usize {
883        self.flow.output.transcript_len()
884    }
885
886    /// Reset the transcript read cursor to the beginning (for re-rendering,
887    /// e.g. after a locale swap).
888    pub fn reset_cursor(&mut self) {
889        self.flow.output.reset_cursor();
890    }
891
892    /// The fragments captured during execution (for re-rendering choice
893    /// display text and computed substrings in a different locale).
894    #[must_use]
895    pub fn fragments(&self) -> &crate::output::Fragments {
896        self.flow.output.fragments()
897    }
898
899    // ── External calls (ink → engine) ────────────────────────────────
900
901    /// Returns `true` if this flow is frozen on an unresolved external
902    /// call — i.e. the VM hit a `CallExternal` opcode and the handler
903    /// returned [`ExternalResult::Pending`], leaving the `External` frame
904    /// on top of the call stack.
905    ///
906    /// The orchestration layer (e.g. a Bevy resolver system) polls this to
907    /// decide whether the flow needs an external resolved before it can be
908    /// driven further. Resolve via [`resolve_external`](Self::resolve_external).
909    #[must_use]
910    pub fn has_pending_external(&self) -> bool {
911        self.flow.external_fn_id().is_some()
912    }
913
914    /// The [`DefinitionId`] of the pending external function, if this flow
915    /// is frozen on one. Returns `None` otherwise.
916    #[must_use]
917    pub fn pending_external_fn_id(&self) -> Option<DefinitionId> {
918        self.flow.external_fn_id()
919    }
920
921    /// The arguments to the pending external call, in declaration order.
922    /// Empty if no external call is pending.
923    #[must_use]
924    pub fn pending_external_args(&self) -> &[Value] {
925        self.flow.external_args()
926    }
927
928    /// The ink-declared name of the pending external function, resolved
929    /// against `program`'s name table. Returns `None` if no external is
930    /// pending (or the entry is missing, which would indicate a malformed
931    /// program).
932    ///
933    /// The orchestration layer uses this to look up the binding registered
934    /// for this name.
935    #[must_use]
936    pub fn pending_external_name<'p>(&self, program: &'p Program) -> Option<&'p str> {
937        let id = self.flow.external_fn_id()?;
938        let entry = program.external_fn(id)?;
939        Some(program.name(entry.name))
940    }
941
942    /// Resolve a pending external call by supplying its return value. Pops
943    /// the `External` frame and pushes `value` onto the value stack so the
944    /// VM can resume. For fire-and-forget externals, pass [`Value::Null`].
945    ///
946    /// No-op if no external call is pending. After resolving, drive the
947    /// flow forward with [`step_single_line`](Self::step_single_line).
948    pub fn resolve_external(&mut self, value: Value) {
949        self.flow.resolve_external(value);
950    }
951
952    // ── Engine → ink calls ───────────────────────────────────────────
953
954    /// Evaluate an ink function from engine code, returning its value.
955    ///
956    /// This does **not** advance the player-visible story: a
957    /// `FunctionEvalFromGame` boundary frame is pushed, `args` are passed
958    /// in declaration order (exactly as a normal call site would), output
959    /// is captured and discarded, and the function runs until it returns.
960    ///
961    /// If the function calls an external whose handler returns
962    /// [`ExternalResult::Pending`] (e.g. a binding that needs Bevy World
963    /// access), evaluation pauses and returns
964    /// [`FunctionEval::AwaitingExternal`]; the caller resolves the
965    /// external (see [`resolve_external`](Self::resolve_external)) and
966    /// calls [`resume_function_eval`](Self::resume_function_eval).
967    ///
968    /// `container_idx` is the function's container, typically obtained from
969    /// [`Program::find_address`](crate::Program::find_address) on the
970    /// function name. Unlike a normal `Call`, this does not increment the
971    /// function's visit count — an engine query is out-of-band, matching
972    /// C#'s `EvaluateFunction`.
973    ///
974    /// # Errors
975    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
976    ///   if a function evaluation is already in progress on this flow.
977    /// - [`FunctionYielded`](RuntimeError::FunctionYielded) if the function
978    ///   presents choices or ends the story (functions must not yield).
979    /// - [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall)
980    ///   if an external has neither a binding nor a fallback.
981    #[expect(
982        clippy::too_many_arguments,
983        reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target and args"
984    )]
985    pub fn begin_function_eval<R: StoryRng>(
986        &mut self,
987        program: &Program,
988        line_tables: &[Vec<brink_format::LineEntry>],
989        context: &mut (impl ContextAccess + ?Sized),
990        handler: &dyn ExternalFnHandler,
991        container_idx: u32,
992        args: &[Value],
993        resolver: Option<&dyn PluralResolver>,
994    ) -> Result<FunctionEval, RuntimeError> {
995        self.begin_function_eval_with_limit::<R>(
996            program,
997            line_tables,
998            context,
999            handler,
1000            container_idx,
1001            args,
1002            resolver,
1003            Self::STEP_LIMIT,
1004        )
1005    }
1006
1007    /// Like [`begin_function_eval`](Self::begin_function_eval), but the VM
1008    /// step budget for the whole evaluation is `step_limit` rather than the
1009    /// hardcoded [`Self::STEP_LIMIT`] (#1868).
1010    ///
1011    /// This is what lets a caller give an engine→ink evaluation its own,
1012    /// appropriately scoped budget — e.g. a compile-time registry walk,
1013    /// which wants a small ceiling of its own rather than the 1,000,000-step
1014    /// production default — mirroring how [`advance_with_limit`](Self::advance_with_limit)
1015    /// already lets [`crate::Speculation`] cap the line-stepping path.
1016    /// `begin_function_eval` itself is a thin wrapper over this with
1017    /// `step_limit: Self::STEP_LIMIT` — every existing call site keeps its
1018    /// exact prior behavior.
1019    ///
1020    /// # Errors
1021    /// Same as [`begin_function_eval`](Self::begin_function_eval), plus
1022    /// [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now bounded
1023    /// by the caller-supplied `step_limit` rather than the fixed default.
1024    #[expect(
1025        clippy::too_many_arguments,
1026        reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target, args, and step_limit"
1027    )]
1028    pub fn begin_function_eval_with_limit<R: StoryRng>(
1029        &mut self,
1030        program: &Program,
1031        line_tables: &[Vec<brink_format::LineEntry>],
1032        context: &mut (impl ContextAccess + ?Sized),
1033        handler: &dyn ExternalFnHandler,
1034        container_idx: u32,
1035        args: &[Value],
1036        resolver: Option<&dyn PluralResolver>,
1037        step_limit: u64,
1038    ) -> Result<FunctionEval, RuntimeError> {
1039        // M-2b: refuse host-driven evaluation of a `#@private` function
1040        // while visibility enforcement is on (`docs/modules-spec.md` §4
1041        // boundary rule 2). Mirrors `Story::call_function`'s own check for
1042        // callers that drive this `FlowInstance` directly — `bevy-brink`,
1043        // `Speculation` — without going through `Story`. The caller
1044        // resolves `container_idx` itself (typically via
1045        // [`Program::find_address`](crate::Program::find_address) on the
1046        // function name), so the error names the definition by its compiled
1047        // id rather than the original name string, which isn't available
1048        // here.
1049        if self.enforce_visibility
1050            && program.has_private_defs()
1051            && program.container_is_private(container_idx)
1052        {
1053            return Err(RuntimeError::PrivateAccess {
1054                name: format!("{}", program.container(container_idx).id),
1055            });
1056        }
1057        if self.eval.is_some() {
1058            return Err(RuntimeError::AlreadyEvaluatingFunction);
1059        }
1060
1061        // Record floors BEFORE pushing args: the value-stack length (so the
1062        // return value and any leftover args can be reclaimed), and the
1063        // pending-choice count (so we can tell a choice the function
1064        // presents from choices the main story already has waiting).
1065        let value_floor = self.flow.value_stack.len();
1066        let choice_floor = self.flow.pending_choices.len();
1067
1068        // Isolate output: anything the function emits routes to the
1069        // capture scratch space and never reaches the transcript.
1070        self.flow.output.begin_capture();
1071
1072        let output_start = self.flow.output.mark();
1073        let boundary = CallFrame::new(
1074            CallFrameType::FunctionEvalFromGame,
1075            None,
1076            Some(output_start),
1077        );
1078        self.flow.current_thread_mut().call_stack.push(
1079            boundary,
1080            Some(ContainerPosition {
1081                container_idx,
1082                offset: 0,
1083            }),
1084        );
1085        self.stats.frames_pushed += 1;
1086
1087        // Pass arguments onto the value stack in declaration order, then
1088        // bind them: `.inkb` v10 removed the callee's `DeclareTemp`
1089        // prologue, so the VM does that work here. Pushing first and
1090        // binding after keeps the pre-v10 behaviour exactly, surplus
1091        // arguments included.
1092        self.flow.value_stack.extend_from_slice(args);
1093        crate::vm::bind_entry_params(&mut self.flow, program, container_idx)?;
1094
1095        self.eval = Some(EvalState {
1096            value_floor,
1097            choice_floor,
1098        });
1099        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1100    }
1101
1102    /// Evaluate an ink **function value** (`FnRef`/`Closure`) from engine
1103    /// code — the host callback-invocation surface (T1c-3,
1104    /// `docs/t1c-spec.md` §6). A function value crosses to the host as an
1105    /// opaque token `{DefinitionId, env}`; the host never dereferences the
1106    /// env — invocation always re-enters the VM here and is journaled
1107    /// exactly like [`begin_function_eval`](Self::begin_function_eval).
1108    ///
1109    /// `callee` must be a [`Value::FnRef`] / [`Value::Closure`]; `args`
1110    /// supply the remaining (val-only) params after the value's bound
1111    /// prefix. The same fault set as in-story dispatch applies — non-function
1112    /// callee, wrong arity, rehydration mismatch, cross-flow ref-`#@local`
1113    /// (`docs/t1c-spec.md` §3/§6) — surfaced as the `Err` here rather than as
1114    /// a turn-terminating story fault, since this is out-of-band evaluation.
1115    ///
1116    /// Like [`begin_function_eval`](Self::begin_function_eval) this does not
1117    /// advance the player-visible story (output isolated, transcript
1118    /// untouched, no visit-count increment) and pauses on world-access
1119    /// externals — resume with
1120    /// [`resume_function_eval`](Self::resume_function_eval).
1121    ///
1122    /// # Errors
1123    /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1124    ///   if an evaluation is already in progress on this flow.
1125    /// - The function-value dispatch faults above (via
1126    ///   `vm::prepare_fn_value_call`), before any frame is pushed.
1127    /// - The same evaluation errors as
1128    ///   [`begin_function_eval`](Self::begin_function_eval).
1129    #[expect(
1130        clippy::too_many_arguments,
1131        reason = "mirrors begin_function_eval: the VM environment plus the callee value and args"
1132    )]
1133    pub fn begin_function_value_eval<R: StoryRng>(
1134        &mut self,
1135        program: &Program,
1136        line_tables: &[Vec<brink_format::LineEntry>],
1137        context: &mut (impl ContextAccess + ?Sized),
1138        handler: &dyn ExternalFnHandler,
1139        callee: &Value,
1140        args: &[Value],
1141        resolver: Option<&dyn PluralResolver>,
1142    ) -> Result<FunctionEval, RuntimeError> {
1143        self.begin_function_value_eval_with_limit::<R>(
1144            program,
1145            line_tables,
1146            context,
1147            handler,
1148            callee,
1149            args,
1150            resolver,
1151            Self::STEP_LIMIT,
1152        )
1153    }
1154
1155    /// Like [`begin_function_value_eval`](Self::begin_function_value_eval),
1156    /// but the VM step budget for the whole evaluation is `step_limit`
1157    /// rather than the hardcoded [`Self::STEP_LIMIT`] (#1868) — the function-value
1158    /// sibling of [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit).
1159    ///
1160    /// # Errors
1161    /// Same as [`begin_function_value_eval`](Self::begin_function_value_eval),
1162    /// plus [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now
1163    /// bounded by the caller-supplied `step_limit` rather than the fixed
1164    /// default.
1165    #[expect(
1166        clippy::too_many_arguments,
1167        reason = "mirrors begin_function_eval_with_limit: the VM environment plus the callee value, args, and step_limit"
1168    )]
1169    pub fn begin_function_value_eval_with_limit<R: StoryRng>(
1170        &mut self,
1171        program: &Program,
1172        line_tables: &[Vec<brink_format::LineEntry>],
1173        context: &mut (impl ContextAccess + ?Sized),
1174        handler: &dyn ExternalFnHandler,
1175        callee: &Value,
1176        args: &[Value],
1177        resolver: Option<&dyn PluralResolver>,
1178        step_limit: u64,
1179    ) -> Result<FunctionEval, RuntimeError> {
1180        if self.eval.is_some() {
1181            return Err(RuntimeError::AlreadyEvaluatingFunction);
1182        }
1183
1184        // Validate + assemble the full arg row (bound prefix then supplied)
1185        // through the shared dispatch path, so a bad callee faults *before*
1186        // any boundary frame or capture scope is set up — no partial state.
1187        let (container_idx, _target, full_args) =
1188            vm::prepare_fn_value_call(program, callee, args.to_vec())?;
1189
1190        // M-2b: refuse a `#@private` function value the same way
1191        // `begin_function_eval` refuses a `#@private` name — this is the
1192        // sibling engine→ink call-dispatch path (T1c function values), sharing
1193        // the same `container_idx` resolve-then-enter shape, so it shares the
1194        // same gap and the same fix. Checked before any boundary frame or
1195        // capture scope is set up, same as the `eval.is_some()` check above.
1196        if self.enforce_visibility
1197            && program.has_private_defs()
1198            && program.container_is_private(container_idx)
1199        {
1200            return Err(RuntimeError::PrivateAccess {
1201                name: format!("{}", program.container(container_idx).id),
1202            });
1203        }
1204
1205        let value_floor = self.flow.value_stack.len();
1206        let choice_floor = self.flow.pending_choices.len();
1207
1208        self.flow.output.begin_capture();
1209        let output_start = self.flow.output.mark();
1210        let boundary = CallFrame::new(
1211            CallFrameType::FunctionEvalFromGame,
1212            None,
1213            Some(output_start),
1214        );
1215        self.flow.current_thread_mut().call_stack.push(
1216            boundary,
1217            Some(ContainerPosition {
1218                container_idx,
1219                offset: 0,
1220            }),
1221        );
1222        self.stats.frames_pushed += 1;
1223
1224        // Pass the full arg row (bound prefix then supplied) onto the value
1225        // stack in declaration order, then bind it: `.inkb` v10 removed the
1226        // callee's `DeclareTemp` prologue, so the VM does that work here —
1227        // the same push-then-bind shape as `begin_function_eval_with_limit`,
1228        // which keeps surplus-argument behaviour (a `bind`-curried closure
1229        // over-supplied by the host) exactly as it was pre-v10.
1230        self.flow.value_stack.extend_from_slice(&full_args);
1231        crate::vm::bind_entry_params(&mut self.flow, program, container_idx)?;
1232
1233        self.eval = Some(EvalState {
1234            value_floor,
1235            choice_floor,
1236        });
1237        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1238    }
1239
1240    /// Resume a function evaluation that paused on
1241    /// [`FunctionEval::AwaitingExternal`], after the pending external has
1242    /// been resolved via [`resolve_external`](Self::resolve_external).
1243    ///
1244    /// # Errors
1245    /// - [`NotEvaluatingFunction`](RuntimeError::NotEvaluatingFunction) if
1246    ///   no evaluation is in progress.
1247    /// - Same evaluation errors as
1248    ///   [`begin_function_eval`](Self::begin_function_eval).
1249    pub fn resume_function_eval<R: StoryRng>(
1250        &mut self,
1251        program: &Program,
1252        line_tables: &[Vec<brink_format::LineEntry>],
1253        context: &mut (impl ContextAccess + ?Sized),
1254        handler: &dyn ExternalFnHandler,
1255        resolver: Option<&dyn PluralResolver>,
1256    ) -> Result<FunctionEval, RuntimeError> {
1257        self.resume_function_eval_with_limit::<R>(
1258            program,
1259            line_tables,
1260            context,
1261            handler,
1262            resolver,
1263            Self::STEP_LIMIT,
1264        )
1265    }
1266
1267    /// Like [`resume_function_eval`](Self::resume_function_eval), but the VM
1268    /// step budget for the remainder of the evaluation is `step_limit`
1269    /// rather than the hardcoded [`Self::STEP_LIMIT`] (#1868). A caller that
1270    /// began the evaluation with
1271    /// [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit)/
1272    /// [`begin_function_value_eval_with_limit`](Self::begin_function_value_eval_with_limit)
1273    /// should resume with the same `step_limit` to keep one consistent
1274    /// budget across pauses — this call's step count starts fresh (mirrors
1275    /// [`advance_with_limit`](Self::advance_with_limit): each call gets its
1276    /// own `step_limit`-sized allowance, not a running total).
1277    ///
1278    /// # Errors
1279    /// Same as [`resume_function_eval`](Self::resume_function_eval), plus
1280    /// [`StepLimitExceeded`](RuntimeError::StepLimitExceeded) is now bounded
1281    /// by the caller-supplied `step_limit` rather than the fixed default.
1282    pub fn resume_function_eval_with_limit<R: StoryRng>(
1283        &mut self,
1284        program: &Program,
1285        line_tables: &[Vec<brink_format::LineEntry>],
1286        context: &mut (impl ContextAccess + ?Sized),
1287        handler: &dyn ExternalFnHandler,
1288        resolver: Option<&dyn PluralResolver>,
1289        step_limit: u64,
1290    ) -> Result<FunctionEval, RuntimeError> {
1291        if self.eval.is_none() {
1292            return Err(RuntimeError::NotEvaluatingFunction);
1293        }
1294        self.drive_function_eval::<R>(program, line_tables, context, handler, resolver, step_limit)
1295    }
1296
1297    /// Returns `true` if a function evaluation is in progress (possibly
1298    /// paused awaiting an external).
1299    #[must_use]
1300    pub fn is_evaluating_function(&self) -> bool {
1301        self.eval.is_some()
1302    }
1303
1304    /// Step the VM until the in-progress function evaluation returns or
1305    /// pauses on a pending external. Shared by `begin`/`resume`. `step_limit`
1306    /// bounds this call's own step loop (#1868) — see
1307    /// [`begin_function_eval_with_limit`](Self::begin_function_eval_with_limit)
1308    /// for why a caller-supplied budget matters here.
1309    fn drive_function_eval<R: StoryRng>(
1310        &mut self,
1311        program: &Program,
1312        line_tables: &[Vec<brink_format::LineEntry>],
1313        context: &mut (impl ContextAccess + ?Sized),
1314        handler: &dyn ExternalFnHandler,
1315        resolver: Option<&dyn PluralResolver>,
1316        step_limit: u64,
1317    ) -> Result<FunctionEval, RuntimeError> {
1318        let step_start = self.stats.steps;
1319        loop {
1320            self.stats.steps += 1;
1321            if self.stats.steps - step_start > step_limit {
1322                self.abort_eval(program, line_tables, resolver);
1323                return Err(RuntimeError::StepLimitExceeded(step_limit));
1324            }
1325
1326            let stepped = vm::step::<R>(
1327                &mut self.flow,
1328                program,
1329                line_tables,
1330                context,
1331                &mut self.stats,
1332                resolver,
1333            )?;
1334
1335            match stepped {
1336                vm::Stepped::Done | vm::Stepped::Ended => {
1337                    // A function reached `-> DONE`/`-> END` — illegal.
1338                    self.abort_eval(program, line_tables, resolver);
1339                    return Err(RuntimeError::FunctionYielded);
1340                }
1341                vm::Stepped::ExternalCall => {
1342                    if let Some(pending) =
1343                        self.resolve_eval_external(program, line_tables, resolver, handler)?
1344                    {
1345                        return Ok(pending);
1346                    }
1347                }
1348                vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {}
1349            }
1350
1351            // Did the boundary frame pop? Then the function has returned
1352            // (via `~ return` or implicit exhaustion).
1353            if !self.flow.has_eval_boundary() {
1354                let _captured = self.flow.output.end_capture(program, line_tables, resolver);
1355                let floor = self.eval.take().map_or(0, |e| e.value_floor);
1356                let mut ret: Option<Value> = None;
1357                while self.flow.value_stack.len() > floor {
1358                    let v = self.flow.value_stack.pop();
1359                    if ret.is_none() {
1360                        ret = v; // first popped = top of stack = the return value
1361                    }
1362                }
1363                return Ok(FunctionEval::Returned(ret.unwrap_or(Value::Null)));
1364            }
1365
1366            // A function must not present choices. Compare against the
1367            // count when the eval began — the main story may already have
1368            // choices waiting, which are none of our concern.
1369            let choice_floor = self.eval.as_ref().map_or(0, |e| e.choice_floor);
1370            if self.flow.pending_choices.len() > choice_floor {
1371                self.abort_eval(program, line_tables, resolver);
1372                return Err(RuntimeError::FunctionYielded);
1373            }
1374        }
1375    }
1376
1377    /// Resolve an external hit during function evaluation, mirroring the
1378    /// normal step path but surfacing [`ExternalResult::Pending`] as
1379    /// [`FunctionEval::AwaitingExternal`] (returned as `Some`) rather than
1380    /// an error. Returns `None` when the external resolved and stepping
1381    /// should continue.
1382    fn resolve_eval_external(
1383        &mut self,
1384        program: &Program,
1385        line_tables: &[Vec<brink_format::LineEntry>],
1386        resolver: Option<&dyn PluralResolver>,
1387        handler: &dyn ExternalFnHandler,
1388    ) -> Result<Option<FunctionEval>, RuntimeError> {
1389        let fn_id = self
1390            .flow
1391            .external_fn_id()
1392            .ok_or_else(|| RuntimeError::CallStackUnderflow)?;
1393        let entry = program.external_fn(fn_id);
1394        let fn_name = entry.map_or("?", |e| program.name(e.name));
1395        match handler.call(fn_name, self.flow.external_args()) {
1396            ExternalResult::Resolved(value) => {
1397                self.flow.resolve_external(value);
1398                Ok(None)
1399            }
1400            ExternalResult::Fallback => {
1401                if let Some(fb_id) = entry.and_then(|e| e.fallback) {
1402                    let container_idx = program
1403                        .resolve_target(fb_id)
1404                        .map(|(idx, _)| idx)
1405                        .ok_or_else(|| RuntimeError::UnresolvedDefinition(fb_id))?;
1406                    self.flow.invoke_fallback(
1407                        container_idx,
1408                        &program.container_param_slots(container_idx),
1409                    );
1410                    Ok(None)
1411                } else {
1412                    self.abort_eval(program, line_tables, resolver);
1413                    Err(RuntimeError::UnresolvedExternalCall(fn_id))
1414                }
1415            }
1416            ExternalResult::Pending => Ok(Some(FunctionEval::AwaitingExternal)),
1417        }
1418    }
1419
1420    /// Tear down an aborted/failed evaluation: end the output capture and
1421    /// clear the eval marker. Leaves the call stack as-is (the caller is
1422    /// erroring out).
1423    pub(crate) fn abort_eval(
1424        &mut self,
1425        program: &Program,
1426        line_tables: &[Vec<brink_format::LineEntry>],
1427        resolver: Option<&dyn PluralResolver>,
1428    ) {
1429        if self.eval.take().is_some() {
1430            let _ = self.flow.output.end_capture(program, line_tables, resolver);
1431        }
1432    }
1433}
1434
1435/// Outcome of [`apply_done_bookkeeping`] — mirrors the branches
1436/// [`FlowInstance::step_single_line`]'s own `vm::Stepped::Done` arm takes,
1437/// minus the buffered-output handling the `debug-hooks` seam
1438/// (`Story::debug_run`/`debug_step`/`debug_run_watching`) doesn't use.
1439#[cfg(feature = "debug-hooks")]
1440pub(crate) enum DoneBookkeeping {
1441    /// All pending choices were invisible defaults and got auto-selected —
1442    /// `status` is `Active` again; the caller should keep stepping, not
1443    /// treat this as a stop.
1444    AutoSelected,
1445    /// Real choices are pending; `status` is now `WaitingForChoice`.
1446    WaitingForChoice,
1447    /// No choices pending; `status` is now `Done` (mirrors an explicit
1448    /// `-> DONE` or the flow otherwise running out of content).
1449    Terminal,
1450}
1451
1452/// Apply the same bookkeeping `step_single_line`'s per-turn loop performs
1453/// when `vm::step` returns `vm::Stepped::Done` (turn-index bump,
1454/// invisible-default auto-select, `WaitingForChoice`/`Done` status) —
1455/// shared with the `debug-hooks` seam so a choice/turn boundary reached
1456/// via opcode-level debug stepping leaves the same `FlowInstance` state a
1457/// production-path caller would see: `Story::choose()` and
1458/// `Story::continue_single()` keep working after a debug session hands
1459/// control back to the production API, and the turn index never diverges
1460/// between the two (issue #3186 review).
1461#[cfg(feature = "debug-hooks")]
1462#[expect(
1463    clippy::similar_names,
1464    reason = "status/stats mirrors select_choice's identical pair"
1465)]
1466pub(crate) fn apply_done_bookkeeping(
1467    flow: &mut Flow,
1468    context: &mut (impl ContextAccess + ?Sized),
1469    status: &mut StoryStatus,
1470    stats: &mut Stats,
1471) -> Result<DoneBookkeeping, RuntimeError> {
1472    context.increment_turn_index();
1473
1474    if !flow.pending_choices.is_empty() {
1475        let all_invisible = flow
1476            .pending_choices
1477            .iter()
1478            .all(|pc| pc.flags.is_invisible_default);
1479        if all_invisible {
1480            select_choice(flow, context, status, stats, 0)?;
1481            return Ok(DoneBookkeeping::AutoSelected);
1482        }
1483    }
1484
1485    if flow.pending_choices.is_empty() {
1486        *status = StoryStatus::Done;
1487        Ok(DoneBookkeeping::Terminal)
1488    } else {
1489        *status = StoryStatus::WaitingForChoice;
1490        stats.choices_presented += 1;
1491        Ok(DoneBookkeeping::WaitingForChoice)
1492    }
1493}
1494
1495/// Internal: set execution position to the given choice target, clear
1496/// pending choices, and set status to Active. No status precondition.
1497#[expect(clippy::similar_names)]
1498/// Returns the `DefinitionId` of the selected choice target, so the
1499/// caller can notify observers if needed.
1500fn select_choice(
1501    flow: &mut Flow,
1502    context: &mut (impl ContextAccess + ?Sized),
1503    status: &mut StoryStatus,
1504    stats: &mut Stats,
1505    index: usize,
1506) -> Result<(), RuntimeError> {
1507    let available = flow.pending_choices.len();
1508    if index >= available {
1509        return Err(RuntimeError::InvalidChoiceIndex { index, available });
1510    }
1511
1512    let choice = flow.pending_choices.swap_remove(index);
1513    let target_id = choice.target_id;
1514
1515    // Increment visit count for the choice target container so that
1516    // once-only choices can be filtered on subsequent passes.
1517    context.increment_visit(target_id);
1518    context.set_turn_count(target_id, context.turn_index());
1519
1520    // Replace the current thread with the fork from choice creation
1521    // time. By selection time, all spawned threads should have
1522    // completed — only the main thread remains.
1523    let current = flow.current_thread_mut();
1524    *current = choice.thread_fork;
1525    // What is installed here IS the root thread from now on, so it has no
1526    // parent frames below it. `fork_thread` already hands back `0`; this
1527    // restates it at the one place a fork becomes the flow's own thread,
1528    // which is exactly where the old `Thread` boundary frame used to ride
1529    // in and stay forever (issue #3561).
1530    current.base_depth = 0;
1531
1532    // Set execution position to the choice target. We reset the top
1533    // frame's container_stack to just the target — the snapshot may
1534    // have captured stale nesting from inside the choice eval block.
1535    if current.call_stack.is_empty() {
1536        return Err(RuntimeError::CallStackUnderflow);
1537    }
1538    current.call_stack.reset_top_containers(ContainerPosition {
1539        container_idx: choice.target_idx,
1540        offset: choice.target_offset,
1541    });
1542
1543    flow.pending_choices.clear();
1544    // No explicit pending-terminal clear needed here either (same reasoning
1545    // as `choose_path_string_with_args`): `next_block_id`'s bump below moves
1546    // this choice to a fresh run, which is exactly what `PendingTerminal`
1547    // uses to invalidate a stash from before the choice — so the next step
1548    // correctly runs the VM at the chosen target rather than replaying
1549    // whatever `Done`/`Choices`/`End` was pending before selection.
1550    *status = StoryStatus::Active;
1551    stats.choices_selected += 1;
1552    // A fresh run begins at the chosen branch (`BlockId`, §3.7/§8d.2).
1553    flow.next_block_id += 1;
1554    flow.line_delivered_this_turn = false;
1555
1556    Ok(())
1557}
1558
1559/// Resolve an external function call using the handler and program metadata.
1560///
1561/// Returns `Ok(true)` if the call was resolved (a value was supplied or the
1562/// in-story fallback was invoked) and stepping should continue; `Ok(false)`
1563/// if the handler deferred ([`ExternalResult::Pending`]), leaving the
1564/// `External` frame intact for the caller to resolve out-of-band. Errors
1565/// only when the handler declined and no fallback exists.
1566///
1567/// `pub(super)` since #3224: the `Story` debug loops resolve externals
1568/// through this exact function, so debug and production stepping can
1569/// never disagree about binding semantics.
1570pub(super) fn resolve_external_call(
1571    flow: &mut Flow,
1572    program: &Program,
1573    handler: &dyn ExternalFnHandler,
1574) -> Result<bool, RuntimeError> {
1575    let fn_id = flow
1576        .external_fn_id()
1577        .ok_or_else(|| RuntimeError::CallStackUnderflow)?;
1578
1579    let entry = program.external_fn(fn_id);
1580    let fn_name = entry.map_or("?", |e| program.name(e.name));
1581
1582    let result = handler.call(fn_name, flow.external_args());
1583    match result {
1584        ExternalResult::Resolved(value) => {
1585            flow.resolve_external(value);
1586            Ok(true)
1587        }
1588        ExternalResult::Fallback => {
1589            let fallback_id = entry.and_then(|e| e.fallback);
1590            if let Some(fb_id) = fallback_id {
1591                let container_idx = program
1592                    .resolve_target(fb_id)
1593                    .map(|(idx, _)| idx)
1594                    .ok_or_else(|| RuntimeError::UnresolvedDefinition(fb_id))?;
1595
1596                flow.invoke_fallback(container_idx, &program.container_param_slots(container_idx));
1597                Ok(true)
1598            } else {
1599                Err(RuntimeError::UnresolvedExternalCall(fn_id))
1600            }
1601        }
1602        ExternalResult::Pending => {
1603            // Leave the External frame intact — the caller resolves it
1604            // out-of-band (via resolve_external) before continuing.
1605            Ok(false)
1606        }
1607    }
1608}
1609
1610/// Flush remaining output buffer content into `(text, tags, element_data)`.
1611///
1612/// At a yield point (Done/Choices/Ended), no more output is coming, so
1613/// trailing newlines are committed. Lines are joined with `\n`, tags are
1614/// flattened into a single vec, and element-attachment data (issue #2108) is
1615/// merged the same way — later lines' keys win on conflict, matching the
1616/// existing "just flatten" precision this function already had for tags:
1617/// multiple flushed-at-once lines belonging to genuinely different attach
1618/// runs is a pre-existing imprecision this fix does not newly introduce.
1619/// `flush_remaining`'s flattened yield-time output: joined text, all tags,
1620/// merged element data, and the FIRST flushed line's source location
1621/// (W7/#3300 provenance — the run "is" where it starts).
1622type FlushedRemaining = (
1623    String,
1624    Vec<String>,
1625    BTreeMap<String, String>,
1626    Option<brink_format::SourceLocation>,
1627);
1628
1629fn flush_remaining(
1630    flow: &mut Flow,
1631    program: &Program,
1632    line_tables: &[Vec<brink_format::LineEntry>],
1633    resolver: Option<&dyn brink_format::PluralResolver>,
1634) -> FlushedRemaining {
1635    let lines = flow.output.flush_lines_at_yield(
1636        program,
1637        line_tables,
1638        resolver,
1639        flow.line_delivered_this_turn,
1640    );
1641    // The first line's buffers are taken over rather than copied — at a
1642    // choice point there is usually exactly one — and any further lines are
1643    // joined onto them.
1644    let mut lines = lines.into_iter();
1645    let Some((mut text, mut tags, mut element, mut source)) = lines.next() else {
1646        return (String::new(), Vec::new(), BTreeMap::new(), None);
1647    };
1648    for (line_text, line_tags, line_element, line_source) in lines {
1649        text.push('\n');
1650        text.push_str(&line_text);
1651        tags.extend(line_tags);
1652        element.extend(line_element);
1653        if source.is_none() {
1654            source = line_source;
1655        }
1656    }
1657    (text, tags, element, source)
1658}
1659
1660/// Build a [`Step::Line`] stamped with the flow's current [`BlockId`] and
1661/// its [`Element`] classification.
1662///
1663/// Issue #2108 (`docs/decision-log.md` 2026-08-03 "The element output
1664/// model") populates `element.data` from `data` — the per-line element-
1665/// attachment snapshot [`OutputBuffer::take_first_line`]/`flush_lines`
1666/// already resolved from the output buffer's own transcript (see
1667/// `OutputPart::ElementAttach`'s doc for why it lives there rather than on
1668/// `Flow`). The common case — no attach convention preceded this line —
1669/// passes an empty map, falling back to the always-correct
1670/// [`Element::narrative`] default, unchanged from #1683.
1671///
1672/// `element.kind` stays [`Element::NARRATIVE`] either way: only *data* is
1673/// populated here. Classifying `kind` itself for a non-attach single-line
1674/// handler (`heading`/`transition` reporting their own handler name) is a
1675/// distinct, separately-tractable gap this PR does not close — see
1676/// `docs/decision-log.md`/this issue's follow-up notes.
1677fn make_output_line(
1678    flow: &mut Flow,
1679    text: String,
1680    tags: Vec<String>,
1681    data: BTreeMap<String, String>,
1682    source: Option<brink_format::SourceLocation>,
1683) -> Step {
1684    flow.line_delivered_this_turn = true;
1685    let element = if data.is_empty() {
1686        Element::narrative()
1687    } else {
1688        Element {
1689            kind: Element::NARRATIVE.to_string(),
1690            data,
1691        }
1692    };
1693    Step::Line(OutputLine {
1694        text,
1695        tags,
1696        block_id: BlockId(flow.next_block_id),
1697        element,
1698        source,
1699    })
1700}
1701
1702/// Collect the currently pending choices into the public [`Choice`] shape,
1703/// resolving each display text (trimming spaces/tabs, matching C#:
1704/// `choice.text = (startText + choiceOnlyText).Trim(' ', '\t')`).
1705fn collect_choices(
1706    flow: &Flow,
1707    program: &Program,
1708    line_tables: &[Vec<brink_format::LineEntry>],
1709    resolver: Option<&dyn brink_format::PluralResolver>,
1710) -> Vec<Choice> {
1711    // `index` counts visible choices only — C#'s `currentChoices`
1712    // numbering, and what `choose` takes (issue #3527).
1713    flow.pending_choices
1714        .iter()
1715        .filter(|pc| !pc.flags.is_invisible_default)
1716        .enumerate()
1717        .map(|(i, pc)| {
1718            let is_blank = |c: char| c == ' ' || c == '\t';
1719            let display_text = match &pc.display {
1720                ChoiceDisplay::Text(s) => s.trim_matches(is_blank).to_string(),
1721                ChoiceDisplay::Fragment(idx) => {
1722                    let mut text =
1723                        flow.output
1724                            .resolve_fragment(*idx, program, line_tables, resolver);
1725                    crate::output::trim_in_place_matches(&mut text, is_blank);
1726                    text
1727                }
1728            };
1729            let source = match &pc.display {
1730                ChoiceDisplay::Fragment(idx) => {
1731                    flow.output.fragment_source(*idx, program, line_tables)
1732                }
1733                ChoiceDisplay::Text(_) => None,
1734            };
1735            Choice {
1736                text: display_text,
1737                index: i,
1738                tags: pc.tags.clone(),
1739                sticky: !pc.flags.once_only,
1740                source,
1741            }
1742        })
1743        .collect()
1744}
1745
1746/// Build the terminal [`Step`] for a yield point (`WaitingForChoice`/
1747/// `Done`/`Ended`) based on the current story status.
1748///
1749/// Terminals carry no text (`docs/prose-dialect-spec.md` §7, RULED) —
1750/// `Line`'s old fused shape no longer exists. If `text`/`tags` is
1751/// non-empty, it's delivered first as its own `Step::Line`, and the bare
1752/// terminal is stashed on `flow.pending_terminal` for the very next
1753/// `advance` call to return with no further VM stepping. If there's
1754/// nothing to flush, the bare terminal is returned immediately.
1755#[expect(
1756    clippy::too_many_arguments,
1757    reason = "issue #2108's `element` param pushed this past 7; each param is \
1758              a distinct piece of the terminal/line it builds, not a natural group"
1759)]
1760fn yield_step(
1761    status: StoryStatus,
1762    text: String,
1763    tags: Vec<String>,
1764    element: BTreeMap<String, String>,
1765    source: Option<brink_format::SourceLocation>,
1766    flow: &mut Flow,
1767    program: &Program,
1768    line_tables: &[Vec<brink_format::LineEntry>],
1769    resolver: Option<&dyn brink_format::PluralResolver>,
1770) -> Step {
1771    let terminal = match status {
1772        StoryStatus::WaitingForChoice => {
1773            Step::Choices(collect_choices(flow, program, line_tables, resolver))
1774        }
1775        StoryStatus::Ended => Step::End,
1776        StoryStatus::Done => Step::Done,
1777        // Defensive fallback — `yield_step` is only ever called once
1778        // `status` has transitioned away from `Active` at a genuine yield
1779        // point (see call sites), so this arm is unreachable in practice.
1780        StoryStatus::Active => return make_output_line(flow, text, tags, element, source),
1781    };
1782
1783    if text.is_empty() && tags.is_empty() {
1784        terminal
1785    } else {
1786        flow.pending_terminal.stash(flow.next_block_id, terminal);
1787        make_output_line(flow, text, tags, element, source)
1788    }
1789}