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}