brink_runtime/story.rs
1//! Per-instance mutable story state.
2
3use std::collections::HashMap;
4use std::marker::PhantomData;
5use std::sync::Arc;
6
7use brink_format::{ChoiceFlags, DefinitionId, PluralResolver, Value};
8
9use crate::error::RuntimeError;
10use crate::output::OutputBuffer;
11use crate::program::Program;
12use crate::rng::{FastRng, StoryRng};
13use crate::state::{ContextAccess, WriteObserver};
14use crate::vm;
15use crate::world::{ContextView, FlowLocal, ResolvedPolicy, World};
16
17/// The current execution status of a story.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum StoryStatus {
20 /// Ready to step.
21 Active,
22 /// Waiting for a choice selection via [`Story::choose`].
23 WaitingForChoice,
24 /// Hit a `done` opcode — can still resume after output is consumed.
25 Done,
26 /// Hit an `end` opcode — permanently finished.
27 Ended,
28}
29
30/// A single step of story output from [`Story::continue_single`].
31///
32/// The enum tells the caller what to do next:
33/// - `Text` — more output may follow, keep calling `continue_single`.
34/// - `Done` — this turn's output is complete. Call `continue_single`
35/// again for the next turn (the story isn't over).
36/// - `Choices` — pick a choice via [`Story::choose`], then resume.
37/// - `End` — the story has permanently ended.
38#[derive(Debug, Clone)]
39pub enum Line {
40 /// One line of story content. More may follow — keep calling
41 /// [`Story::continue_single`].
42 Text { text: String, tags: Vec<String> },
43 /// This turn's output is complete (ink `-> DONE`). The story isn't
44 /// over — call [`Story::continue_single`] again for more.
45 Done { text: String, tags: Vec<String> },
46 /// The story is presenting choices. Call [`Story::choose`] then
47 /// resume with [`Story::continue_single`].
48 Choices {
49 text: String,
50 tags: Vec<String>,
51 choices: Vec<Choice>,
52 },
53 /// The story has permanently ended (ink `-> END`).
54 End { text: String, tags: Vec<String> },
55}
56
57impl Line {
58 /// The text content of this line, regardless of variant.
59 pub fn text(&self) -> &str {
60 match self {
61 Self::Text { text, .. }
62 | Self::Done { text, .. }
63 | Self::Choices { text, .. }
64 | Self::End { text, .. } => text,
65 }
66 }
67
68 /// The tags associated with this line, regardless of variant.
69 pub fn tags(&self) -> &[String] {
70 match self {
71 Self::Text { tags, .. }
72 | Self::Done { tags, .. }
73 | Self::Choices { tags, .. }
74 | Self::End { tags, .. } => tags,
75 }
76 }
77
78 /// Returns true if this is a terminal variant (`Done`, `Choices`, or `End`).
79 pub fn is_terminal(&self) -> bool {
80 !matches!(self, Self::Text { .. })
81 }
82}
83
84/// Outcome of a single [`FlowInstance::advance`] step.
85///
86/// Like [`Line`], but with an extra variant for when a binding handler
87/// deferred an external call ([`ExternalResult::Pending`]) — e.g. a
88/// world-access query hit during normal playback. The flow is paused with
89/// its state intact: inspect the pending call via
90/// [`pending_external_name`](FlowInstance::pending_external_name) /
91/// [`pending_external_args`](FlowInstance::pending_external_args), supply
92/// the result with [`resolve_external`](FlowInstance::resolve_external),
93/// then call [`advance`](FlowInstance::advance) again.
94///
95/// [`step_single_line`](FlowInstance::step_single_line) is the simpler API
96/// for consumers whose handler never pauses — it maps `AwaitingExternal`
97/// to an error.
98#[derive(Debug, Clone)]
99pub enum StepOutcome {
100 /// A line of output, or a yield point (`Done`/`Choices`/`End`).
101 Line(Line),
102 /// The flow paused on a deferred external; resolve it and `advance`.
103 AwaitingExternal,
104}
105
106/// A single choice presented to the player.
107#[derive(Debug, Clone)]
108pub struct Choice {
109 pub text: String,
110 pub index: usize,
111 pub tags: Vec<String>,
112}
113
114// ── Stats ───────────────────────────────────────────────────────────────────
115
116/// Lightweight counters tracking VM activity over a story's lifetime.
117///
118/// Always-on — incrementing a `u64` is effectively free compared to opcode
119/// dispatch. Use [`Story::stats`] to read after a run.
120#[derive(Debug, Clone, Default)]
121pub struct Stats {
122 /// Total opcodes dispatched.
123 pub opcodes: u64,
124 /// Total `vm::step` calls from the outer loop.
125 pub steps: u64,
126 /// Threads forked (via `ThreadCall` and choice creation).
127 pub threads_created: u64,
128 /// Threads that completed and were popped.
129 pub threads_completed: u64,
130 /// Call frames pushed onto thread stacks.
131 pub frames_pushed: u64,
132 /// Call frames popped from thread stacks.
133 pub frames_popped: u64,
134 /// Choice sets presented to the player.
135 pub choices_presented: u64,
136 /// Individual choices selected.
137 pub choices_selected: u64,
138 /// `CallStack::snapshot` cache hits (reused existing `Arc`).
139 pub snapshot_cache_hits: u64,
140 /// `CallStack::snapshot` cache misses (new allocation).
141 pub snapshot_cache_misses: u64,
142 /// `CallStack::materialize` calls (flattened inherited prefix).
143 pub materializations: u64,
144}
145
146// ── Internal types ──────────────────────────────────────────────────────────
147
148#[derive(Debug, Clone, Copy)]
149pub(crate) struct ContainerPosition {
150 pub container_idx: u32,
151 pub offset: usize,
152}
153
154/// Distinguishes call frame types for container-stack-empty semantics:
155///
156/// - **Root**: the initial frame. Yields for pending choices.
157/// - **Function**: `f()` calls. Output is captured as a return value.
158/// - **Tunnel**: `->t->` calls. Yields for pending choices (the tunnel
159/// needs the player's choice before it can continue).
160/// - **Thread**: boundary frame pushed by `ThreadCall`. When this frame
161/// exhausts, the thread is done — inherited frames below it are never
162/// unwound into during normal execution. `->->` (`TunnelReturn`) strips
163/// Thread frames to find the enclosing Tunnel.
164/// - **External**: pushed by `CallExternal`. Holds popped arguments in
165/// `temps` and the external function's [`DefinitionId`] in
166/// `external_fn_id`. The orchestration layer resolves it (binding or
167/// fallback) before the VM resumes.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub(crate) enum CallFrameType {
170 Root,
171 Function,
172 Tunnel,
173 Thread,
174 External,
175 /// Boundary frame pushed by an engine→ink call
176 /// ([`FlowInstance::begin_function_eval`]). Behaves like `Function`
177 /// for output trimming and implicit-return purposes, but marks where
178 /// a from-game evaluation began so the eval driver knows when the
179 /// function has returned. Mirrors C#'s
180 /// `PushPopType.FunctionEvaluationFromGame`.
181 FunctionEvalFromGame,
182}
183
184#[derive(Debug, Clone)]
185pub(crate) struct CallFrame {
186 pub return_address: Option<ContainerPosition>,
187 pub temps: Vec<Value>,
188 pub container_stack: Vec<ContainerPosition>,
189 pub frame_type: CallFrameType,
190 /// For `External` frames: the `DefinitionId` of the external function,
191 /// used to look up the fallback container if no binding is registered.
192 pub external_fn_id: Option<DefinitionId>,
193 /// For `Function` frames: the length of the active output target at
194 /// call time. On return, trailing whitespace is trimmed back to this
195 /// point — matching the C# runtime's `TrimWhitespaceFromFunctionEnd`.
196 pub function_output_start: Option<usize>,
197}
198
199/// Two-part call stack: shared read-only prefix + owned mutable frames.
200///
201/// `fork_thread` snapshots the parent's frames into a cached `Arc<[CallFrame]>`
202/// (one clone, amortized across all children). Children get `Arc::clone` — O(1).
203/// The parent keeps its `own` vec unchanged and continues mutating freely.
204#[derive(Debug, Clone)]
205pub(crate) struct CallStack {
206 /// Shared read-only prefix inherited from the parent thread.
207 inherited: Option<Arc<[CallFrame]>>,
208 /// Frames owned by this thread (above the fork point).
209 own: Vec<CallFrame>,
210 /// Cached snapshot so multiple forks from the same parent share one allocation.
211 cached_snapshot: Option<Arc<[CallFrame]>>,
212 /// Count of materializations (flattening inherited prefix into own).
213 pub(crate) materialization_count: u64,
214}
215
216impl CallStack {
217 pub fn new(frame: CallFrame) -> Self {
218 Self {
219 inherited: None,
220 own: vec![frame],
221 cached_snapshot: None,
222 materialization_count: 0,
223 }
224 }
225
226 pub fn push(&mut self, frame: CallFrame) {
227 self.cached_snapshot = None;
228 self.own.push(frame);
229 }
230
231 pub fn pop(&mut self) -> Option<CallFrame> {
232 self.cached_snapshot = None;
233 if let Some(f) = self.own.pop() {
234 return Some(f);
235 }
236 self.materialize();
237 self.own.pop()
238 }
239
240 pub fn last(&self) -> Option<&CallFrame> {
241 self.own
242 .last()
243 .or_else(|| self.inherited.as_ref().and_then(|h| h.last()))
244 }
245
246 pub fn last_mut(&mut self) -> Option<&mut CallFrame> {
247 if !self.own.is_empty() {
248 return self.own.last_mut();
249 }
250 self.materialize();
251 self.own.last_mut()
252 }
253
254 pub fn len(&self) -> usize {
255 self.inherited.as_ref().map_or(0, |h| h.len()) + self.own.len()
256 }
257
258 pub fn is_empty(&self) -> bool {
259 self.own.is_empty() && self.inherited.as_ref().is_none_or(|h| h.is_empty())
260 }
261
262 /// Get a frame by absolute index (0 = bottom of stack).
263 pub fn get(&self, index: usize) -> Option<&CallFrame> {
264 let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
265 if index < inherited_len {
266 self.inherited.as_ref().and_then(|h| h.get(index))
267 } else {
268 self.own.get(index - inherited_len)
269 }
270 }
271
272 /// Get a mutable reference to a frame by absolute index.
273 /// Materializes the inherited prefix if the target is in it.
274 pub fn get_mut(&mut self, index: usize) -> Option<&mut CallFrame> {
275 let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
276 if index < inherited_len {
277 self.materialize();
278 self.own.get_mut(index)
279 } else {
280 self.own.get_mut(index - inherited_len)
281 }
282 }
283
284 /// Build an `Arc<[CallFrame]>` snapshot of the full stack (inherited + own).
285 /// The result is cached so multiple forks from the same parent share one
286 /// allocation. Returns `(snapshot, cache_hit)`.
287 pub fn snapshot(&mut self) -> (Arc<[CallFrame]>, bool) {
288 if let Some(ref cached) = self.cached_snapshot {
289 return (Arc::clone(cached), true);
290 }
291 let rc = match &self.inherited {
292 None => Arc::from(self.own.as_slice()),
293 Some(prefix) if self.own.is_empty() => Arc::clone(prefix),
294 Some(prefix) => {
295 let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
296 combined.extend_from_slice(prefix);
297 combined.extend_from_slice(&self.own);
298 Arc::from(combined)
299 }
300 };
301 self.cached_snapshot = Some(Arc::clone(&rc));
302 (rc, false)
303 }
304
305 /// Flatten inherited prefix into `own`. Returns `true` if work was done.
306 fn materialize(&mut self) -> bool {
307 self.cached_snapshot = None;
308 if let Some(prefix) = self.inherited.take() {
309 let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
310 combined.extend_from_slice(&prefix);
311 combined.append(&mut self.own);
312 self.own = combined;
313 self.materialization_count += 1;
314 true
315 } else {
316 false
317 }
318 }
319}
320
321/// A single execution thread with its own call stack.
322#[derive(Debug, Clone)]
323pub(crate) struct Thread {
324 pub call_stack: CallStack,
325}
326
327/// How the choice display text is stored internally.
328#[derive(Debug, Clone)]
329pub(crate) enum ChoiceDisplay {
330 /// Eagerly resolved text (legacy path, converter, or non-fragment codegen).
331 Text(String),
332 /// Index into the output buffer's fragment store — resolved on demand.
333 Fragment(u32),
334}
335
336#[derive(Debug, Clone)]
337pub(crate) struct PendingChoice {
338 pub display: ChoiceDisplay,
339 pub target_id: DefinitionId,
340 pub target_idx: u32,
341 pub target_offset: usize,
342 pub flags: ChoiceFlags,
343 #[expect(
344 dead_code,
345 reason = "needs research — likely needed for structured output / voice acting"
346 )]
347 pub original_index: usize,
348 /// Tags collected during choice evaluation.
349 pub tags: Vec<String>,
350 /// Snapshot of the current thread at choice creation time, so that
351 /// selecting this choice can restore the execution context
352 /// (including temp variables from enclosing tunnels/functions).
353 pub thread_fork: Thread,
354}
355
356/// Per-flow execution context. Owns threads, eval stack, output, choices.
357#[derive(Debug, Clone)]
358#[expect(
359 clippy::struct_excessive_bools,
360 reason = "VM flags are inherently boolean"
361)]
362pub(crate) struct Flow {
363 pub threads: Vec<Thread>,
364 pub value_stack: Vec<Value>,
365 pub output: OutputBuffer,
366 pub pending_choices: Vec<PendingChoice>,
367 pub current_tags: Vec<String>,
368 pub in_tag: bool,
369 pub skipping_choice: bool,
370 /// Set to `true` when a `Done` opcode fires (explicit `-> DONE`).
371 /// Cleared at the start of each `continue_single` call.
372 pub did_safe_exit: bool,
373 /// Set to `true` when a `Yield` opcode falls through with no
374 /// pending choices — the story passed through an empty choice set.
375 /// Cleared at the start of each `continue_single` call.
376 pub did_unsafe_yield: bool,
377}
378
379impl Flow {
380 /// Returns a reference to the current (topmost) thread.
381 ///
382 /// # Panics
383 ///
384 /// Panics if the thread stack is empty. This is a programming error —
385 /// flows are always constructed with at least one thread.
386 #[expect(clippy::expect_used)]
387 pub fn current_thread(&self) -> &Thread {
388 self.threads
389 .last()
390 .expect("flow must always have at least one thread")
391 }
392
393 /// Returns a mutable reference to the current (topmost) thread.
394 ///
395 /// # Panics
396 ///
397 /// Panics if the thread stack is empty. This is a programming error —
398 /// flows are always constructed with at least one thread.
399 #[expect(clippy::expect_used)]
400 pub fn current_thread_mut(&mut self) -> &mut Thread {
401 self.threads
402 .last_mut()
403 .expect("flow must always have at least one thread")
404 }
405
406 pub fn can_pop_thread(&self) -> bool {
407 self.threads.len() > 1
408 }
409
410 /// Returns `true` if a `FunctionEvalFromGame` boundary frame is present
411 /// in the current thread's call stack — i.e. an engine→ink function
412 /// evaluation is still in progress. Functions don't fork threads, so
413 /// the current thread is where the boundary lives. The eval driver
414 /// uses this to detect when the function has returned (boundary popped).
415 pub fn has_eval_boundary(&self) -> bool {
416 let cs = &self.current_thread().call_stack;
417 (0..cs.len())
418 .filter_map(|i| cs.get(i))
419 .any(|f| f.frame_type == CallFrameType::FunctionEvalFromGame)
420 }
421
422 pub fn pop_thread(&mut self) {
423 self.threads.pop();
424 }
425
426 /// Fork a new thread from the current one. Returns `(thread, snapshot_cache_hit)`.
427 pub fn fork_thread(&mut self) -> (Thread, bool) {
428 let (shared, cache_hit) = self.current_thread_mut().call_stack.snapshot();
429 (
430 Thread {
431 call_stack: CallStack {
432 inherited: Some(shared),
433 own: Vec::new(),
434 cached_snapshot: None,
435 materialization_count: 0,
436 },
437 },
438 cache_hit,
439 )
440 }
441
442 /// Drain materialization counts from all thread call stacks.
443 pub fn drain_materializations(&mut self) -> u64 {
444 let mut total = 0;
445 for thread in &mut self.threads {
446 total += thread.call_stack.materialization_count;
447 thread.call_stack.materialization_count = 0;
448 }
449 total
450 }
451
452 /// Read the arguments from the top External frame.
453 pub fn external_args(&self) -> &[Value] {
454 let frame = self.current_thread().call_stack.last();
455 match frame {
456 Some(f) if f.frame_type == CallFrameType::External => &f.temps,
457 _ => &[],
458 }
459 }
460
461 /// Read the external function's `DefinitionId` from the top External frame.
462 pub fn external_fn_id(&self) -> Option<DefinitionId> {
463 let frame = self.current_thread().call_stack.last()?;
464 if frame.frame_type == CallFrameType::External {
465 frame.external_fn_id
466 } else {
467 None
468 }
469 }
470
471 /// Resolve an external call: pop the External frame and push the
472 /// return value onto the value stack.
473 pub fn resolve_external(&mut self, value: Value) {
474 let thread = self.current_thread_mut();
475 if let Some(frame) = thread.call_stack.last()
476 && frame.frame_type == CallFrameType::External
477 {
478 let ret_addr = frame.return_address;
479 thread.call_stack.pop();
480 self.value_stack.push(value);
481 // Restore position from return address (if any).
482 if let Some(pos) = ret_addr
483 && let Some(f) = self.current_thread_mut().call_stack.last_mut()
484 && let Some(top) = f.container_stack.last_mut()
485 {
486 *top = pos;
487 }
488 }
489 }
490
491 /// Replace the External frame with a Function frame pointing at the
492 /// fallback container. Args are pushed back onto the value stack so
493 /// the fallback body's `temp=` opcodes can pop them.
494 pub fn invoke_fallback(&mut self, container_idx: u32) {
495 let output_start = self.output.target_len();
496 let thread = self.current_thread_mut();
497 if let Some(frame) = thread.call_stack.last_mut()
498 && frame.frame_type == CallFrameType::External
499 {
500 let args = core::mem::take(&mut frame.temps);
501 frame.frame_type = CallFrameType::Function;
502 frame.container_stack = vec![ContainerPosition {
503 container_idx,
504 offset: 0,
505 }];
506 frame.external_fn_id = None;
507 frame.function_output_start = Some(output_start);
508 // Push args back onto the value stack — the fallback body
509 // starts with `temp=` instructions that pop them.
510 self.value_stack.extend(args);
511 }
512 }
513
514 /// Pop a value from the value stack.
515 pub fn pop_value(&mut self) -> Result<Value, RuntimeError> {
516 self.value_stack.pop().ok_or(RuntimeError::StackUnderflow)
517 }
518
519 /// Peek at the top value without popping.
520 pub fn peek_value(&self) -> Result<&Value, RuntimeError> {
521 self.value_stack.last().ok_or(RuntimeError::StackUnderflow)
522 }
523}
524
525/// Result of an external function handler call.
526#[derive(Debug, Clone)]
527pub enum ExternalResult {
528 /// The handler resolved the call and returned a value.
529 /// `Value::Null` is valid for fire-and-forget calls.
530 Resolved(Value),
531 /// The handler declined — use the ink fallback body if available.
532 Fallback,
533 /// The handler cannot resolve the call yet (async resolution).
534 /// The VM freezes with the `External` frame intact. The caller must
535 /// resolve via `story.resolve_external(value)` before continuing.
536 Pending,
537}
538
539/// Trait for handling external function calls from ink.
540///
541/// Implement this to provide runtime-injected external function behavior.
542/// The orchestration layer calls [`call`](ExternalFnHandler::call) when the
543/// VM encounters a `CallExternal` opcode. The handler can resolve the call
544/// immediately, decline to handle it (triggering fallback), or in the future,
545/// indicate that resolution is pending (async/WASM).
546pub trait ExternalFnHandler {
547 /// Handle an external function call.
548 ///
549 /// `name` is the ink-declared function name. `args` are the values
550 /// popped from the value stack, in declaration order.
551 fn call(&self, name: &str, args: &[Value]) -> ExternalResult;
552}
553
554/// Default handler that always falls back to the ink function body.
555///
556/// Use this as the `handler` argument to [`FlowInstance::step_single_line`]
557/// or [`FlowInstance::choose`] when you don't want to provide a custom
558/// external-function binding registry. Every external call returns
559/// [`ExternalResult::Fallback`], delegating to the in-story fallback
560/// container declared on the `EXTERNAL` declaration.
561pub struct FallbackHandler;
562
563impl ExternalFnHandler for FallbackHandler {
564 fn call(&self, _name: &str, _args: &[Value]) -> ExternalResult {
565 ExternalResult::Fallback
566 }
567}
568
569/// Outcome of an engine→ink function evaluation
570/// ([`FlowInstance::begin_function_eval`] / [`resume_function_eval`](FlowInstance::resume_function_eval)).
571///
572/// Evaluating an ink function from engine code does not advance the
573/// player-visible story: its output is isolated and discarded, and the
574/// transcript is untouched. The only result is the function's return
575/// value — unless the function calls an external that can't be resolved
576/// synchronously.
577#[derive(Debug, Clone)]
578pub enum FunctionEval {
579 /// The function returned this value and evaluation is complete.
580 /// (Functions with no explicit `~ return` yield [`Value::Null`].)
581 Returned(Value),
582 /// The function called an external whose handler returned
583 /// [`ExternalResult::Pending`] — typically a binding that needs
584 /// engine/World access resolved out-of-band. Evaluation is paused
585 /// with its full state intact. Inspect the pending call via
586 /// [`pending_external_name`](FlowInstance::pending_external_name) /
587 /// [`pending_external_args`](FlowInstance::pending_external_args),
588 /// supply the result with
589 /// [`resolve_external`](FlowInstance::resolve_external), then call
590 /// [`resume_function_eval`](FlowInstance::resume_function_eval).
591 AwaitingExternal,
592}
593
594// ── FlowInstance ────────────────────────────────────────────────────────────
595
596/// A single independent execution context within a story. The default flow
597/// runs from the root container; named flows can be spawned at arbitrary
598/// entry points via [`FlowInstance::new_at`].
599///
600/// A `FlowInstance` is opaque from outside the crate: its internal fields
601/// (`flow`, `status`, `stats`) are crate-private, but consumers can hold,
602/// clone, serialize, and pass `&mut FlowInstance` to the runtime's step
603/// functions. Use the inherent methods ([`step_single_line`](Self::step_single_line),
604/// [`choose`](Self::choose), [`transcript`](Self::transcript),
605/// [`status`](Self::status), etc.) for all interaction.
606#[derive(Clone, Debug)]
607pub struct FlowInstance {
608 pub(crate) flow: Flow,
609 pub(crate) status: StoryStatus,
610 pub(crate) stats: Stats,
611 /// Transient state for an in-progress engine→ink function evaluation
612 /// ([`begin_function_eval`](Self::begin_function_eval)). `Some` only
613 /// while a from-game call is mid-flight (possibly paused on an
614 /// external); `None` during normal play. Not meaningful to persist.
615 pub(crate) eval: Option<EvalState>,
616}
617
618/// Bookkeeping for an in-progress engine→ink function evaluation.
619#[derive(Debug, Clone)]
620pub(crate) struct EvalState {
621 /// Value-stack length recorded before arguments were pushed, so the
622 /// return value (and any leftover args) can be reclaimed on return.
623 pub value_floor: usize,
624 /// Pending-choice count when the eval began. A function that *grows*
625 /// this presented a choice — illegal, and distinct from choices the
626 /// main story may already have waiting.
627 pub choice_floor: usize,
628}
629
630/// Outcome of a single [`FlowInstance::drive`] call: either the drive
631/// reached a terminal line, or it paused on a deferred external mid-drive.
632/// Both variants carry every [`Line`] produced during *this* call — for
633/// `AwaitingExternal`, that's the (possibly empty) run of `Line::Text`
634/// produced before the pause; for `Terminal`, the terminal line is always
635/// the last element (see [`FlowInstance::drive`]).
636#[derive(Debug, Clone)]
637pub enum DriveOutcome {
638 /// Reached a terminal line ([`Line::Done`], [`Line::Choices`], or
639 /// [`Line::End`]) — always the last element of the `Vec`.
640 Terminal(Vec<Line>),
641 /// Paused on a deferred external
642 /// ([`ExternalResult::Pending`](crate::ExternalResult::Pending)).
643 /// Resolve it ([`FlowInstance::resolve_external`]) and call
644 /// [`FlowInstance::drive`] again — with the **same** `budget` — to
645 /// resume.
646 AwaitingExternal(Vec<Line>),
647}
648
649impl FlowInstance {
650 /// Create a new flow instance starting at the program's root container,
651 /// along with a fresh [`World`] initialized from the program's global
652 /// defaults.
653 pub fn new_at_root(program: &Program) -> (Self, World) {
654 Self::new_at(program, program.root_idx())
655 }
656
657 /// Create a new flow instance starting at an arbitrary container index,
658 /// along with a fresh [`World`]. Use this to spawn a named flow at a
659 /// specific entry point. The caller is responsible for deciding whether
660 /// to share the returned `World` with other flows or discard it and
661 /// reuse an existing one.
662 pub fn new_at(program: &Program, container_idx: u32) -> (Self, World) {
663 let globals = program.global_defaults();
664 let initial_frame = CallFrame {
665 return_address: None,
666 temps: Vec::new(),
667 container_stack: vec![ContainerPosition {
668 container_idx,
669 offset: 0,
670 }],
671 frame_type: CallFrameType::Root,
672 external_fn_id: None,
673 function_output_start: None,
674 };
675 let initial_thread = Thread {
676 call_stack: CallStack::new(initial_frame),
677 };
678 let flow_instance = Self {
679 flow: Flow {
680 threads: vec![initial_thread],
681 value_stack: Vec::new(),
682 output: OutputBuffer::new(),
683 pending_choices: Vec::new(),
684 current_tags: Vec::new(),
685 in_tag: false,
686 skipping_choice: false,
687 did_safe_exit: false,
688 did_unsafe_yield: false,
689 },
690 status: StoryStatus::Active,
691 stats: Stats::default(),
692 eval: None,
693 };
694 // All existing construction paths default to the all-`World`
695 // policy (see `docs/scoped-flow-state-spec.md` "The policy") — this
696 // is the fast path that needs no `Program` symbol lookups and
697 // can't fail, so `new_at`/`new_at_root` keep their infallible
698 // `(Self, World)` signature.
699 let world = World::from_globals(globals, ResolvedPolicy::all_world());
700 (flow_instance, world)
701 }
702
703 /// Maximum VM steps per `continue_maximally` call before erroring.
704 /// Prevents infinite loops from malformed bytecode.
705 const STEP_LIMIT: u64 = 1_000_000;
706
707 /// Execute until one complete line of output is available, or until a
708 /// yield point (choices/done/ended) if no newline occurs first.
709 ///
710 /// Returns a [`Line`] telling the caller what happened (`Text`/`Done`/
711 /// `Choices`/`End`). This is the simple API for consumers whose
712 /// external handler never defers: if the handler returns
713 /// [`ExternalResult::Pending`], this errors with
714 /// [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall).
715 /// For pausable world-access bindings, use [`advance`](Self::advance).
716 pub fn step_single_line<R: StoryRng>(
717 &mut self,
718 program: &Program,
719 line_tables: &[Vec<brink_format::LineEntry>],
720 context: &mut (impl ContextAccess + ?Sized),
721 handler: &dyn ExternalFnHandler,
722 resolver: Option<&dyn PluralResolver>,
723 ) -> Result<Line, RuntimeError> {
724 match self.advance::<R>(program, line_tables, context, handler, resolver)? {
725 StepOutcome::Line(line) => Ok(line),
726 StepOutcome::AwaitingExternal => {
727 // Preserve historical behavior for consumers using this
728 // (non-pausing) API: a deferred external they can't resolve
729 // is an error.
730 let id = self
731 .flow
732 .external_fn_id()
733 .ok_or(RuntimeError::CallStackUnderflow)?;
734 Err(RuntimeError::UnresolvedExternalCall(id))
735 }
736 }
737 }
738
739 /// Maximum lines produced by a single [`drive_to_terminal`](Self::drive_to_terminal)
740 /// call before erroring. Safety net against infinite loops from
741 /// malformed bytecode.
742 pub const LINE_LIMIT: usize = 10_000;
743
744 /// Step this flow forward until the next terminal line (`Done`,
745 /// `Choices`, or `End`), collecting every [`Line`] produced along the
746 /// way.
747 ///
748 /// This is the single Layer-2 "drive to terminal" loop: [`Story`]'s
749 /// `continue_maximally*` family is a thin wrapper over it, and any other
750 /// holder of a `FlowInstance` (e.g. an engine integration like
751 /// `bevy-brink`) should reach for this instead of hand-rolling the same
752 /// loop. Semantics:
753 ///
754 /// - Steps via [`step_single_line`](Self::step_single_line): a deferred
755 /// external ([`ExternalResult::Pending`]) is **not** paused on here —
756 /// it errors with [`RuntimeError::UnresolvedExternalCall`], exactly as
757 /// `step_single_line` does. Callers that need to pause on world-access
758 /// externals mid-drive should drive [`advance`](Self::advance)
759 /// themselves rather than use this method.
760 /// - Stops at the first [`Line`] for which [`Line::is_terminal`] returns
761 /// `true`; that line is always the last element of the returned
762 /// `Vec`, and every element before it is a [`Line::Text`].
763 /// - Bounded by [`Self::LINE_LIMIT`] (10,000) lines produced in a single
764 /// call; exceeding it returns [`RuntimeError::LineLimitExceeded`]
765 /// rather than looping forever.
766 ///
767 /// # Errors
768 /// Any error [`step_single_line`](Self::step_single_line) itself can
769 /// produce, plus [`RuntimeError::LineLimitExceeded`] if the drive
770 /// produces [`Self::LINE_LIMIT`] lines without reaching a terminal one.
771 pub fn drive_to_terminal<R: StoryRng>(
772 &mut self,
773 program: &Program,
774 line_tables: &[Vec<brink_format::LineEntry>],
775 context: &mut (impl ContextAccess + ?Sized),
776 handler: &dyn ExternalFnHandler,
777 resolver: Option<&dyn PluralResolver>,
778 ) -> Result<Vec<Line>, RuntimeError> {
779 let mut lines = Vec::new();
780 loop {
781 let line =
782 self.step_single_line::<R>(program, line_tables, context, handler, resolver)?;
783 let terminal = line.is_terminal();
784 lines.push(line);
785 if terminal {
786 return Ok(lines);
787 }
788 if lines.len() >= Self::LINE_LIMIT {
789 return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
790 }
791 }
792 }
793
794 /// The pausable Layer-2 "drive to terminal or external pause" op:
795 /// [`drive_to_terminal`](Self::drive_to_terminal)'s sibling for callers
796 /// (e.g. `bevy-brink`) whose external bindings need to pause mid-drive
797 /// for out-of-band (world-access) resolution rather than erroring.
798 ///
799 /// Steps via [`advance`](Self::advance) instead of
800 /// [`step_single_line`](Self::step_single_line): a deferred external
801 /// yields [`DriveOutcome::AwaitingExternal`] (carrying every line
802 /// produced so far this call) instead of
803 /// [`RuntimeError::UnresolvedExternalCall`]. Resolve it and call `drive`
804 /// again to continue — the drive is logically one operation spanning
805 /// however many pauses it takes.
806 ///
807 /// `budget` is the caller-owned line budget for that whole logical
808 /// operation: each line `drive` produces (whether the call ends in
809 /// `Terminal` or `AwaitingExternal`) decrements it by one, and it is
810 /// **not** reset between calls — the caller passes the same `&mut
811 /// usize` back in on resume, so a drive spanning many external pauses
812 /// still has exactly one bound on total output, not a fresh
813 /// [`Self::LINE_LIMIT`] per resume (see the "guard against unbounded
814 /// growth" rule). Start a fresh logical drive with a fresh
815 /// `budget = FlowInstance::LINE_LIMIT` (or any caller-chosen cap).
816 ///
817 /// Like `drive_to_terminal`, the terminal line is always the last
818 /// element of the returned `Vec` and every line before it is
819 /// [`Line::Text`].
820 ///
821 /// # Errors
822 /// Any error [`advance`](Self::advance) itself can produce, plus
823 /// [`RuntimeError::LineLimitExceeded`] if `budget` reaches zero before a
824 /// terminal line is produced.
825 pub fn drive<R: StoryRng>(
826 &mut self,
827 program: &Program,
828 line_tables: &[Vec<brink_format::LineEntry>],
829 context: &mut (impl ContextAccess + ?Sized),
830 handler: &dyn ExternalFnHandler,
831 resolver: Option<&dyn PluralResolver>,
832 budget: &mut usize,
833 ) -> Result<DriveOutcome, RuntimeError> {
834 // Captured only to report a meaningful number on exhaustion: the
835 // remaining budget *this call* started with, not the (possibly
836 // already-partially-spent, across earlier resumes) original cap.
837 let starting_budget = *budget;
838 let mut lines = Vec::new();
839 loop {
840 if *budget == 0 {
841 return Err(RuntimeError::LineLimitExceeded(starting_budget));
842 }
843 match self.advance::<R>(program, line_tables, context, handler, resolver)? {
844 StepOutcome::AwaitingExternal => return Ok(DriveOutcome::AwaitingExternal(lines)),
845 StepOutcome::Line(line) => {
846 let terminal = line.is_terminal();
847 *budget -= 1;
848 lines.push(line);
849 if terminal {
850 return Ok(DriveOutcome::Terminal(lines));
851 }
852 }
853 }
854 }
855 }
856
857 /// Like [`step_single_line`](Self::step_single_line), but surfaces a
858 /// deferred external ([`ExternalResult::Pending`]) as
859 /// [`StepOutcome::AwaitingExternal`] instead of an error — so a
860 /// world-access binding hit during normal playback can pause cleanly.
861 /// Resolve the pending external and call `advance` again to continue.
862 pub fn advance<R: StoryRng>(
863 &mut self,
864 program: &Program,
865 line_tables: &[Vec<brink_format::LineEntry>],
866 context: &mut (impl ContextAccess + ?Sized),
867 handler: &dyn ExternalFnHandler,
868 resolver: Option<&dyn PluralResolver>,
869 ) -> Result<StepOutcome, RuntimeError> {
870 self.advance_with_limit::<R>(
871 program,
872 line_tables,
873 context,
874 handler,
875 resolver,
876 Self::STEP_LIMIT,
877 )
878 }
879
880 /// Like [`advance`](Self::advance), but the per-call VM step budget is
881 /// `step_limit` rather than the hardcoded [`Self::STEP_LIMIT`].
882 ///
883 /// This is what lets [`crate::Speculation::advance`] cap a single
884 /// visible-line drive at a small, caller-supplied budget instead of the
885 /// production 1,000,000-step ceiling, so a runaway speculative probe
886 /// errors quickly instead of burning a huge step budget before giving
887 /// up. `advance` itself is a thin wrapper over this with
888 /// `step_limit: Self::STEP_LIMIT` — every existing call site keeps its
889 /// exact prior behavior.
890 #[expect(clippy::too_many_lines)]
891 pub(crate) fn advance_with_limit<R: StoryRng>(
892 &mut self,
893 program: &Program,
894 line_tables: &[Vec<brink_format::LineEntry>],
895 context: &mut (impl ContextAccess + ?Sized),
896 handler: &dyn ExternalFnHandler,
897 resolver: Option<&dyn PluralResolver>,
898 step_limit: u64,
899 ) -> Result<StepOutcome, RuntimeError> {
900 // 1. If buffer already has a completed line from a previous step,
901 // take it immediately (no VM stepping needed).
902 if self.flow.output.has_completed_line()
903 && let Some((text, tags)) =
904 self.flow
905 .output
906 .take_first_line(program, line_tables, resolver)
907 {
908 return Ok(StepOutcome::Line(Line::Text { text, tags }));
909 }
910
911 // 2. If buffer has partial content but VM has already yielded
912 // (any non-Active state), flush it. At a yield point, no more
913 // output is coming, so trailing Newlines are committed.
914 if self.flow.output.has_unread() && self.status != StoryStatus::Active {
915 let (text, tags) = flush_remaining(&mut self.flow, program, line_tables, resolver);
916 return Ok(StepOutcome::Line(make_yield_line(
917 self.status,
918 text,
919 tags,
920 &self.flow,
921 program,
922 line_tables,
923 resolver,
924 )));
925 }
926
927 // 3. Status checks.
928 if self.status == StoryStatus::Ended {
929 return Err(RuntimeError::StoryEnded);
930 }
931 if self.status == StoryStatus::WaitingForChoice {
932 return Err(RuntimeError::NotWaitingForChoice);
933 }
934
935 // 4. Reset Done → Active (resuming after output).
936 // If the previous cycle ended without a safe exit (no explicit
937 // -> DONE opcode), the story ran out of content. The previous
938 // call delivered the text — error now.
939 if self.status == StoryStatus::Done {
940 if !self.flow.did_safe_exit {
941 return Err(RuntimeError::RanOutOfContent);
942 }
943 self.status = StoryStatus::Active;
944 }
945
946 // Clear flags — will be set during this cycle if relevant.
947 self.flow.did_safe_exit = false;
948 self.flow.did_unsafe_yield = false;
949
950 // 5. Step VM loop.
951 let Self {
952 flow,
953 status,
954 stats,
955 ..
956 } = self;
957 let step_start = stats.steps;
958
959 loop {
960 stats.steps += 1;
961
962 if stats.steps - step_start > step_limit {
963 return Err(RuntimeError::StepLimitExceeded(step_limit));
964 }
965
966 let stepped = vm::step::<R>(flow, program, line_tables, context, stats, resolver)?;
967 stats.materializations += flow.drain_materializations();
968
969 match stepped {
970 vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {
971 if flow.output.has_completed_line()
972 && let Some((text, tags)) =
973 flow.output.take_first_line(program, line_tables, resolver)
974 {
975 return Ok(StepOutcome::Line(Line::Text { text, tags }));
976 }
977 }
978
979 vm::Stepped::ExternalCall => {
980 // `false` means the handler deferred (Pending): pause
981 // cleanly so the caller can resolve it out-of-band.
982 if !resolve_external_call(flow, program, handler)? {
983 return Ok(StepOutcome::AwaitingExternal);
984 }
985 if flow.output.has_completed_line()
986 && let Some((text, tags)) =
987 flow.output.take_first_line(program, line_tables, resolver)
988 {
989 return Ok(StepOutcome::Line(Line::Text { text, tags }));
990 }
991 }
992
993 vm::Stepped::Done => {
994 context.increment_turn_index();
995
996 // Handle invisible default choices: auto-select and keep running.
997 if !flow.pending_choices.is_empty() {
998 let all_invisible = flow
999 .pending_choices
1000 .iter()
1001 .all(|pc| pc.flags.is_invisible_default);
1002 if all_invisible {
1003 select_choice(flow, context, status, stats, 0)?;
1004 if flow.output.has_completed_line()
1005 && let Some((text, tags)) =
1006 flow.output.take_first_line(program, line_tables, resolver)
1007 {
1008 return Ok(StepOutcome::Line(Line::Text { text, tags }));
1009 }
1010 continue;
1011 }
1012 }
1013
1014 // Set status based on remaining choices.
1015 if flow.pending_choices.is_empty() {
1016 *status = StoryStatus::Done;
1017 } else {
1018 *status = StoryStatus::WaitingForChoice;
1019 stats.choices_presented += 1;
1020 }
1021
1022 if flow.output.has_completed_line()
1023 && let Some((text, tags)) =
1024 flow.output.take_first_line(program, line_tables, resolver)
1025 {
1026 return Ok(StepOutcome::Line(Line::Text { text, tags }));
1027 }
1028
1029 let (text, tags) = flush_remaining(flow, program, line_tables, resolver);
1030 return Ok(StepOutcome::Line(make_yield_line(
1031 *status,
1032 text,
1033 tags,
1034 flow,
1035 program,
1036 line_tables,
1037 resolver,
1038 )));
1039 }
1040
1041 vm::Stepped::Ended => {
1042 context.increment_turn_index();
1043 *status = StoryStatus::Ended;
1044
1045 if flow.output.has_completed_line()
1046 && let Some((text, tags)) =
1047 flow.output.take_first_line(program, line_tables, resolver)
1048 {
1049 return Ok(StepOutcome::Line(Line::Text { text, tags }));
1050 }
1051
1052 let (text, tags) = flush_remaining(flow, program, line_tables, resolver);
1053 return Ok(StepOutcome::Line(Line::End { text, tags }));
1054 }
1055 }
1056 }
1057 }
1058
1059 /// Select a choice by index. Call [`step_single_line`](Self::step_single_line)
1060 /// afterward to continue execution from the chosen branch.
1061 pub fn choose(
1062 &mut self,
1063 context: &mut (impl ContextAccess + ?Sized),
1064 index: usize,
1065 ) -> Result<(), RuntimeError> {
1066 if self.status != StoryStatus::WaitingForChoice {
1067 return Err(RuntimeError::NotWaitingForChoice);
1068 }
1069 select_choice(
1070 &mut self.flow,
1071 context,
1072 &mut self.status,
1073 &mut self.stats,
1074 index,
1075 )
1076 }
1077
1078 /// Move the play head to a named knot/stitch path — the equivalent of
1079 /// ink's `Story.ChoosePathString(path)` (with its default
1080 /// `resetCallstack: true`). Call [`step_single_line`](Self::step_single_line)
1081 /// (or any continue method) afterward to run from there.
1082 ///
1083 /// `path` is a dot-separated runtime path: a knot (`intro`), a qualified
1084 /// stitch (`intro.dock`), or — for programs compiled by `brink-compiler` —
1085 /// an author label (`knot.label`, `knot.stitch.label`; an extension over
1086 /// C#, which cannot address labels).
1087 ///
1088 /// Mirroring the C# reference (`Story.ChoosePathString` →
1089 /// `ResetCallstack`/`ForceEnd` → `ChoosePath` → `state.SetChosenPath` +
1090 /// `VisitChangedContainersDueToDivert`):
1091 ///
1092 /// - The current flow is **force-completed** first: the call stack
1093 /// collapses to a single fresh root frame (abandoning any tunnels,
1094 /// threads, or in-progress weave), pending choices are cleared, and
1095 /// the jump counts as a safe exit (as if the story had hit `-> DONE`).
1096 /// - The jump **counts as a visit** to the target, with exactly the
1097 /// semantics of an in-story `-> path` divert (it goes through the same
1098 /// goto machinery, so counting flags are honored identically).
1099 /// - Output already produced but not yet consumed is **kept** (C# leaves
1100 /// the output stream untouched); it is delivered before content from
1101 /// the new location. The value stack is likewise left as-is.
1102 /// - A permanently **ended** story (`-> END`) may be re-entered by
1103 /// jumping, matching C# where `ChoosePathString` + `Continue` works
1104 /// after the story has ended.
1105 ///
1106 /// # Errors
1107 /// - [`UnknownPath`](RuntimeError::UnknownPath) if `path` resolves to no
1108 /// target (the message names the path).
1109 /// - [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
1110 /// if the flow is parked on an unresolved external call — a pending
1111 /// host call must be resolved, not silently abandoned.
1112 /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1113 /// if an engine→ink function evaluation is in progress (C# likewise
1114 /// refuses to redirect mid-function).
1115 pub fn choose_path_string(
1116 &mut self,
1117 program: &Program,
1118 context: &mut (impl ContextAccess + ?Sized),
1119 path: &str,
1120 ) -> Result<(), RuntimeError> {
1121 self.choose_path_string_with_args(program, context, path, &[])
1122 }
1123
1124 /// Like [`choose_path_string`](Self::choose_path_string) but **binds the
1125 /// target knot's declared parameters** from `args` — host-directed entry
1126 /// into a parameterized knot/stitch (`=== call(action, present) ===`),
1127 /// which a plain path jump can't reach with its params bound.
1128 ///
1129 /// Semantics are otherwise identical to `choose_path_string` (force-ends
1130 /// the current flow, counts as a visit, etc.). The args are pushed onto the
1131 /// value stack in declaration order and bound by the target's prologue —
1132 /// exactly as an in-story `-> call(a, b)` divert binds them, so this enters
1133 /// at the container start (where the prologue runs).
1134 ///
1135 /// # Errors
1136 /// In addition to [`choose_path_string`](Self::choose_path_string)'s errors:
1137 /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) if `args.len()`
1138 /// differs from the target container's declared parameter count. (Programs
1139 /// built by the converter record no param counts, so they report `0` — pass
1140 /// no args.)
1141 pub fn choose_path_string_with_args(
1142 &mut self,
1143 program: &Program,
1144 context: &mut (impl ContextAccess + ?Sized),
1145 path: &str,
1146 args: &[Value],
1147 ) -> Result<(), RuntimeError> {
1148 // A parked host call cannot be silently abandoned: erroring is the
1149 // strictest safe behavior (brink-specific — C# has no pausable
1150 // externals during normal playback).
1151 if let Some(id) = self.flow.external_fn_id() {
1152 let external = program
1153 .external_fn(id)
1154 .map_or_else(|| format!("{id}"), |e| program.name(e.name).to_owned());
1155 return Err(RuntimeError::JumpWhileAwaitingExternal {
1156 path: path.to_owned(),
1157 external,
1158 });
1159 }
1160 // An in-flight engine→ink evaluation (possibly paused on an external)
1161 // must finish or be aborted before the flow can be redirected.
1162 if self.eval.is_some() {
1163 return Err(RuntimeError::AlreadyEvaluatingFunction);
1164 }
1165
1166 let target_id = program
1167 .find_path_target(path)
1168 .ok_or_else(|| RuntimeError::UnknownPath(path.to_owned()))?;
1169
1170 // Arity-check before mutating any state. The target container's
1171 // declared param count is what its prologue's `DeclareTemp`s will pop.
1172 let expected = program.path_param_count(path).unwrap_or(0);
1173 if args.len() != expected as usize {
1174 return Err(RuntimeError::ArgCountMismatch {
1175 target: path.to_owned(),
1176 expected,
1177 got: args.len(),
1178 });
1179 }
1180
1181 // Force-end the current flow, mirroring C# `ResetCallstack` →
1182 // `StoryState.ForceEnd`: a single fresh root frame (callStack.Reset),
1183 // cleared choices, null pointers (the empty container stack), and
1184 // didSafeExit = true. The output buffer and value stack are
1185 // deliberately left untouched — C# `ForceEnd` does not clear the
1186 // output stream or the evaluation stack.
1187 let root_frame = CallFrame {
1188 return_address: None,
1189 temps: Vec::new(),
1190 container_stack: Vec::new(),
1191 frame_type: CallFrameType::Root,
1192 external_fn_id: None,
1193 function_output_start: None,
1194 };
1195 self.flow.threads = vec![Thread {
1196 call_stack: CallStack::new(root_frame),
1197 }];
1198 self.flow.pending_choices.clear();
1199 // Transient intra-step flags. Both are false at any point a host can
1200 // observe (between lines / at a yield), but the jump abandons whatever
1201 // produced them, so clear defensively.
1202 self.flow.skipping_choice = false;
1203 self.flow.in_tag = false;
1204 self.flow.did_safe_exit = true;
1205
1206 // Push the arguments in declaration order; the target's prologue
1207 // (`DeclareTemp`) binds them, exactly as `begin_function_eval` and an
1208 // in-story `-> call(a, b)` divert do.
1209 self.flow.value_stack.extend_from_slice(args);
1210
1211 // Jump via the same divert machinery as an in-story `-> path`
1212 // (mirrors C# `ChoosePath` → `SetChosenPath` +
1213 // `VisitChangedContainersDueToDivert`): sets the position and
1214 // increments the target's visit/turn counts per its counting flags.
1215 vm::goto_target(&mut self.flow, program, context, target_id)?;
1216
1217 self.status = StoryStatus::Active;
1218 Ok(())
1219 }
1220
1221 /// The current execution status of this flow.
1222 #[must_use]
1223 pub fn status(&self) -> StoryStatus {
1224 self.status
1225 }
1226
1227 /// Runtime statistics (instructions, materialization counts, etc.)
1228 /// accumulated over this flow's execution.
1229 #[must_use]
1230 pub fn stats(&self) -> &Stats {
1231 &self.stats
1232 }
1233
1234 /// The full append-only transcript of all output parts produced so far.
1235 ///
1236 /// The transcript stores structural references (e.g. `LineRef`) rather
1237 /// than resolved strings, so it can be re-rendered in any locale by
1238 /// passing a different set of line tables to
1239 /// [`transcript::render_transcript`](crate::transcript::render_transcript).
1240 #[must_use]
1241 pub fn transcript(&self) -> &[crate::output::OutputPart] {
1242 self.flow.output.transcript()
1243 }
1244
1245 /// Number of parts in the transcript.
1246 #[must_use]
1247 pub fn transcript_len(&self) -> usize {
1248 self.flow.output.transcript_len()
1249 }
1250
1251 /// Reset the transcript read cursor to the beginning (for re-rendering,
1252 /// e.g. after a locale swap).
1253 pub fn reset_cursor(&mut self) {
1254 self.flow.output.reset_cursor();
1255 }
1256
1257 /// The fragments captured during execution (for re-rendering choice
1258 /// display text and computed substrings in a different locale).
1259 #[must_use]
1260 pub fn fragments(&self) -> &[crate::output::Fragment] {
1261 self.flow.output.fragments()
1262 }
1263
1264 // ── External calls (ink → engine) ────────────────────────────────
1265
1266 /// Returns `true` if this flow is frozen on an unresolved external
1267 /// call — i.e. the VM hit a `CallExternal` opcode and the handler
1268 /// returned [`ExternalResult::Pending`], leaving the `External` frame
1269 /// on top of the call stack.
1270 ///
1271 /// The orchestration layer (e.g. a Bevy resolver system) polls this to
1272 /// decide whether the flow needs an external resolved before it can be
1273 /// driven further. Resolve via [`resolve_external`](Self::resolve_external).
1274 #[must_use]
1275 pub fn has_pending_external(&self) -> bool {
1276 self.flow.external_fn_id().is_some()
1277 }
1278
1279 /// The [`DefinitionId`] of the pending external function, if this flow
1280 /// is frozen on one. Returns `None` otherwise.
1281 #[must_use]
1282 pub fn pending_external_fn_id(&self) -> Option<DefinitionId> {
1283 self.flow.external_fn_id()
1284 }
1285
1286 /// The arguments to the pending external call, in declaration order.
1287 /// Empty if no external call is pending.
1288 #[must_use]
1289 pub fn pending_external_args(&self) -> &[Value] {
1290 self.flow.external_args()
1291 }
1292
1293 /// The ink-declared name of the pending external function, resolved
1294 /// against `program`'s name table. Returns `None` if no external is
1295 /// pending (or the entry is missing, which would indicate a malformed
1296 /// program).
1297 ///
1298 /// The orchestration layer uses this to look up the binding registered
1299 /// for this name.
1300 #[must_use]
1301 pub fn pending_external_name<'p>(&self, program: &'p Program) -> Option<&'p str> {
1302 let id = self.flow.external_fn_id()?;
1303 let entry = program.external_fn(id)?;
1304 Some(program.name(entry.name))
1305 }
1306
1307 /// Resolve a pending external call by supplying its return value. Pops
1308 /// the `External` frame and pushes `value` onto the value stack so the
1309 /// VM can resume. For fire-and-forget externals, pass [`Value::Null`].
1310 ///
1311 /// No-op if no external call is pending. After resolving, drive the
1312 /// flow forward with [`step_single_line`](Self::step_single_line).
1313 pub fn resolve_external(&mut self, value: Value) {
1314 self.flow.resolve_external(value);
1315 }
1316
1317 // ── Engine → ink calls ───────────────────────────────────────────
1318
1319 /// Evaluate an ink function from engine code, returning its value.
1320 ///
1321 /// This does **not** advance the player-visible story: a
1322 /// `FunctionEvalFromGame` boundary frame is pushed, `args` are passed
1323 /// in declaration order (exactly as a normal call site would), output
1324 /// is captured and discarded, and the function runs until it returns.
1325 ///
1326 /// If the function calls an external whose handler returns
1327 /// [`ExternalResult::Pending`] (e.g. a binding that needs Bevy World
1328 /// access), evaluation pauses and returns
1329 /// [`FunctionEval::AwaitingExternal`]; the caller resolves the
1330 /// external (see [`resolve_external`](Self::resolve_external)) and
1331 /// calls [`resume_function_eval`](Self::resume_function_eval).
1332 ///
1333 /// `container_idx` is the function's container, typically obtained from
1334 /// [`Program::find_address`](crate::Program::find_address) on the
1335 /// function name. Unlike a normal `Call`, this does not increment the
1336 /// function's visit count — an engine query is out-of-band, matching
1337 /// C#'s `EvaluateFunction`.
1338 ///
1339 /// # Errors
1340 /// - [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
1341 /// if a function evaluation is already in progress on this flow.
1342 /// - [`FunctionYielded`](RuntimeError::FunctionYielded) if the function
1343 /// presents choices or ends the story (functions must not yield).
1344 /// - [`UnresolvedExternalCall`](RuntimeError::UnresolvedExternalCall)
1345 /// if an external has neither a binding nor a fallback.
1346 #[expect(
1347 clippy::too_many_arguments,
1348 reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target and args"
1349 )]
1350 pub fn begin_function_eval<R: StoryRng>(
1351 &mut self,
1352 program: &Program,
1353 line_tables: &[Vec<brink_format::LineEntry>],
1354 context: &mut (impl ContextAccess + ?Sized),
1355 handler: &dyn ExternalFnHandler,
1356 container_idx: u32,
1357 args: &[Value],
1358 resolver: Option<&dyn PluralResolver>,
1359 ) -> Result<FunctionEval, RuntimeError> {
1360 if self.eval.is_some() {
1361 return Err(RuntimeError::AlreadyEvaluatingFunction);
1362 }
1363
1364 // Record floors BEFORE pushing args: the value-stack length (so the
1365 // return value and any leftover args can be reclaimed), and the
1366 // pending-choice count (so we can tell a choice the function
1367 // presents from choices the main story already has waiting).
1368 let value_floor = self.flow.value_stack.len();
1369 let choice_floor = self.flow.pending_choices.len();
1370
1371 // Isolate output: anything the function emits routes to the
1372 // capture scratch space and never reaches the transcript.
1373 self.flow.output.begin_capture();
1374
1375 let output_start = self.flow.output.target_len();
1376 let boundary = CallFrame {
1377 return_address: None,
1378 temps: Vec::new(),
1379 container_stack: vec![ContainerPosition {
1380 container_idx,
1381 offset: 0,
1382 }],
1383 frame_type: CallFrameType::FunctionEvalFromGame,
1384 external_fn_id: None,
1385 function_output_start: Some(output_start),
1386 };
1387 self.flow.current_thread_mut().call_stack.push(boundary);
1388 self.stats.frames_pushed += 1;
1389
1390 // Pass arguments onto the value stack in declaration order — the
1391 // function's prologue (`DeclareTemp`) binds them exactly as it
1392 // would for an in-story call.
1393 self.flow.value_stack.extend_from_slice(args);
1394
1395 self.eval = Some(EvalState {
1396 value_floor,
1397 choice_floor,
1398 });
1399 self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
1400 }
1401
1402 /// Resume a function evaluation that paused on
1403 /// [`FunctionEval::AwaitingExternal`], after the pending external has
1404 /// been resolved via [`resolve_external`](Self::resolve_external).
1405 ///
1406 /// # Errors
1407 /// - [`NotEvaluatingFunction`](RuntimeError::NotEvaluatingFunction) if
1408 /// no evaluation is in progress.
1409 /// - Same evaluation errors as
1410 /// [`begin_function_eval`](Self::begin_function_eval).
1411 pub fn resume_function_eval<R: StoryRng>(
1412 &mut self,
1413 program: &Program,
1414 line_tables: &[Vec<brink_format::LineEntry>],
1415 context: &mut (impl ContextAccess + ?Sized),
1416 handler: &dyn ExternalFnHandler,
1417 resolver: Option<&dyn PluralResolver>,
1418 ) -> Result<FunctionEval, RuntimeError> {
1419 if self.eval.is_none() {
1420 return Err(RuntimeError::NotEvaluatingFunction);
1421 }
1422 self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
1423 }
1424
1425 /// Returns `true` if a function evaluation is in progress (possibly
1426 /// paused awaiting an external).
1427 #[must_use]
1428 pub fn is_evaluating_function(&self) -> bool {
1429 self.eval.is_some()
1430 }
1431
1432 /// Step the VM until the in-progress function evaluation returns or
1433 /// pauses on a pending external. Shared by `begin`/`resume`.
1434 fn drive_function_eval<R: StoryRng>(
1435 &mut self,
1436 program: &Program,
1437 line_tables: &[Vec<brink_format::LineEntry>],
1438 context: &mut (impl ContextAccess + ?Sized),
1439 handler: &dyn ExternalFnHandler,
1440 resolver: Option<&dyn PluralResolver>,
1441 ) -> Result<FunctionEval, RuntimeError> {
1442 let step_start = self.stats.steps;
1443 loop {
1444 self.stats.steps += 1;
1445 if self.stats.steps - step_start > Self::STEP_LIMIT {
1446 self.abort_eval(program, line_tables, resolver);
1447 return Err(RuntimeError::StepLimitExceeded(Self::STEP_LIMIT));
1448 }
1449
1450 let stepped = vm::step::<R>(
1451 &mut self.flow,
1452 program,
1453 line_tables,
1454 context,
1455 &mut self.stats,
1456 resolver,
1457 )?;
1458 self.stats.materializations += self.flow.drain_materializations();
1459
1460 match stepped {
1461 vm::Stepped::Done | vm::Stepped::Ended => {
1462 // A function reached `-> DONE`/`-> END` — illegal.
1463 self.abort_eval(program, line_tables, resolver);
1464 return Err(RuntimeError::FunctionYielded);
1465 }
1466 vm::Stepped::ExternalCall => {
1467 if let Some(pending) =
1468 self.resolve_eval_external(program, line_tables, resolver, handler)?
1469 {
1470 return Ok(pending);
1471 }
1472 }
1473 vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {}
1474 }
1475
1476 // Did the boundary frame pop? Then the function has returned
1477 // (via `~ return` or implicit exhaustion).
1478 if !self.flow.has_eval_boundary() {
1479 let _captured = self.flow.output.end_capture(program, line_tables, resolver);
1480 let floor = self.eval.take().map_or(0, |e| e.value_floor);
1481 let mut ret: Option<Value> = None;
1482 while self.flow.value_stack.len() > floor {
1483 let v = self.flow.value_stack.pop();
1484 if ret.is_none() {
1485 ret = v; // first popped = top of stack = the return value
1486 }
1487 }
1488 return Ok(FunctionEval::Returned(ret.unwrap_or(Value::Null)));
1489 }
1490
1491 // A function must not present choices. Compare against the
1492 // count when the eval began — the main story may already have
1493 // choices waiting, which are none of our concern.
1494 let choice_floor = self.eval.as_ref().map_or(0, |e| e.choice_floor);
1495 if self.flow.pending_choices.len() > choice_floor {
1496 self.abort_eval(program, line_tables, resolver);
1497 return Err(RuntimeError::FunctionYielded);
1498 }
1499 }
1500 }
1501
1502 /// Resolve an external hit during function evaluation, mirroring the
1503 /// normal step path but surfacing [`ExternalResult::Pending`] as
1504 /// [`FunctionEval::AwaitingExternal`] (returned as `Some`) rather than
1505 /// an error. Returns `None` when the external resolved and stepping
1506 /// should continue.
1507 fn resolve_eval_external(
1508 &mut self,
1509 program: &Program,
1510 line_tables: &[Vec<brink_format::LineEntry>],
1511 resolver: Option<&dyn PluralResolver>,
1512 handler: &dyn ExternalFnHandler,
1513 ) -> Result<Option<FunctionEval>, RuntimeError> {
1514 let fn_id = self
1515 .flow
1516 .external_fn_id()
1517 .ok_or(RuntimeError::CallStackUnderflow)?;
1518 let entry = program.external_fn(fn_id);
1519 let fn_name = entry.map_or("?", |e| program.name(e.name));
1520 match handler.call(fn_name, self.flow.external_args()) {
1521 ExternalResult::Resolved(value) => {
1522 self.flow.resolve_external(value);
1523 Ok(None)
1524 }
1525 ExternalResult::Fallback => {
1526 if let Some(fb_id) = entry.and_then(|e| e.fallback) {
1527 let container_idx = program
1528 .resolve_target(fb_id)
1529 .map(|(idx, _)| idx)
1530 .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1531 self.flow.invoke_fallback(container_idx);
1532 Ok(None)
1533 } else {
1534 self.abort_eval(program, line_tables, resolver);
1535 Err(RuntimeError::UnresolvedExternalCall(fn_id))
1536 }
1537 }
1538 ExternalResult::Pending => Ok(Some(FunctionEval::AwaitingExternal)),
1539 }
1540 }
1541
1542 /// Tear down an aborted/failed evaluation: end the output capture and
1543 /// clear the eval marker. Leaves the call stack as-is (the caller is
1544 /// erroring out).
1545 fn abort_eval(
1546 &mut self,
1547 program: &Program,
1548 line_tables: &[Vec<brink_format::LineEntry>],
1549 resolver: Option<&dyn PluralResolver>,
1550 ) {
1551 if self.eval.take().is_some() {
1552 let _ = self.flow.output.end_capture(program, line_tables, resolver);
1553 }
1554 }
1555}
1556
1557/// Internal: set execution position to the given choice target, clear
1558/// pending choices, and set status to Active. No status precondition.
1559#[expect(clippy::similar_names)]
1560/// Returns the `DefinitionId` of the selected choice target, so the
1561/// caller can notify observers if needed.
1562fn select_choice(
1563 flow: &mut Flow,
1564 context: &mut (impl ContextAccess + ?Sized),
1565 status: &mut StoryStatus,
1566 stats: &mut Stats,
1567 index: usize,
1568) -> Result<(), RuntimeError> {
1569 let available = flow.pending_choices.len();
1570 if index >= available {
1571 return Err(RuntimeError::InvalidChoiceIndex { index, available });
1572 }
1573
1574 let choice = flow.pending_choices.swap_remove(index);
1575 let target_id = choice.target_id;
1576
1577 // Increment visit count for the choice target container so that
1578 // once-only choices can be filtered on subsequent passes.
1579 context.increment_visit(target_id);
1580 context.set_turn_count(target_id, context.turn_index());
1581
1582 // Replace the current thread with the fork from choice creation
1583 // time. By selection time, all spawned threads should have
1584 // completed — only the main thread remains.
1585 let current = flow.current_thread_mut();
1586 *current = choice.thread_fork;
1587
1588 // Set execution position to the choice target. We reset the top
1589 // frame's container_stack to just the target — the snapshot may
1590 // have captured stale nesting from inside the choice eval block.
1591 let frame = current
1592 .call_stack
1593 .last_mut()
1594 .ok_or(RuntimeError::CallStackUnderflow)?;
1595
1596 frame.container_stack.clear();
1597 frame.container_stack.push(ContainerPosition {
1598 container_idx: choice.target_idx,
1599 offset: choice.target_offset,
1600 });
1601
1602 flow.pending_choices.clear();
1603 *status = StoryStatus::Active;
1604 stats.choices_selected += 1;
1605
1606 Ok(())
1607}
1608
1609/// Resolve an external function call using the handler and program metadata.
1610///
1611/// Returns `Ok(true)` if the call was resolved (a value was supplied or the
1612/// in-story fallback was invoked) and stepping should continue; `Ok(false)`
1613/// if the handler deferred ([`ExternalResult::Pending`]), leaving the
1614/// `External` frame intact for the caller to resolve out-of-band. Errors
1615/// only when the handler declined and no fallback exists.
1616fn resolve_external_call(
1617 flow: &mut Flow,
1618 program: &Program,
1619 handler: &dyn ExternalFnHandler,
1620) -> Result<bool, RuntimeError> {
1621 let fn_id = flow
1622 .external_fn_id()
1623 .ok_or(RuntimeError::CallStackUnderflow)?;
1624
1625 let entry = program.external_fn(fn_id);
1626 let fn_name = entry.map_or("?", |e| program.name(e.name));
1627
1628 let result = handler.call(fn_name, flow.external_args());
1629 match result {
1630 ExternalResult::Resolved(value) => {
1631 flow.resolve_external(value);
1632 Ok(true)
1633 }
1634 ExternalResult::Fallback => {
1635 let fallback_id = entry.and_then(|e| e.fallback);
1636 if let Some(fb_id) = fallback_id {
1637 let container_idx = program
1638 .resolve_target(fb_id)
1639 .map(|(idx, _)| idx)
1640 .ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
1641
1642 flow.invoke_fallback(container_idx);
1643 Ok(true)
1644 } else {
1645 Err(RuntimeError::UnresolvedExternalCall(fn_id))
1646 }
1647 }
1648 ExternalResult::Pending => {
1649 // Leave the External frame intact — the caller resolves it
1650 // out-of-band (via resolve_external) before continuing.
1651 Ok(false)
1652 }
1653 }
1654}
1655
1656/// Flush remaining output buffer content into `(text, tags)`.
1657///
1658/// At a yield point (Done/Choices/Ended), no more output is coming, so
1659/// trailing newlines are committed. Lines are joined with `\n` and tags
1660/// are flattened into a single vec.
1661fn flush_remaining(
1662 flow: &mut Flow,
1663 program: &Program,
1664 line_tables: &[Vec<brink_format::LineEntry>],
1665 resolver: Option<&dyn brink_format::PluralResolver>,
1666) -> (String, Vec<String>) {
1667 let lines = flow.output.flush_lines(program, line_tables, resolver);
1668 let mut text = String::new();
1669 let mut tags = Vec::new();
1670 for (i, (line_text, line_tags)) in lines.iter().enumerate() {
1671 if i > 0 {
1672 text.push('\n');
1673 }
1674 text.push_str(line_text);
1675 tags.extend_from_slice(line_tags);
1676 }
1677 (text, tags)
1678}
1679
1680/// Build the appropriate [`Line`] variant for a yield point based on
1681/// the current story status.
1682fn make_yield_line(
1683 status: StoryStatus,
1684 text: String,
1685 tags: Vec<String>,
1686 flow: &Flow,
1687 program: &Program,
1688 line_tables: &[Vec<brink_format::LineEntry>],
1689 resolver: Option<&dyn brink_format::PluralResolver>,
1690) -> Line {
1691 match status {
1692 StoryStatus::WaitingForChoice => {
1693 let choices = flow
1694 .pending_choices
1695 .iter()
1696 .enumerate()
1697 .filter(|(_, pc)| !pc.flags.is_invisible_default)
1698 .map(|(i, pc)| {
1699 let display_text = match &pc.display {
1700 ChoiceDisplay::Text(s) => s.clone(),
1701 ChoiceDisplay::Fragment(idx) => {
1702 flow.output
1703 .resolve_fragment(*idx, program, line_tables, resolver)
1704 }
1705 };
1706 // Trim spaces/tabs from choice display text, matching C#:
1707 // choice.text = (startText + choiceOnlyText).Trim(' ', '\t');
1708 let display_text = display_text
1709 .trim_matches(|c: char| c == ' ' || c == '\t')
1710 .to_string();
1711 Choice {
1712 text: display_text,
1713 index: i,
1714 tags: pc.tags.clone(),
1715 }
1716 })
1717 .collect();
1718 Line::Choices {
1719 text,
1720 tags,
1721 choices,
1722 }
1723 }
1724 StoryStatus::Ended => Line::End { text, tags },
1725 StoryStatus::Done => Line::Done { text, tags },
1726 StoryStatus::Active => Line::Text { text, tags },
1727 }
1728}
1729
1730// ── Story ───────────────────────────────────────────────────────────────────
1731
1732/// Per-instance mutable state for executing stories.
1733///
1734/// Created from a [`Program`] via [`Story::new`]. Holds all mutable state
1735/// (stacks, globals, output buffer) while the immutable program data lives
1736/// in [`Program`].
1737///
1738/// Generic over `R: StoryRng` — defaults to [`FastRng`]. Use
1739/// [`DotNetRng`](crate::DotNetRng) for .NET-compatible deterministic output.
1740pub struct Story<R: StoryRng = FastRng> {
1741 program: Arc<Program>,
1742 pub(crate) default: FlowInstance,
1743 pub(crate) default_context: World,
1744 /// The default flow's per-flow override layer. Empty in F1.3 (F3 fills
1745 /// it in) — the routing view built from `(default_context, default_local)`
1746 /// is an all-`World` passthrough, so this contributes nothing yet.
1747 default_local: FlowLocal,
1748 line_tables: Vec<Vec<brink_format::LineEntry>>,
1749 instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
1750 /// Named flows that **share** `default_context` (globals / visit counts /
1751 /// rng) — true ink concurrent-flow semantics, where one flow's writes are
1752 /// visible to the others. Each still has its own call stack + temps (those
1753 /// live in the [`FlowInstance`]). Distinct from `instances`, whose flows
1754 /// each own an isolated `World` (bevy-brink's per-entity model). Transient
1755 /// studio/host state — not persisted in a [`StorySnapshot`].
1756 shared_instances: HashMap<String, FlowInstance>,
1757 resolver: Option<Box<dyn PluralResolver>>,
1758 _rng: PhantomData<R>,
1759}
1760
1761impl<R: StoryRng> Clone for Story<R> {
1762 fn clone(&self) -> Self {
1763 Self {
1764 program: Arc::clone(&self.program),
1765 default: self.default.clone(),
1766 default_context: self.default_context.clone(),
1767 default_local: self.default_local.clone(),
1768 line_tables: self.line_tables.clone(),
1769 instances: self.instances.clone(),
1770 shared_instances: self.shared_instances.clone(),
1771 resolver: None,
1772 _rng: PhantomData,
1773 }
1774 }
1775}
1776
1777/// Owned story state that can be detached from a `Program` and reattached later.
1778///
1779/// Created by [`Story::into_snapshot`], consumed by [`Story::from_snapshot`].
1780/// This enables locale hot-swapping: detach state, mutate the program's line
1781/// tables, then reattach.
1782pub struct StorySnapshot<R: StoryRng = FastRng> {
1783 default: FlowInstance,
1784 default_context: World,
1785 default_local: FlowLocal,
1786 instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
1787 _rng: PhantomData<R>,
1788}
1789
1790impl<R: StoryRng> Story<R> {
1791 /// Create a new story instance from a linked program and its line tables.
1792 pub fn new(program: Arc<Program>, line_tables: Vec<Vec<brink_format::LineEntry>>) -> Self {
1793 let (default, default_context) = FlowInstance::new_at_root(&program);
1794 Self {
1795 program,
1796 default,
1797 default_context,
1798 default_local: FlowLocal::new(),
1799 line_tables,
1800 instances: HashMap::new(),
1801 shared_instances: HashMap::new(),
1802 resolver: None,
1803 _rng: PhantomData,
1804 }
1805 }
1806
1807 /// Set the plural resolver for Select resolution in localized lines.
1808 pub fn set_plural_resolver(&mut self, resolver: Box<dyn PluralResolver>) {
1809 self.resolver = Some(resolver);
1810 }
1811
1812 /// Replace the active line tables (e.g. for locale swapping).
1813 pub fn set_line_tables(&mut self, tables: Vec<Vec<brink_format::LineEntry>>) {
1814 self.line_tables = tables;
1815 }
1816
1817 /// Read-only access to the current line tables.
1818 pub fn line_tables(&self) -> &[Vec<brink_format::LineEntry>] {
1819 &self.line_tables
1820 }
1821
1822 /// The full append-only transcript of all output parts produced so far.
1823 pub fn transcript(&self) -> &[crate::output::OutputPart] {
1824 self.default.flow.output.transcript()
1825 }
1826
1827 /// Number of parts in the transcript.
1828 pub fn transcript_len(&self) -> usize {
1829 self.default.flow.output.transcript_len()
1830 }
1831
1832 /// Reset the transcript read cursor to the beginning (for re-rendering).
1833 pub fn reset_cursor(&mut self) {
1834 self.default.flow.output.reset_cursor();
1835 }
1836
1837 /// Resolve a slice of the transcript against the current line tables.
1838 /// Returns `(text, tags)` tuples — one per line in the resolved output.
1839 pub fn resolve_transcript_slice(
1840 &self,
1841 range: std::ops::Range<usize>,
1842 ) -> Vec<(String, Vec<String>)> {
1843 let transcript = self.default.flow.output.transcript();
1844 let end = range.end.min(transcript.len());
1845 let start = range.start.min(end);
1846 let slice = &transcript[start..end];
1847 let fragments = self.default.flow.output.fragments();
1848 crate::output::resolve_lines(
1849 slice,
1850 &self.program,
1851 &self.line_tables,
1852 self.resolver.as_deref(),
1853 fragments,
1854 )
1855 }
1856
1857 /// Re-resolve all pending choices against the current line tables.
1858 /// Returns the same choices that would appear in `Line::Choices`,
1859 /// but freshly resolved (useful after locale switch).
1860 pub fn pending_choices(&self) -> Vec<Choice> {
1861 self.resolved_choices_for(&self.default.flow)
1862 }
1863
1864 /// Resolve a given flow's pending choices against the current line tables.
1865 /// Shared by [`pending_choices`](Self::pending_choices) (default flow) and
1866 /// the per-flow debug snapshot (#200 shared flows).
1867 fn resolved_choices_for(&self, flow: &Flow) -> Vec<Choice> {
1868 flow.pending_choices
1869 .iter()
1870 .enumerate()
1871 .filter(|(_, pc)| !pc.flags.is_invisible_default)
1872 .map(|(i, pc)| {
1873 let display_text = match &pc.display {
1874 ChoiceDisplay::Text(s) => s.clone(),
1875 ChoiceDisplay::Fragment(idx) => flow.output.resolve_fragment(
1876 *idx,
1877 &self.program,
1878 &self.line_tables,
1879 self.resolver.as_deref(),
1880 ),
1881 };
1882 let display_text = display_text
1883 .trim_matches(|c: char| c == ' ' || c == '\t')
1884 .to_string();
1885 Choice {
1886 text: display_text,
1887 index: i,
1888 tags: pc.tags.clone(),
1889 }
1890 })
1891 .collect()
1892 }
1893
1894 /// Resolve a fragment against the current line tables.
1895 pub fn resolve_fragment(&self, idx: u32) -> String {
1896 self.default.flow.output.resolve_fragment(
1897 idx,
1898 &self.program,
1899 &self.line_tables,
1900 self.resolver.as_deref(),
1901 )
1902 }
1903
1904 /// Get the fragment index for a pending choice's display text, if any.
1905 pub fn choice_fragment_idx(&self, choice_index: usize) -> Option<u32> {
1906 self.default
1907 .flow
1908 .pending_choices
1909 .get(choice_index)
1910 .and_then(|pc| match &pc.display {
1911 ChoiceDisplay::Fragment(idx) => Some(*idx),
1912 ChoiceDisplay::Text(_) => None,
1913 })
1914 }
1915
1916 /// Read-only access to the fragment store (for transcript serialization).
1917 pub fn fragments(&self) -> &[crate::output::Fragment] {
1918 self.default.flow.output.fragments()
1919 }
1920
1921 /// Read-only access to the program.
1922 pub fn program(&self) -> &Program {
1923 &self.program
1924 }
1925
1926 /// Cheap `Arc` clone of the program, for callers (e.g. [`crate::save`])
1927 /// that need a `&Program` alongside a disjoint mutable borrow of another
1928 /// field — `self.program()` ties its `&Program` to all of `&self`, which
1929 /// conflicts with a simultaneous `&mut self.default_context`.
1930 pub(crate) fn program_arc(&self) -> Arc<Program> {
1931 Arc::clone(&self.program)
1932 }
1933
1934 // ── Variable access (host-facing) ───────────────────────────────
1935
1936 /// Read a global variable's current value by name. `None` if no global
1937 /// with that name is declared. Reads the default flow's context.
1938 pub fn variable(&self, name: &str) -> Option<&Value> {
1939 let idx = self.program.global_index(name)?;
1940 Some(ContextAccess::global(&self.default_context, idx))
1941 }
1942
1943 /// Set a global variable by name, returning `false` (no-op) if no global
1944 /// with that name is declared. Ink globals are dynamically typed, so the
1945 /// host is responsible for passing a sensibly-typed value.
1946 pub fn set_variable(&mut self, name: &str, value: Value) -> bool {
1947 match self.program.global_index(name) {
1948 Some(idx) => {
1949 ContextAccess::set_global(&mut self.default_context, idx, value);
1950 true
1951 }
1952 None => false,
1953 }
1954 }
1955
1956 /// Set the RNG seed for the default flow's context. Seeding makes
1957 /// `RANDOM`/shuffle output reproducible — set it before running (or after
1958 /// a reset) so two runs of the same story on different machines match.
1959 pub fn set_rng_seed(&mut self, seed: i32) {
1960 ContextAccess::set_rng_seed(&mut self.default_context, seed);
1961 }
1962
1963 // ── Pausable stepping (async externals) ─────────────────────────
1964
1965 /// Advance the default flow by one step with a custom handler, surfacing a
1966 /// deferred external as [`StepOutcome::AwaitingExternal`] rather than
1967 /// erroring (unlike [`continue_single_with`](Self::continue_single_with)).
1968 ///
1969 /// On `AwaitingExternal`, resolve the pending call
1970 /// ([`resolve_external`](Self::resolve_external), or
1971 /// [`invoke_fallback`](Self::invoke_fallback)) and call `advance_with` again
1972 /// to resume. Inspect the pending call via
1973 /// [`pending_external_name`](Self::pending_external_name) /
1974 /// [`pending_external_args`](Self::pending_external_args).
1975 pub fn advance_with(
1976 &mut self,
1977 handler: &dyn ExternalFnHandler,
1978 ) -> Result<StepOutcome, RuntimeError> {
1979 let resolver = self.resolver.as_deref();
1980 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
1981 self.default.advance::<R>(
1982 &self.program,
1983 &self.line_tables,
1984 &mut view,
1985 handler,
1986 resolver,
1987 )
1988 }
1989
1990 /// Name of the external the default flow is paused on, if any.
1991 #[must_use]
1992 pub fn pending_external_name(&self) -> Option<&str> {
1993 self.default.pending_external_name(&self.program)
1994 }
1995
1996 /// Arguments of the external the default flow is paused on.
1997 #[must_use]
1998 pub fn pending_external_args(&self) -> &[Value] {
1999 self.default.pending_external_args()
2000 }
2001
2002 /// Evaluate an ink function by name from engine code, returning its value.
2003 ///
2004 /// Runs out-of-band on the default flow: output is isolated (the visible
2005 /// story is untouched), and the call completes synchronously. Externals the
2006 /// function calls are resolved inline by `handler`; an external the handler
2007 /// defers ([`ExternalResult::Pending`]) can't be resolved in a synchronous
2008 /// call and yields [`RuntimeError::AsyncExternalInCall`] (the paused eval is
2009 /// cleaned up first).
2010 ///
2011 /// # Errors
2012 /// [`RuntimeError::FunctionNotFound`] for an unknown name;
2013 /// [`RuntimeError::AsyncExternalInCall`] if a called external defers; plus
2014 /// any runtime error raised during evaluation.
2015 pub fn call_function(
2016 &mut self,
2017 name: &str,
2018 args: &[Value],
2019 handler: &dyn ExternalFnHandler,
2020 ) -> Result<Value, RuntimeError> {
2021 let container_idx = self
2022 .program
2023 .find_address(name)
2024 .ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
2025 .0;
2026 // Arity-check against the function's declared parameters (compiler-built
2027 // programs only; converter-built ones record 0 and so accept no args).
2028 let expected = self.program.container(container_idx).param_count;
2029 if args.len() != expected as usize {
2030 return Err(RuntimeError::ArgCountMismatch {
2031 target: name.to_owned(),
2032 expected,
2033 got: args.len(),
2034 });
2035 }
2036 let resolver = self.resolver.as_deref();
2037 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2038 let outcome = self.default.begin_function_eval::<R>(
2039 &self.program,
2040 &self.line_tables,
2041 &mut view,
2042 handler,
2043 container_idx,
2044 args,
2045 resolver,
2046 )?;
2047 match outcome {
2048 FunctionEval::Returned(value) => Ok(value),
2049 FunctionEval::AwaitingExternal => {
2050 let name = self
2051 .default
2052 .pending_external_name(&self.program)
2053 .map_or_else(|| name.to_owned(), ToOwned::to_owned);
2054 self.default
2055 .abort_eval(&self.program, &self.line_tables, resolver);
2056 Err(RuntimeError::AsyncExternalInCall(name))
2057 }
2058 }
2059 }
2060
2061 /// Fork a [`Speculation`](crate::Speculation) — a sandboxed,
2062 /// side-effect-proof speculative run — from the default flow's
2063 /// current state.
2064 ///
2065 /// The speculation owns an independent snapshot: driving it (via its
2066 /// own `advance`/`choose`/`go_to_path`/`eval_function` verbs) never
2067 /// mutates this `Story`. Dropping it discards everything it did. See
2068 /// [`crate::Speculation`] for the full picture, and
2069 /// [`crate::Speculation::fork_from`] for forking a non-default flow
2070 /// (e.g. a named flow spawned via [`spawn_flow`](Self::spawn_flow)).
2071 #[must_use]
2072 pub fn speculate(&self) -> crate::Speculation<R> {
2073 crate::Speculation::fork_from(
2074 Arc::clone(&self.program),
2075 &self.default_context,
2076 &self.default_local,
2077 &self.default,
2078 &self.line_tables,
2079 )
2080 }
2081
2082 /// Detach story state from the program, consuming the story.
2083 pub fn into_snapshot(self) -> (StorySnapshot<R>, Vec<Vec<brink_format::LineEntry>>) {
2084 let snapshot = StorySnapshot {
2085 default: self.default,
2086 default_context: self.default_context,
2087 default_local: self.default_local,
2088 instances: self.instances,
2089 _rng: PhantomData,
2090 };
2091 (snapshot, self.line_tables)
2092 }
2093
2094 /// Reattach a snapshot to a program with line tables.
2095 pub fn from_snapshot(
2096 program: Arc<Program>,
2097 snapshot: StorySnapshot<R>,
2098 line_tables: Vec<Vec<brink_format::LineEntry>>,
2099 ) -> Self {
2100 Self {
2101 program,
2102 default: snapshot.default,
2103 default_context: snapshot.default_context,
2104 default_local: snapshot.default_local,
2105 line_tables,
2106 instances: snapshot.instances,
2107 // Shared flows are transient (not persisted) — a reattached story
2108 // starts with none.
2109 shared_instances: HashMap::new(),
2110 resolver: None,
2111 _rng: PhantomData,
2112 }
2113 }
2114
2115 // ── Execution API ──────────────────────────────────────────────
2116
2117 /// Execute until one line of content (up to newline), or until a
2118 /// yield point (choices/end) if no newline occurs first.
2119 ///
2120 /// The returned [`Line`] variant tells you what to do next:
2121 /// - [`Line::Text`] — more output may follow, keep calling.
2122 /// - [`Line::Choices`] — call [`choose`](Self::choose) then resume.
2123 /// - [`Line::End`] — the story has permanently ended.
2124 pub fn continue_single(&mut self) -> Result<Line, RuntimeError> {
2125 let resolver = self.resolver.as_deref();
2126 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2127 self.default.step_single_line::<R>(
2128 &self.program,
2129 &self.line_tables,
2130 &mut view,
2131 &FallbackHandler,
2132 resolver,
2133 )
2134 }
2135
2136 /// Like [`continue_single`](Self::continue_single) but with a
2137 /// [`WriteObserver`] that receives notifications for every state mutation.
2138 pub fn continue_single_observed(
2139 &mut self,
2140 observer: &mut dyn WriteObserver,
2141 ) -> Result<Line, RuntimeError> {
2142 use crate::state::ObservedContext;
2143 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2144 let mut obs_ctx = ObservedContext::new(&mut view, observer);
2145 let resolver = self.resolver.as_deref();
2146 self.default.step_single_line::<R>(
2147 &self.program,
2148 &self.line_tables,
2149 &mut obs_ctx,
2150 &FallbackHandler,
2151 resolver,
2152 )
2153 }
2154
2155 /// Like [`continue_single`](Self::continue_single) but with a custom
2156 /// external function handler.
2157 pub fn continue_single_with(
2158 &mut self,
2159 handler: &dyn ExternalFnHandler,
2160 ) -> Result<Line, RuntimeError> {
2161 let resolver = self.resolver.as_deref();
2162 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2163 self.default.step_single_line::<R>(
2164 &self.program,
2165 &self.line_tables,
2166 &mut view,
2167 handler,
2168 resolver,
2169 )
2170 }
2171
2172 /// Execute until the next yield point, collecting all lines.
2173 ///
2174 /// Returns a `Vec<Line>` where the last element is always
2175 /// [`Line::Choices`] or [`Line::End`], and all preceding elements
2176 /// are [`Line::Text`].
2177 pub fn continue_maximally(&mut self) -> Result<Vec<Line>, RuntimeError> {
2178 self.continue_maximally_impl(&FallbackHandler)
2179 }
2180
2181 /// Like [`continue_maximally`](Self::continue_maximally) but with a
2182 /// custom external function handler.
2183 pub fn continue_maximally_with(
2184 &mut self,
2185 handler: &dyn ExternalFnHandler,
2186 ) -> Result<Vec<Line>, RuntimeError> {
2187 self.continue_maximally_impl(handler)
2188 }
2189
2190 fn continue_maximally_impl(
2191 &mut self,
2192 handler: &dyn ExternalFnHandler,
2193 ) -> Result<Vec<Line>, RuntimeError> {
2194 let resolver = self.resolver.as_deref();
2195 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2196 self.default.drive_to_terminal::<R>(
2197 &self.program,
2198 &self.line_tables,
2199 &mut view,
2200 handler,
2201 resolver,
2202 )
2203 }
2204
2205 /// Execute until the next yield point with a [`WriteObserver`] that
2206 /// receives notifications for every state mutation.
2207 pub fn continue_maximally_observed(
2208 &mut self,
2209 observer: &mut dyn WriteObserver,
2210 ) -> Result<Vec<Line>, RuntimeError> {
2211 use crate::state::ObservedContext;
2212 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2213 let mut obs_ctx = ObservedContext::new(&mut view, observer);
2214 let resolver = self.resolver.as_deref();
2215 self.default.drive_to_terminal::<R>(
2216 &self.program,
2217 &self.line_tables,
2218 &mut obs_ctx,
2219 &FallbackHandler,
2220 resolver,
2221 )
2222 }
2223
2224 /// Select a choice by index, then resume with
2225 /// [`continue_single`](Self::continue_single) or
2226 /// [`continue_maximally`](Self::continue_maximally).
2227 pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
2228 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2229 self.default.choose(&mut view, index)
2230 }
2231
2232 /// Move the default flow's play head to a named knot/stitch path — ink's
2233 /// `ChoosePathString` equivalent. The current flow is force-completed
2234 /// (callstack reset, pending choices cleared), the jump counts as a visit
2235 /// to the target exactly like a `-> path` divert, and subsequent
2236 /// [`continue_single`](Self::continue_single) /
2237 /// [`continue_maximally`](Self::continue_maximally) calls run from there.
2238 /// See [`FlowInstance::choose_path_string`] for full semantics.
2239 ///
2240 /// # Errors
2241 /// [`UnknownPath`](RuntimeError::UnknownPath) for an unknown path;
2242 /// [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
2243 /// if the flow is parked on an unresolved external call;
2244 /// [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
2245 /// if an engine→ink function evaluation is in progress.
2246 pub fn choose_path_string(&mut self, path: &str) -> Result<(), RuntimeError> {
2247 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2248 self.default
2249 .choose_path_string(&self.program, &mut view, path)
2250 }
2251
2252 /// Move the default flow's play head to a parameterized knot/stitch,
2253 /// **binding its declared parameters** from `args` — ink's
2254 /// `ChoosePathString` with arguments. Otherwise identical to
2255 /// [`choose_path_string`](Self::choose_path_string). See
2256 /// [`FlowInstance::choose_path_string_with_args`] for full semantics.
2257 ///
2258 /// # Errors
2259 /// As [`choose_path_string`](Self::choose_path_string), plus
2260 /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) when `args.len()`
2261 /// doesn't match the target's declared parameter count.
2262 pub fn choose_path_string_with_args(
2263 &mut self,
2264 path: &str,
2265 args: &[Value],
2266 ) -> Result<(), RuntimeError> {
2267 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2268 self.default
2269 .choose_path_string_with_args(&self.program, &mut view, path, args)
2270 }
2271
2272 /// Read-only access to the default flow's VM statistics.
2273 pub fn stats(&self) -> &Stats {
2274 &self.default.stats
2275 }
2276
2277 /// Returns `true` if the default flow has a pending external call
2278 /// (an `External` frame on top of the call stack).
2279 pub fn has_pending_external(&self) -> bool {
2280 self.default.flow.external_fn_id().is_some()
2281 }
2282
2283 /// Resolve a pending external call on the default flow by providing
2284 /// the return value. For fire-and-forget calls, pass `Value::Null`.
2285 ///
2286 /// After resolving, call [`continue_maximally`](Story::continue_maximally)
2287 /// to continue execution.
2288 pub fn resolve_external(&mut self, value: Value) {
2289 self.default.flow.resolve_external(value);
2290 }
2291
2292 /// Resolve a pending external call on the default flow by invoking
2293 /// the ink-defined fallback body. The fallback is a function call
2294 /// whose output becomes the return value.
2295 ///
2296 /// After invoking, call [`continue_maximally`](Story::continue_maximally)
2297 /// to continue execution.
2298 pub fn invoke_fallback(&mut self) -> Result<(), RuntimeError> {
2299 let fn_id = self
2300 .default
2301 .flow
2302 .external_fn_id()
2303 .ok_or(RuntimeError::CallStackUnderflow)?;
2304 let entry = self.program.external_fn(fn_id);
2305 let fallback_id = entry
2306 .and_then(|e| e.fallback)
2307 .ok_or(RuntimeError::UnresolvedExternalCall(fn_id))?;
2308 let container_idx = self
2309 .program
2310 .resolve_target(fallback_id)
2311 .map(|(idx, _)| idx)
2312 .ok_or(RuntimeError::UnresolvedDefinition(fallback_id))?;
2313 self.default.flow.output.begin_capture();
2314 self.default.flow.invoke_fallback(container_idx);
2315 Ok(())
2316 }
2317
2318 // ── Named flow API ──────────────────────────────────────────────
2319
2320 /// Spawn a new flow instance starting at the given entry point.
2321 ///
2322 /// `entry_point` is the `DefinitionId` of the target container
2323 /// (e.g., a knot). Each flow instance gets its own globals, visit
2324 /// counts, and execution state.
2325 pub fn spawn_flow(
2326 &mut self,
2327 name: &str,
2328 entry_point: DefinitionId,
2329 ) -> Result<(), RuntimeError> {
2330 if self.instances.contains_key(name) {
2331 return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
2332 }
2333 let container_idx = self
2334 .program
2335 .resolve_target(entry_point)
2336 .map(|(idx, _)| idx)
2337 .ok_or(RuntimeError::UnresolvedDefinition(entry_point))?;
2338 let (flow, ctx) = FlowInstance::new_at(&self.program, container_idx);
2339 self.instances
2340 .insert(name.to_owned(), (flow, ctx, FlowLocal::new()));
2341 Ok(())
2342 }
2343
2344 /// Run a named flow instance until the next yield point.
2345 pub fn continue_flow_maximally(&mut self, name: &str) -> Result<Vec<Line>, RuntimeError> {
2346 self.continue_flow_maximally_with(name, &FallbackHandler)
2347 }
2348
2349 /// Run a named flow instance with an external function handler.
2350 pub fn continue_flow_maximally_with(
2351 &mut self,
2352 name: &str,
2353 handler: &dyn ExternalFnHandler,
2354 ) -> Result<Vec<Line>, RuntimeError> {
2355 let (instance, ctx, local) = self
2356 .instances
2357 .get_mut(name)
2358 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2359 let mut view = ContextView::new(ctx, local);
2360 let resolver = self.resolver.as_deref();
2361 instance.drive_to_terminal::<R>(
2362 &self.program,
2363 &self.line_tables,
2364 &mut view,
2365 handler,
2366 resolver,
2367 )
2368 }
2369
2370 /// Select a choice in a named flow.
2371 pub fn choose_flow(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
2372 let (instance, ctx, local) = self
2373 .instances
2374 .get_mut(name)
2375 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2376 let mut view = ContextView::new(ctx, local);
2377 instance.choose(&mut view, index)
2378 }
2379
2380 /// Destroy a named flow instance — isolated or shared (#200).
2381 pub fn destroy_flow(&mut self, name: &str) -> Result<(), RuntimeError> {
2382 if self.shared_instances.remove(name).is_some() || self.instances.remove(name).is_some() {
2383 Ok(())
2384 } else {
2385 Err(RuntimeError::UnknownFlow(name.to_owned()))
2386 }
2387 }
2388
2389 /// List active flow names (isolated + shared), sorted for determinism.
2390 pub fn flow_names(&self) -> Vec<&str> {
2391 let mut names: Vec<&str> = self
2392 .instances
2393 .keys()
2394 .chain(self.shared_instances.keys())
2395 .map(String::as_str)
2396 .collect();
2397 names.sort_unstable();
2398 names
2399 }
2400
2401 // ── Shared flows (#200) ─────────────────────────────────────────
2402 // Spawn a flow that **shares** `default_context` (globals / visit counts /
2403 // rng) with the default flow — true ink concurrent-flow semantics — while
2404 // keeping its own call stack + temps. Distinct from `spawn_flow`, whose
2405 // flows each own an isolated context (bevy-brink's per-entity model).
2406
2407 /// Spawn a shared-context flow at `container_idx` (or the root if `None`).
2408 pub fn spawn_flow_shared(
2409 &mut self,
2410 name: &str,
2411 container_idx: Option<u32>,
2412 ) -> Result<(), RuntimeError> {
2413 if self.shared_instances.contains_key(name) || self.instances.contains_key(name) {
2414 return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
2415 }
2416 // The fresh context the constructor returns is discarded — a shared
2417 // flow runs against `default_context`.
2418 let (flow, _ctx) = match container_idx {
2419 Some(idx) => FlowInstance::new_at(&self.program, idx),
2420 None => FlowInstance::new_at_root(&self.program),
2421 };
2422 self.shared_instances.insert(name.to_owned(), flow);
2423 Ok(())
2424 }
2425
2426 /// Advance a shared flow one line (against the shared context).
2427 pub fn continue_flow_single(&mut self, name: &str) -> Result<Line, RuntimeError> {
2428 self.continue_flow_single_with(name, &FallbackHandler)
2429 }
2430
2431 /// Advance a shared flow one line with an external-function handler.
2432 pub fn continue_flow_single_with(
2433 &mut self,
2434 name: &str,
2435 handler: &dyn ExternalFnHandler,
2436 ) -> Result<Line, RuntimeError> {
2437 let resolver = self.resolver.as_deref();
2438 let instance = self
2439 .shared_instances
2440 .get_mut(name)
2441 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2442 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2443 instance.step_single_line::<R>(
2444 &self.program,
2445 &self.line_tables,
2446 &mut view,
2447 handler,
2448 resolver,
2449 )
2450 }
2451
2452 /// Select a choice in a shared flow (against the shared context).
2453 pub fn choose_flow_shared(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
2454 let instance = self
2455 .shared_instances
2456 .get_mut(name)
2457 .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
2458 let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
2459 instance.choose(&mut view, index)
2460 }
2461
2462 /// A structured, name-resolved snapshot of the current runtime state for
2463 /// the studio State View: status, current location, globals, call stack,
2464 /// visit counts, pending choices, and rng. Read-only; built on demand and
2465 /// not on any hot path. See [`DebugSnapshot`](crate::DebugSnapshot).
2466 #[must_use]
2467 pub fn debug_snapshot(&self) -> crate::DebugSnapshot {
2468 self.build_debug_snapshot(&self.default, &self.default_context)
2469 }
2470
2471 /// A debug snapshot of a named shared flow (#200), built against the shared
2472 /// `default_context` — so its globals / visit counts match the default
2473 /// flow's, while its call stack + temps are the flow's own. Falls back to a
2474 /// named isolated flow's own context if `name` is one of those instead.
2475 pub fn debug_snapshot_flow(&self, name: &str) -> Result<crate::DebugSnapshot, RuntimeError> {
2476 if let Some(instance) = self.shared_instances.get(name) {
2477 Ok(self.build_debug_snapshot(instance, &self.default_context))
2478 } else if let Some((instance, ctx, _local)) = self.instances.get(name) {
2479 Ok(self.build_debug_snapshot(instance, ctx))
2480 } else {
2481 Err(RuntimeError::UnknownFlow(name.to_owned()))
2482 }
2483 }
2484
2485 /// Build a debug snapshot from a specific flow instance + context. Backs
2486 /// both [`debug_snapshot`](Self::debug_snapshot) and the per-flow variant.
2487 fn build_debug_snapshot(&self, instance: &FlowInstance, ctx: &World) -> crate::DebugSnapshot {
2488 use crate::debug::{
2489 DebugChoice, DebugFrame, DebugGlobal, DebugRng, DebugSnapshot, DebugVisit, NameResolver,
2490 };
2491
2492 let flow = &instance.flow;
2493 let resolver = NameResolver::new(&self.program);
2494
2495 let status = match instance.status {
2496 StoryStatus::Active => "active",
2497 StoryStatus::WaitingForChoice => "waiting_for_choice",
2498 StoryStatus::Done => "done",
2499 StoryStatus::Ended => "ended",
2500 };
2501
2502 let thread = flow.current_thread();
2503
2504 // Nearest named container the cursor is currently in (innermost-first).
2505 let resolve_frame_location = |frame: &CallFrame| {
2506 frame
2507 .container_stack
2508 .iter()
2509 .rev()
2510 .find_map(|cp| resolver.container_path(cp.container_idx))
2511 .map(str::to_owned)
2512 };
2513
2514 let current_location = thread.call_stack.last().and_then(resolve_frame_location);
2515
2516 // Globals, skipping unnamed slots.
2517 let globals = ctx
2518 .globals
2519 .iter()
2520 .enumerate()
2521 .filter_map(|(i, value)| {
2522 self.program.global_slot_name(i).map(|name| DebugGlobal {
2523 name: name.to_owned(),
2524 value: resolver.format_value(value),
2525 })
2526 })
2527 .collect();
2528
2529 // Call stack, innermost (current) frame first.
2530 let depth = thread.call_stack.len();
2531 let mut call_stack = Vec::with_capacity(depth);
2532 for i in (0..depth).rev() {
2533 if let Some(frame) = thread.call_stack.get(i) {
2534 let kind = match frame.frame_type {
2535 CallFrameType::Root => "root",
2536 CallFrameType::Function => "function",
2537 CallFrameType::Tunnel => "tunnel",
2538 CallFrameType::Thread => "thread",
2539 CallFrameType::External => "external",
2540 CallFrameType::FunctionEvalFromGame => "eval",
2541 };
2542 call_stack.push(DebugFrame {
2543 kind,
2544 location: resolve_frame_location(frame),
2545 temps: frame.temps.len(),
2546 });
2547 }
2548 }
2549
2550 // Visit counts, resolved and sorted by path for determinism.
2551 let mut visit_counts: Vec<DebugVisit> = ctx
2552 .visit_counts
2553 .iter()
2554 .filter_map(|(id, &count)| {
2555 resolver.def_path(*id).map(|path| DebugVisit {
2556 path: path.to_owned(),
2557 count,
2558 })
2559 })
2560 .collect();
2561 visit_counts.sort_by(|a, b| a.path.cmp(&b.path));
2562
2563 // Pending choices: visible texts (resolved) paired with target paths.
2564 let visible_targets: Vec<DefinitionId> = flow
2565 .pending_choices
2566 .iter()
2567 .filter(|pc| !pc.flags.is_invisible_default)
2568 .map(|pc| pc.target_id)
2569 .collect();
2570 let pending_choices = self
2571 .resolved_choices_for(flow)
2572 .into_iter()
2573 .enumerate()
2574 .map(|(i, ch)| DebugChoice {
2575 text: ch.text,
2576 target: visible_targets
2577 .get(i)
2578 .and_then(|id| resolver.def_path(*id))
2579 .map(str::to_owned),
2580 // `ch.index` is the pre-filter `flow.pending_choices` position
2581 // (see `resolved_choices_for`) — the same index `choose()`
2582 // expects, not the post-filter enumeration position `i`.
2583 index: ch.index,
2584 })
2585 .collect();
2586
2587 DebugSnapshot {
2588 status,
2589 current_location,
2590 turn_index: ctx.turn_index,
2591 globals,
2592 call_stack,
2593 visit_counts,
2594 pending_choices,
2595 rng: DebugRng {
2596 seed: ctx.rng_seed,
2597 previous: ctx.previous_random,
2598 },
2599 }
2600 }
2601
2602 // ── Session support (crate-internal) ────────────────────────────
2603
2604 /// Whether the default flow is in the `Active` status (mid-turn, more
2605 /// content pending). Used by [`StorySession`](crate::StorySession) for the
2606 /// turn-boundary mutation gate.
2607 pub(crate) fn status_is_active(&self) -> bool {
2608 self.default.status == StoryStatus::Active
2609 }
2610
2611 /// Whether the default flow is waiting for a choice selection. Used by
2612 /// [`StorySession`](crate::StorySession) replay.
2613 pub(crate) fn status_is_waiting_for_choice(&self) -> bool {
2614 self.default.status == StoryStatus::WaitingForChoice
2615 }
2616
2617 /// Build a typed [`StateSnapshot`](crate::StateSnapshot) of the default
2618 /// flow's game state — a NEW typed serialization path (globals with list
2619 /// membership, turn counts, callstack summary), distinct from the
2620 /// string-valued [`DebugSnapshot`](crate::DebugSnapshot).
2621 ///
2622 /// Known projection limit (deliberate, not a silent bug): visit/turn-count
2623 /// entries whose scope has no resolvable author path (anonymous counted
2624 /// containers — gathers, choice points — keyed only by hash id) are
2625 /// **omitted** from the snapshot's path-keyed maps. The full id-keyed
2626 /// counts remain available via [`Story::save_state`].
2627 pub(crate) fn state_snapshot(&self) -> crate::session::StateSnapshot {
2628 use std::collections::BTreeMap;
2629
2630 use crate::debug::NameResolver;
2631 use crate::session::{SnapshotFrame, SnapshotList, StateSnapshot};
2632
2633 let flow = &self.default.flow;
2634 let ctx = &self.default_context;
2635 let resolver = NameResolver::new(&self.program);
2636
2637 // Typed globals + resolved list membership.
2638 let mut globals: BTreeMap<String, Value> = BTreeMap::new();
2639 let mut lists: BTreeMap<String, SnapshotList> = BTreeMap::new();
2640 for (i, value) in ctx.globals.iter().enumerate() {
2641 if let Some(name) = self.program.global_slot_name(i) {
2642 if let Value::List(list) = value {
2643 let mut items: Vec<String> = list
2644 .items
2645 .iter()
2646 .filter_map(|id| self.program.list_item_name(*id).map(str::to_owned))
2647 .collect();
2648 items.sort_unstable();
2649 lists.insert(name.to_owned(), SnapshotList { items });
2650 }
2651 globals.insert(name.to_owned(), value.clone());
2652 }
2653 }
2654
2655 // Visit / turn counts by resolved path (deterministic BTreeMap).
2656 let mut visit_counts: BTreeMap<String, u32> = BTreeMap::new();
2657 for (id, &count) in &ctx.visit_counts {
2658 if let Some(path) = resolver.def_path(*id) {
2659 visit_counts.insert(path.to_owned(), count);
2660 }
2661 }
2662 let mut turn_counts: BTreeMap<String, u32> = BTreeMap::new();
2663 for (id, &count) in &ctx.turn_counts {
2664 if let Some(path) = resolver.def_path(*id) {
2665 turn_counts.insert(path.to_owned(), count);
2666 }
2667 }
2668
2669 // Callstack summary, innermost frame first.
2670 let resolve_frame_location = |frame: &CallFrame| {
2671 frame
2672 .container_stack
2673 .iter()
2674 .rev()
2675 .find_map(|cp| resolver.container_path(cp.container_idx))
2676 .map(str::to_owned)
2677 };
2678 let thread = flow.current_thread();
2679 let depth = thread.call_stack.len();
2680 let mut call_stack = Vec::with_capacity(depth);
2681 for i in (0..depth).rev() {
2682 if let Some(frame) = thread.call_stack.get(i) {
2683 let kind = match frame.frame_type {
2684 CallFrameType::Root => "root",
2685 CallFrameType::Function => "function",
2686 CallFrameType::Tunnel => "tunnel",
2687 CallFrameType::Thread => "thread",
2688 CallFrameType::External => "external",
2689 CallFrameType::FunctionEvalFromGame => "eval",
2690 };
2691 call_stack.push(SnapshotFrame {
2692 kind: kind.to_owned(),
2693 location: resolve_frame_location(frame),
2694 temps: frame.temps.len(),
2695 });
2696 }
2697 }
2698
2699 StateSnapshot {
2700 globals,
2701 lists,
2702 turn_index: ctx.turn_index,
2703 visit_counts,
2704 turn_counts,
2705 call_stack,
2706 status: self.default.status.into(),
2707 }
2708 }
2709
2710 // ── Testing / instrumentation API ───────────────────────────────
2711
2712 /// Dump the current execution state for debugging.
2713 ///
2714 /// Returns a human-readable summary of the call stack, current position,
2715 /// value stack, output buffer, globals, and pending choices.
2716 #[cfg(feature = "testing")]
2717 pub fn debug_state(&self) -> String {
2718 use std::fmt::Write;
2719 let mut out = String::new();
2720 let flow = &self.default.flow;
2721 let ctx = &self.default_context;
2722
2723 let _ = writeln!(out, "=== Story Debug State ===");
2724 let _ = writeln!(out, "status: {:?}", self.default.status);
2725
2726 // Current position
2727 let thread = flow.current_thread();
2728 if let Some(frame) = thread.call_stack.last()
2729 && let Some(cp) = frame.container_stack.last()
2730 {
2731 let id = self.program.container(cp.container_idx).id;
2732 let _ = writeln!(
2733 out,
2734 "position: container_idx={} id={id:?} offset={}",
2735 cp.container_idx, cp.offset,
2736 );
2737 }
2738
2739 // Call stack
2740 let depth = thread.call_stack.len();
2741 let _ = writeln!(out, "\ncall stack ({depth} frames):");
2742 for i in 0..depth {
2743 if let Some(frame) = thread.call_stack.get(i) {
2744 let ret = frame
2745 .return_address
2746 .map(|r| format!("idx={} off={}", r.container_idx, r.offset));
2747 let _ = writeln!(
2748 out,
2749 " [{i}] {:?} ret={} temps={} containers={}",
2750 frame.frame_type,
2751 ret.as_deref().unwrap_or("none"),
2752 frame.temps.len(),
2753 frame.container_stack.len(),
2754 );
2755 for (j, cp) in frame.container_stack.iter().enumerate() {
2756 let id = self.program.container(cp.container_idx).id;
2757 let _ = writeln!(
2758 out,
2759 " container_stack[{j}]: idx={} id={id:?} off={}",
2760 cp.container_idx, cp.offset,
2761 );
2762 }
2763 }
2764 }
2765
2766 // Value stack
2767 let _ = writeln!(out, "\nvalue stack ({}):", flow.value_stack.len());
2768 for (i, v) in flow.value_stack.iter().enumerate() {
2769 let _ = writeln!(out, " [{i}] {v:?}");
2770 }
2771
2772 // Output buffer (unread transcript)
2773 let unread_start = flow.output.cursor;
2774 let transcript = &flow.output.transcript[unread_start..];
2775 let _ = writeln!(
2776 out,
2777 "\noutput buffer (cursor={unread_start}, {} unread parts):",
2778 transcript.len(),
2779 );
2780 for (i, part) in transcript.iter().enumerate() {
2781 let _ = writeln!(out, " [{i}] {part:?}");
2782 }
2783
2784 // Globals
2785 let _ = writeln!(out, "\nglobals:");
2786 for (i, v) in ctx.globals.iter().enumerate() {
2787 #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
2788 if let Some(name) = self.program.global_name(i as u32) {
2789 let _ = writeln!(out, " {name} = {v:?}");
2790 }
2791 }
2792
2793 // Flow flags
2794 let _ = writeln!(out, "\nskipping_choice: {}", flow.skipping_choice);
2795
2796 // Pending choices
2797 let _ = writeln!(out, "\npending choices ({}):", flow.pending_choices.len());
2798 for (i, c) in flow.pending_choices.iter().enumerate() {
2799 let _ = writeln!(out, " [{i}] {:?} -> {:?}", c.display, c.target_id);
2800 }
2801
2802 out
2803 }
2804
2805 /// Returns whether the last execution cycle ended with a safe exit
2806 /// (explicit `-> DONE` opcode). If false after a `Done` line, the
2807 /// story ran out of content.
2808 #[cfg(feature = "testing")]
2809 pub fn did_safe_exit(&self) -> bool {
2810 self.default.flow.did_safe_exit
2811 }
2812
2813 /// Returns whether the last execution cycle passed through an empty
2814 /// choice set (a `Yield` opcode with no pending choices).
2815 #[cfg(feature = "testing")]
2816 pub fn did_unsafe_yield(&self) -> bool {
2817 self.default.flow.did_unsafe_yield
2818 }
2819
2820 /// Execute a single VM step and return a debug trace of what happened.
2821 ///
2822 /// Returns `(opcode_description, container_idx, offset_before)` or None
2823 /// if the step didn't decode an opcode (frame exhaustion, thread completion, etc).
2824 #[cfg(feature = "testing")]
2825 pub fn step_once(&mut self) -> Result<Option<(String, u32, usize)>, RuntimeError> {
2826 use brink_format::Opcode;
2827
2828 let flow = &self.default.flow;
2829 let thread = flow.current_thread();
2830
2831 // Capture position before step
2832 let pre_info = thread.call_stack.last().and_then(|frame| {
2833 frame.container_stack.last().map(|pos| {
2834 let container = self.program.container(pos.container_idx);
2835 if pos.offset < container.bytecode.len() {
2836 let mut off = pos.offset;
2837 let op = Opcode::decode(&container.bytecode, &mut off).ok();
2838 (pos.container_idx, pos.offset, op)
2839 } else {
2840 (pos.container_idx, pos.offset, None)
2841 }
2842 })
2843 });
2844
2845 // Execute one step
2846 let _result = vm::step::<R>(
2847 &mut self.default.flow,
2848 &self.program,
2849 &self.line_tables,
2850 &mut self.default_context,
2851 &mut self.default.stats,
2852 self.resolver.as_deref(),
2853 )?;
2854
2855 match pre_info {
2856 Some((ci, off, Some(op))) => Ok(Some((format!("{op:?}"), ci, off))),
2857 Some((ci, off, None)) => Ok(Some(("(end of container)".to_string(), ci, off))),
2858 None => Ok(None),
2859 }
2860 }
2861}
2862
2863#[cfg(test)]
2864#[expect(clippy::panic)]
2865mod tests {
2866 use super::*;
2867 use crate::link;
2868
2869 fn load_i079_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2870 let json_str = std::fs::read_to_string(
2871 "../../tests/tier1/choices/I079-once-only-choices-can-link-back-to-self/story.ink.json",
2872 )
2873 .unwrap();
2874 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2875 let data = brink_converter::convert(&ink).unwrap();
2876 link(&data).unwrap()
2877 }
2878
2879 /// Step a story until it yields choices, panicking if it ends first.
2880 fn step_until_choices(story: &mut Story) -> Vec<Choice> {
2881 loop {
2882 match story.continue_single().unwrap() {
2883 Line::Choices { choices, .. } => return choices,
2884 Line::Text { .. } => {}
2885 Line::Done { .. } => panic!("story hit Done before presenting choices"),
2886 Line::End { .. } => panic!("story ended before presenting choices"),
2887 }
2888 }
2889 }
2890
2891 /// Step a story, accumulating text, until it stops (choices, done, or
2892 /// end) — returns the accumulated text for content assertions.
2893 fn step_until_choices_or_end(story: &mut Story) -> String {
2894 let mut text = String::new();
2895 loop {
2896 match story.continue_single().unwrap() {
2897 Line::Choices { text: t, .. }
2898 | Line::Done { text: t, .. }
2899 | Line::End { text: t, .. } => {
2900 text.push_str(&t);
2901 return text;
2902 }
2903 Line::Text { text: t, .. } => text.push_str(&t),
2904 }
2905 }
2906 }
2907
2908 /// After selecting a once-only choice, the visit count for its target
2909 /// container must be > 0. Without this, the once-only filter in
2910 /// `handle_begin_choice` can never fire.
2911 #[test]
2912 fn select_choice_increments_visit_count_for_target() {
2913 let (program, line_tables) = load_i079_program();
2914 let mut story = Story::new(Arc::new(program), line_tables);
2915 let choices = step_until_choices(&mut story);
2916
2917 assert!(!choices.is_empty(), "expected at least one choice");
2918
2919 // Record the target_id of the first pending choice BEFORE selecting.
2920 let target_id = story.default.flow.pending_choices[0].target_id;
2921 let visit_before = story
2922 .default_context
2923 .visit_counts
2924 .get(&target_id)
2925 .copied()
2926 .unwrap_or(0);
2927
2928 story.choose(0).unwrap();
2929
2930 // After selection, the visit count for this target must have increased.
2931 let visit_after = story
2932 .default_context
2933 .visit_counts
2934 .get(&target_id)
2935 .copied()
2936 .unwrap_or(0);
2937 assert!(
2938 visit_after > visit_before,
2939 "visit count for choice target should increment after selection: \
2940 before={visit_before}, after={visit_after}"
2941 );
2942 }
2943
2944 /// Build a linked `Story` directly from `.ink` source (no fixture file),
2945 /// for cases that need a specific choice shape not already in `tests/`.
2946 fn story_from_source(src: &str) -> Story {
2947 let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
2948 let mut bytes = Vec::new();
2949 brink_format::write_inkb(&out.data, &mut bytes);
2950 let data = brink_format::read_inkb(&bytes).expect("decode");
2951 let (prog, tables) = link(&data).expect("link");
2952 Story::new(Arc::new(prog), tables)
2953 }
2954
2955 /// `Choice.index` (the live, visible choice list) must be the *raw*
2956 /// `pending_choices` position, not the post-filter enumeration position —
2957 /// an invisible-default fallback choice (`* ->`) mixed in with visible
2958 /// choices occupies a `pending_choices` slot but never appears in the
2959 /// visible list, so the visible indices can skip values. This is exactly
2960 /// what `select_choice`/`choose` expects (it indexes `pending_choices`
2961 /// directly) — a caller must never re-derive the index from array
2962 /// position over the visible list alone.
2963 #[test]
2964 fn choice_index_is_raw_pending_choices_position_with_invisible_default_mixed_in() {
2965 let src = "-(start)\n\
2966 * [First] -> a\n\
2967 * -> b\n\
2968 * [Third] -> c\n\
2969 -(a) Went A.\n-> DONE\n\
2970 -(b) Went B.\n-> DONE\n\
2971 -(c) Went C.\n-> DONE\n";
2972 let mut story = story_from_source(src);
2973 let choices = step_until_choices(&mut story);
2974
2975 // The invisible-default fallback (raw index 1) is filtered out of the
2976 // visible list, so the visible choices' indices skip it: 0, then 2.
2977 assert_eq!(
2978 choices.iter().map(|c| c.index).collect::<Vec<_>>(),
2979 vec![0, 2],
2980 "visible choice indices must be the raw pending_choices positions, not 0,1,..: {choices:?}"
2981 );
2982 assert_eq!(story.default.flow.pending_choices.len(), 3);
2983
2984 // Choosing the raw index of the second visible entry must select
2985 // the "Third" branch, not the invisible-default fallback.
2986 story.choose(choices[1].index).expect("choose by raw index");
2987 let text = step_until_choices_or_end(&mut story);
2988 assert!(text.contains("Went C"), "expected the Third branch: {text}");
2989 }
2990
2991 /// `DebugSnapshot.pending_choices[].index` must agree with the live
2992 /// `Choice.index` — both derive from the same pre-filter pass over
2993 /// `pending_choices` (`resolved_choices_for`). A studio consumer restoring
2994 /// a Choice[] from a `DebugSnapshot` (rather than a live `Choice` list)
2995 /// depends on this to dispatch `choose()` correctly.
2996 #[test]
2997 fn debug_snapshot_choice_index_matches_live_choice_index() {
2998 let src = "-(start)\n\
2999 * [First] -> a\n\
3000 * -> b\n\
3001 * [Third] -> c\n\
3002 -(a) Went A.\n-> DONE\n\
3003 -(b) Went B.\n-> DONE\n\
3004 -(c) Went C.\n-> DONE\n";
3005 let mut story = story_from_source(src);
3006 let live_choices = step_until_choices(&mut story);
3007 let snap = story.debug_snapshot();
3008
3009 assert_eq!(snap.pending_choices.len(), live_choices.len());
3010 for (live, dbg) in live_choices.iter().zip(snap.pending_choices.iter()) {
3011 assert_eq!(
3012 dbg.index, live.index,
3013 "DebugChoice.index must match the live Choice.index"
3014 );
3015 }
3016 }
3017
3018 /// On the second pass through a choice set with once-only choices,
3019 /// a choice whose target has already been visited must NOT appear
3020 /// in `pending_choices`.
3021 #[test]
3022 fn once_only_choice_excluded_on_second_pass() {
3023 let (program, line_tables) = load_i079_program();
3024 let mut story = Story::new(Arc::new(program), line_tables);
3025
3026 let first_choices = step_until_choices(&mut story);
3027 assert!(
3028 first_choices
3029 .iter()
3030 .any(|c| c.text.contains("First choice")),
3031 "first pass should contain 'First choice', got: {first_choices:?}"
3032 );
3033
3034 story.choose(0).unwrap();
3035
3036 let second_choices = step_until_choices(&mut story);
3037 assert!(
3038 !second_choices
3039 .iter()
3040 .any(|c| c.text.contains("First choice")),
3041 "second pass should NOT contain 'First choice' (once-only, already visited), \
3042 got: {second_choices:?}"
3043 );
3044 }
3045
3046 // ── Choice thread forking ──────────────────────────────────────────
3047
3048 fn load_i083_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3049 let json_str = std::fs::read_to_string(
3050 "../../tests/tier1/choices/I083-choice-thread-forking/story.ink.json",
3051 )
3052 .unwrap();
3053 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3054 let data = brink_converter::convert(&ink).unwrap();
3055 link(&data).unwrap()
3056 }
3057
3058 /// When a choice is created inside a tunnel, the call stack at that
3059 /// moment (including the tunnel frame with its temps) must be captured.
3060 /// After the tunnel returns and the choice is presented, the snapshot
3061 /// should still reflect the tunnel-era call stack depth (>= 2 frames).
3062 #[test]
3063 fn pending_choice_captures_tunnel_call_stack() {
3064 let (program, line_tables) = load_i083_program();
3065 let mut story = Story::new(Arc::new(program), line_tables);
3066 let _choices = step_until_choices(&mut story);
3067
3068 // At this point the tunnel has returned, so the live call_stack
3069 // has only the root frame.
3070 let current_thread = story.default.flow.current_thread();
3071 assert_eq!(
3072 current_thread.call_stack.len(),
3073 1,
3074 "live call stack should be 1 frame (root) after tunnel return"
3075 );
3076
3077 // But the pending choice's fork should have captured the
3078 // call stack from inside the tunnel (root + tunnel = 2 frames).
3079 assert!(!story.default.flow.pending_choices.is_empty());
3080 let fork = &story.default.flow.pending_choices[0].thread_fork;
3081 assert!(
3082 fork.call_stack.len() >= 2,
3083 "choice fork should have >= 2 frames (root + tunnel), got {}",
3084 fork.call_stack.len()
3085 );
3086 }
3087
3088 /// After selecting a choice that was created inside a tunnel,
3089 /// `select_choice` must restore the tunnel's call frame so that
3090 /// temp variables from the tunnel scope are accessible.
3091 #[test]
3092 fn select_choice_restores_tunnel_frame_with_temps() {
3093 let (program, line_tables) = load_i083_program();
3094 let mut story = Story::new(Arc::new(program), line_tables);
3095 let _choices = step_until_choices(&mut story);
3096
3097 // Before choosing: only root frame, no tunnel temps.
3098 assert_eq!(story.default.flow.current_thread().call_stack.len(), 1);
3099
3100 story.choose(0).unwrap();
3101
3102 // After choosing: the tunnel frame should be restored.
3103 // The call stack should have at least 2 frames (root + tunnel).
3104 let call_stack = &story.default.flow.current_thread().call_stack;
3105 assert!(
3106 call_stack.len() >= 2,
3107 "call stack should be restored to tunnel depth after choice selection, \
3108 got {} frame(s)",
3109 call_stack.len()
3110 );
3111
3112 // The tunnel frame (last frame) should have temp x = Int(1).
3113 let tunnel_frame = call_stack.last().unwrap();
3114 assert!(
3115 !tunnel_frame.temps.is_empty(),
3116 "tunnel frame should have temp variables"
3117 );
3118 assert_eq!(
3119 tunnel_frame.temps[0],
3120 Value::Int(1),
3121 "tunnel frame temps[0] should be Int(1) (the parameter x)"
3122 );
3123 }
3124
3125 // ── Tags ──────────────────────────────────────────────────────────
3126
3127 fn load_tags_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3128 let json_str =
3129 std::fs::read_to_string("../../tests/tier3/tags/tags/story.ink.json").unwrap();
3130 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3131 let data = brink_converter::convert(&ink).unwrap();
3132 link(&data).unwrap()
3133 }
3134
3135 fn load_tags_in_choice_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3136 let json_str =
3137 std::fs::read_to_string("../../tests/tier3/tags/tagsInChoice/story.ink.json").unwrap();
3138 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3139 let data = brink_converter::convert(&ink).unwrap();
3140 link(&data).unwrap()
3141 }
3142
3143 #[test]
3144 fn line_exposes_tags() {
3145 let (program, line_tables) = load_tags_program();
3146 let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
3147 let lines = story.continue_maximally().unwrap();
3148 // The first line should have both tags.
3149 let first = lines.first().expect("expected at least one line");
3150 assert!(
3151 !matches!(first, Line::Choices { .. }),
3152 "expected Text or End, got Choices"
3153 );
3154 assert_eq!(first.tags(), &["author: Joe", "title: My Great Story"],);
3155 }
3156
3157 #[test]
3158 fn choice_exposes_tags() {
3159 let (program, line_tables) = load_tags_in_choice_program();
3160 let mut story = Story::new(Arc::new(program), line_tables);
3161 let choices = step_until_choices(&mut story);
3162 assert!(!choices.is_empty());
3163 // The choice in tagsInChoice has tags "one" and "two"
3164 assert!(
3165 !choices[0].tags.is_empty(),
3166 "choice should have tags, got: {choices:?}"
3167 );
3168 }
3169
3170 // ── Thread support ──────────────────────────────────────────────────
3171
3172 fn load_i091_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3173 let json_str =
3174 std::fs::read_to_string("../../tests/tier1/choices/I091-choice-count/story.ink.json")
3175 .unwrap();
3176 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3177 let data = brink_converter::convert(&ink).unwrap();
3178 link(&data).unwrap()
3179 }
3180
3181 /// `<- choices` (thread) must create choices AND return to the main
3182 /// flow so that `CHOICE_COUNT()` can evaluate. The thread body
3183 /// should be called like a tunnel — when its container stack empties,
3184 /// execution returns to the caller. Non-root frames must always pop
3185 /// back to their caller, even when pending choices exist.
3186 #[test]
3187 fn thread_call_returns_to_main_flow() {
3188 let (program, line_tables) = load_i091_program();
3189 let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
3190
3191 let lines = story.continue_maximally().unwrap();
3192 // I091 should output "2\n" (CHOICE_COUNT) then present 2 choices.
3193 let full_text: String = lines.iter().map(Line::text).collect();
3194 assert!(
3195 full_text.starts_with('2'),
3196 "output should start with '2' from CHOICE_COUNT(), got: {full_text:?}"
3197 );
3198 let last = lines.last().expect("expected at least one line");
3199 match last {
3200 Line::Choices { choices, .. } => {
3201 assert_eq!(choices.len(), 2, "expected 2 choices");
3202 }
3203 other => panic!("expected Choices, got {other:?}"),
3204 }
3205 }
3206
3207 // ── FlowInstance::drive_to_terminal (F6.1a shared drive-to-terminal op) ──
3208
3209 /// Compile `.ink` source directly into a linked `(Program, line_tables)`
3210 /// pair, bypassing `Story` so tests can drive a bare `FlowInstance`
3211 /// directly — the way a `Story`-free consumer (e.g. an engine
3212 /// integration) would.
3213 fn compile_source_for_flow(src: &str) -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3214 let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
3215 let mut bytes = Vec::new();
3216 brink_format::write_inkb(&out.data, &mut bytes);
3217 let data = brink_format::read_inkb(&bytes).expect("decode");
3218 link(&data).expect("link")
3219 }
3220
3221 #[test]
3222 fn drive_to_terminal_stops_at_done() {
3223 let (program, tables) = compile_source_for_flow("Hello.\n-> DONE\n");
3224 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3225 let mut local = FlowLocal::new();
3226 let mut view = ContextView::new(&mut world, &mut local);
3227 let lines = flow
3228 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3229 .expect("drive succeeds");
3230 let (last, rest) = lines.split_last().expect("at least one line");
3231 assert!(
3232 matches!(last, Line::Done { .. }),
3233 "expected Done, got {last:?}"
3234 );
3235 assert!(
3236 rest.iter().all(|l| matches!(l, Line::Text { .. })),
3237 "every line before the terminal one should be Text, got {rest:?}"
3238 );
3239 }
3240
3241 #[test]
3242 fn drive_to_terminal_stops_at_choices() {
3243 let (program, tables) =
3244 compile_source_for_flow("Hello.\n* Pick me\n Picked.\n -> DONE\n");
3245 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3246 let mut local = FlowLocal::new();
3247 let mut view = ContextView::new(&mut world, &mut local);
3248 let lines = flow
3249 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3250 .expect("drive succeeds");
3251 let (last, rest) = lines.split_last().expect("at least one line");
3252 assert!(
3253 matches!(last, Line::Choices { .. }),
3254 "expected Choices, got {last:?}"
3255 );
3256 assert!(
3257 rest.iter().all(|l| matches!(l, Line::Text { .. })),
3258 "every line before the terminal one should be Text, got {rest:?}"
3259 );
3260 }
3261
3262 #[test]
3263 fn drive_to_terminal_stops_at_end() {
3264 let (program, tables) = compile_source_for_flow("Hello.\n-> END\n");
3265 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3266 let mut local = FlowLocal::new();
3267 let mut view = ContextView::new(&mut world, &mut local);
3268 let lines = flow
3269 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3270 .expect("drive succeeds");
3271 let (last, rest) = lines.split_last().expect("at least one line");
3272 assert!(
3273 matches!(last, Line::End { .. }),
3274 "expected End, got {last:?}"
3275 );
3276 assert!(
3277 rest.iter().all(|l| matches!(l, Line::Text { .. })),
3278 "every line before the terminal one should be Text, got {rest:?}"
3279 );
3280 }
3281
3282 /// A knot that prints and re-diverts into itself forever never reaches a
3283 /// terminal line, so `drive_to_terminal` must give up at
3284 /// `FlowInstance::LINE_LIMIT` rather than looping forever — proving the
3285 /// extracted op kept `Story::continue_maximally_impl`'s safety cap.
3286 #[test]
3287 fn drive_to_terminal_errors_at_line_limit() {
3288 let (program, tables) =
3289 compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
3290 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3291 let mut local = FlowLocal::new();
3292 let mut view = ContextView::new(&mut world, &mut local);
3293 let err = flow
3294 .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3295 .expect_err("infinite content should hit the line limit rather than hang");
3296 match err {
3297 RuntimeError::LineLimitExceeded(n) => {
3298 assert_eq!(n, FlowInstance::LINE_LIMIT);
3299 }
3300 other => panic!("expected LineLimitExceeded, got {other:?}"),
3301 }
3302 }
3303
3304 // ── FlowInstance::drive (F6.2 pausable Layer-2 drive op) ─────────────
3305
3306 /// Defers (`Pending`) its first call, then resolves — mirrors the
3307 /// `DeferOnce` pattern used for the flow-level resume gap elsewhere in
3308 /// the runtime's test suite (`tests/session.rs`, `tests/speculation.rs`).
3309 struct DeferOnce {
3310 deferred: std::cell::Cell<bool>,
3311 }
3312
3313 impl ExternalFnHandler for DeferOnce {
3314 fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
3315 if name == "pause_once" && !self.deferred.get() {
3316 self.deferred.set(true);
3317 ExternalResult::Pending
3318 } else {
3319 ExternalResult::Resolved(Value::Int(2))
3320 }
3321 }
3322 }
3323
3324 /// `drive` pauses cleanly (no error) on a deferred external, and resuming
3325 /// after [`FlowInstance::resolve_external`] continues the *same* logical
3326 /// drive to its terminal line — the pausable sibling of
3327 /// `drive_to_terminal`, which instead errors on a deferred external.
3328 #[test]
3329 fn drive_pauses_on_awaiting_external_then_resumes() {
3330 let (program, tables) = compile_source_for_flow(
3331 "EXTERNAL pause_once(x)\nHello.\nWorld.\nValue: {pause_once(1)}.\n-> DONE\n",
3332 );
3333 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3334 let mut local = FlowLocal::new();
3335 let mut view = ContextView::new(&mut world, &mut local);
3336 let handler = DeferOnce {
3337 deferred: std::cell::Cell::new(false),
3338 };
3339 let mut budget = 10usize;
3340
3341 let outcome = flow
3342 .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
3343 .expect("first drive call succeeds");
3344 let paused_lines = match outcome {
3345 DriveOutcome::AwaitingExternal(lines) => lines,
3346 other @ DriveOutcome::Terminal(_) => panic!("expected AwaitingExternal, got {other:?}"),
3347 };
3348 let paused_text: String = paused_lines.iter().map(Line::text).collect();
3349 assert!(
3350 paused_text.contains("Hello"),
3351 "text produced before the pause should include 'Hello.'; got {paused_text:?}"
3352 );
3353 assert!(
3354 !paused_text.contains("Value"),
3355 "the line calling the deferred external should not have completed yet; got {paused_text:?}"
3356 );
3357 assert_eq!(
3358 budget,
3359 10 - paused_lines.len(),
3360 "budget should decrement by exactly the lines produced before the pause"
3361 );
3362
3363 flow.resolve_external(Value::Int(2));
3364 let outcome = flow
3365 .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
3366 .expect("second drive call resumes and completes");
3367 let resumed_lines = match outcome {
3368 DriveOutcome::Terminal(lines) => lines,
3369 other @ DriveOutcome::AwaitingExternal(_) => panic!("expected Terminal, got {other:?}"),
3370 };
3371 let resumed_text: String = resumed_lines.iter().map(Line::text).collect();
3372 assert!(
3373 resumed_text.contains("Value: 2"),
3374 "the resolved external's value should be inlined; got {resumed_text:?}"
3375 );
3376 assert!(
3377 matches!(resumed_lines.last(), Some(Line::Done { .. })),
3378 "expected the drive to finish at Done, got {resumed_lines:?}"
3379 );
3380 assert_eq!(
3381 budget,
3382 10 - paused_lines.len() - resumed_lines.len(),
3383 "budget must keep decrementing across the resume — not reset to a fresh cap \
3384 (the whole point of the caller-owned budget: one bound per logical drive, not \
3385 per resume)"
3386 );
3387 }
3388
3389 /// A knot that prints and re-diverts into itself forever never reaches a
3390 /// terminal line. `drive` must give up when the caller's `budget` is
3391 /// exhausted — which can be far smaller than
3392 /// [`FlowInstance::LINE_LIMIT`] — rather than looping until the much
3393 /// larger production default, proving the budget is a real per-call
3394 /// parameter and not just a relabeling of the constant.
3395 #[test]
3396 fn drive_errors_when_caller_budget_exhausted() {
3397 let (program, tables) =
3398 compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
3399 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3400 let mut local = FlowLocal::new();
3401 let mut view = ContextView::new(&mut world, &mut local);
3402 let mut budget = 3usize;
3403 let err = flow
3404 .drive::<FastRng>(
3405 &program,
3406 &tables,
3407 &mut view,
3408 &FallbackHandler,
3409 None,
3410 &mut budget,
3411 )
3412 .expect_err("infinite content should hit the caller's small budget");
3413 match err {
3414 RuntimeError::LineLimitExceeded(n) => assert_eq!(
3415 n, 3,
3416 "reported limit should be the caller's budget, not the unrelated LINE_LIMIT constant"
3417 ),
3418 other => panic!("expected LineLimitExceeded, got {other:?}"),
3419 }
3420 assert_eq!(budget, 0, "budget should be fully consumed, not partially");
3421 }
3422
3423 // ── free `save_state`/`load_state` (F6.1b) ───────────────────────────
3424
3425 /// A `Story`-free save/load roundtrip: drive a bare `FlowInstance` +
3426 /// `ContextView` (no `Story` anywhere), capture state via the lifted
3427 /// `save_state` free function, mutate the live context, then restore via
3428 /// `load_state` and confirm the mutation is undone. Proves the lifted
3429 /// functions work for a consumer (e.g. `bevy-brink`) that never
3430 /// constructs a `Story`.
3431 #[test]
3432 fn free_fn_save_load_roundtrip_without_story() {
3433 let (program, tables) = compile_source_for_flow(
3434 "VAR gold = 0\n\
3435 -> shrine\n\
3436 === shrine ===\n\
3437 ~ gold = 5\n\
3438 Shrine text.\n\
3439 -> DONE\n\
3440 === reader ===\n\
3441 {READ_COUNT(-> shrine)}\n\
3442 -> DONE\n",
3443 // `reader` is never entered — it exists only so the compiler's
3444 // counting-flags pass sees a visit-count read of `shrine` and
3445 // sets `CountingFlags::VISITS` on it (a knot with no read of its
3446 // own visit count anywhere in the program has counting disabled
3447 // entirely, an existing compiler optimization).
3448 );
3449 let (mut flow, mut world) = FlowInstance::new_at_root(&program);
3450 let mut local = FlowLocal::new();
3451 {
3452 let mut view = ContextView::new(&mut world, &mut local);
3453 flow.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
3454 .expect("drive succeeds");
3455 }
3456
3457 let gold_slot = program.global_index("gold").expect("gold declared");
3458 let shrine_id = program.find_path_target("shrine").expect("shrine exists");
3459
3460 let save = {
3461 let view = ContextView::new(&mut world, &mut local);
3462 crate::save_state(&program, &view)
3463 };
3464 assert_eq!(save.globals.get("gold"), Some(&Value::Int(5)));
3465 assert_eq!(
3466 save.visits
3467 .iter()
3468 .find(|e| e.id == shrine_id)
3469 .map(|e| e.count),
3470 Some(1),
3471 "shrine should have a captured visit entry"
3472 );
3473
3474 // Mutate the live context directly through the trait.
3475 {
3476 let mut view = ContextView::new(&mut world, &mut local);
3477 view.set_global(gold_slot, Value::Int(999));
3478 view.set_visit_count(shrine_id, 42);
3479 }
3480 {
3481 let view = ContextView::new(&mut world, &mut local);
3482 assert_eq!(view.global(gold_slot), &Value::Int(999));
3483 assert_eq!(view.visit_count(shrine_id), 42);
3484 }
3485
3486 // Restore via the lifted `load_state` and confirm the mutation is
3487 // undone.
3488 let report = {
3489 let mut view = ContextView::new(&mut world, &mut local);
3490 crate::load_state(&program, &mut view, &save)
3491 };
3492 assert!(report.unknown_globals.is_empty(), "clean load: {report:?}");
3493
3494 let view = ContextView::new(&mut world, &mut local);
3495 assert_eq!(view.global(gold_slot), &Value::Int(5));
3496 assert_eq!(view.visit_count(shrine_id), 1);
3497 }
3498}