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