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 // `ch.index` is the pre-filter `flow.pending_choices` position
2485 // (see `resolved_choices_for`) — the same index `choose()`
2486 // expects, not the post-filter enumeration position `i`.
2487 index: ch.index,
2488 })
2489 .collect();
2490
2491 DebugSnapshot {
2492 status,
2493 current_location,
2494 turn_index: ctx.turn_index,
2495 globals,
2496 call_stack,
2497 visit_counts,
2498 pending_choices,
2499 rng: DebugRng {
2500 seed: ctx.rng_seed,
2501 previous: ctx.previous_random,
2502 },
2503 }
2504 }
2505
2506 // ── Session support (crate-internal) ────────────────────────────
2507
2508 /// Whether the default flow is in the `Active` status (mid-turn, more
2509 /// content pending). Used by [`StorySession`](crate::StorySession) for the
2510 /// turn-boundary mutation gate.
2511 pub(crate) fn status_is_active(&self) -> bool {
2512 self.default.status == StoryStatus::Active
2513 }
2514
2515 /// Whether the default flow is waiting for a choice selection. Used by
2516 /// [`StorySession`](crate::StorySession) replay.
2517 pub(crate) fn status_is_waiting_for_choice(&self) -> bool {
2518 self.default.status == StoryStatus::WaitingForChoice
2519 }
2520
2521 /// Build a typed [`StateSnapshot`](crate::StateSnapshot) of the default
2522 /// flow's game state — a NEW typed serialization path (globals with list
2523 /// membership, turn counts, callstack summary), distinct from the
2524 /// string-valued [`DebugSnapshot`](crate::DebugSnapshot).
2525 ///
2526 /// Known projection limit (deliberate, not a silent bug): visit/turn-count
2527 /// entries whose scope has no resolvable author path (anonymous counted
2528 /// containers — gathers, choice points — keyed only by hash id) are
2529 /// **omitted** from the snapshot's path-keyed maps. The full id-keyed
2530 /// counts remain available via [`Story::save_state`].
2531 pub(crate) fn state_snapshot(&self) -> crate::session::StateSnapshot {
2532 use std::collections::BTreeMap;
2533
2534 use crate::debug::NameResolver;
2535 use crate::session::{SnapshotFrame, SnapshotList, StateSnapshot};
2536
2537 let flow = &self.default.flow;
2538 let ctx = &self.default_context;
2539 let resolver = NameResolver::new(self.program);
2540
2541 // Typed globals + resolved list membership.
2542 let mut globals: BTreeMap<String, Value> = BTreeMap::new();
2543 let mut lists: BTreeMap<String, SnapshotList> = BTreeMap::new();
2544 for (i, value) in ctx.globals.iter().enumerate() {
2545 if let Some(name) = self.program.global_slot_name(i) {
2546 if let Value::List(list) = value {
2547 let mut items: Vec<String> = list
2548 .items
2549 .iter()
2550 .filter_map(|id| self.program.list_item_name(*id).map(str::to_owned))
2551 .collect();
2552 items.sort_unstable();
2553 lists.insert(name.to_owned(), SnapshotList { items });
2554 }
2555 globals.insert(name.to_owned(), value.clone());
2556 }
2557 }
2558
2559 // Visit / turn counts by resolved path (deterministic BTreeMap).
2560 let mut visit_counts: BTreeMap<String, u32> = BTreeMap::new();
2561 for (id, &count) in &ctx.visit_counts {
2562 if let Some(path) = resolver.def_path(*id) {
2563 visit_counts.insert(path.to_owned(), count);
2564 }
2565 }
2566 let mut turn_counts: BTreeMap<String, u32> = BTreeMap::new();
2567 for (id, &count) in &ctx.turn_counts {
2568 if let Some(path) = resolver.def_path(*id) {
2569 turn_counts.insert(path.to_owned(), count);
2570 }
2571 }
2572
2573 // Callstack summary, innermost frame first.
2574 let resolve_frame_location = |frame: &CallFrame| {
2575 frame
2576 .container_stack
2577 .iter()
2578 .rev()
2579 .find_map(|cp| resolver.container_path(cp.container_idx))
2580 .map(str::to_owned)
2581 };
2582 let thread = flow.current_thread();
2583 let depth = thread.call_stack.len();
2584 let mut call_stack = Vec::with_capacity(depth);
2585 for i in (0..depth).rev() {
2586 if let Some(frame) = thread.call_stack.get(i) {
2587 let kind = match frame.frame_type {
2588 CallFrameType::Root => "root",
2589 CallFrameType::Function => "function",
2590 CallFrameType::Tunnel => "tunnel",
2591 CallFrameType::Thread => "thread",
2592 CallFrameType::External => "external",
2593 CallFrameType::FunctionEvalFromGame => "eval",
2594 };
2595 call_stack.push(SnapshotFrame {
2596 kind: kind.to_owned(),
2597 location: resolve_frame_location(frame),
2598 temps: frame.temps.len(),
2599 });
2600 }
2601 }
2602
2603 StateSnapshot {
2604 globals,
2605 lists,
2606 turn_index: ctx.turn_index,
2607 visit_counts,
2608 turn_counts,
2609 call_stack,
2610 status: self.default.status.into(),
2611 }
2612 }
2613
2614 // ── Testing / instrumentation API ───────────────────────────────
2615
2616 /// Dump the current execution state for debugging.
2617 ///
2618 /// Returns a human-readable summary of the call stack, current position,
2619 /// value stack, output buffer, globals, and pending choices.
2620 #[cfg(feature = "testing")]
2621 pub fn debug_state(&self) -> String {
2622 use std::fmt::Write;
2623 let mut out = String::new();
2624 let flow = &self.default.flow;
2625 let ctx = &self.default_context;
2626
2627 let _ = writeln!(out, "=== Story Debug State ===");
2628 let _ = writeln!(out, "status: {:?}", self.default.status);
2629
2630 // Current position
2631 let thread = flow.current_thread();
2632 if let Some(frame) = thread.call_stack.last()
2633 && let Some(cp) = frame.container_stack.last()
2634 {
2635 let id = self.program.container(cp.container_idx).id;
2636 let _ = writeln!(
2637 out,
2638 "position: container_idx={} id={id:?} offset={}",
2639 cp.container_idx, cp.offset,
2640 );
2641 }
2642
2643 // Call stack
2644 let depth = thread.call_stack.len();
2645 let _ = writeln!(out, "\ncall stack ({depth} frames):");
2646 for i in 0..depth {
2647 if let Some(frame) = thread.call_stack.get(i) {
2648 let ret = frame
2649 .return_address
2650 .map(|r| format!("idx={} off={}", r.container_idx, r.offset));
2651 let _ = writeln!(
2652 out,
2653 " [{i}] {:?} ret={} temps={} containers={}",
2654 frame.frame_type,
2655 ret.as_deref().unwrap_or("none"),
2656 frame.temps.len(),
2657 frame.container_stack.len(),
2658 );
2659 for (j, cp) in frame.container_stack.iter().enumerate() {
2660 let id = self.program.container(cp.container_idx).id;
2661 let _ = writeln!(
2662 out,
2663 " container_stack[{j}]: idx={} id={id:?} off={}",
2664 cp.container_idx, cp.offset,
2665 );
2666 }
2667 }
2668 }
2669
2670 // Value stack
2671 let _ = writeln!(out, "\nvalue stack ({}):", flow.value_stack.len());
2672 for (i, v) in flow.value_stack.iter().enumerate() {
2673 let _ = writeln!(out, " [{i}] {v:?}");
2674 }
2675
2676 // Output buffer (unread transcript)
2677 let unread_start = flow.output.cursor;
2678 let transcript = &flow.output.transcript[unread_start..];
2679 let _ = writeln!(
2680 out,
2681 "\noutput buffer (cursor={unread_start}, {} unread parts):",
2682 transcript.len(),
2683 );
2684 for (i, part) in transcript.iter().enumerate() {
2685 let _ = writeln!(out, " [{i}] {part:?}");
2686 }
2687
2688 // Globals
2689 let _ = writeln!(out, "\nglobals:");
2690 for (i, v) in ctx.globals.iter().enumerate() {
2691 #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
2692 if let Some(name) = self.program.global_name(i as u32) {
2693 let _ = writeln!(out, " {name} = {v:?}");
2694 }
2695 }
2696
2697 // Flow flags
2698 let _ = writeln!(out, "\nskipping_choice: {}", flow.skipping_choice);
2699
2700 // Pending choices
2701 let _ = writeln!(out, "\npending choices ({}):", flow.pending_choices.len());
2702 for (i, c) in flow.pending_choices.iter().enumerate() {
2703 let _ = writeln!(out, " [{i}] {:?} -> {:?}", c.display, c.target_id);
2704 }
2705
2706 out
2707 }
2708
2709 /// Returns whether the last execution cycle ended with a safe exit
2710 /// (explicit `-> DONE` opcode). If false after a `Done` line, the
2711 /// story ran out of content.
2712 #[cfg(feature = "testing")]
2713 pub fn did_safe_exit(&self) -> bool {
2714 self.default.flow.did_safe_exit
2715 }
2716
2717 /// Returns whether the last execution cycle passed through an empty
2718 /// choice set (a `Yield` opcode with no pending choices).
2719 #[cfg(feature = "testing")]
2720 pub fn did_unsafe_yield(&self) -> bool {
2721 self.default.flow.did_unsafe_yield
2722 }
2723
2724 /// Execute a single VM step and return a debug trace of what happened.
2725 ///
2726 /// Returns `(opcode_description, container_idx, offset_before)` or None
2727 /// if the step didn't decode an opcode (frame exhaustion, thread completion, etc).
2728 #[cfg(feature = "testing")]
2729 pub fn step_once(&mut self) -> Result<Option<(String, u32, usize)>, RuntimeError> {
2730 use brink_format::Opcode;
2731
2732 let flow = &self.default.flow;
2733 let thread = flow.current_thread();
2734
2735 // Capture position before step
2736 let pre_info = thread.call_stack.last().and_then(|frame| {
2737 frame.container_stack.last().map(|pos| {
2738 let container = self.program.container(pos.container_idx);
2739 if pos.offset < container.bytecode.len() {
2740 let mut off = pos.offset;
2741 let op = Opcode::decode(&container.bytecode, &mut off).ok();
2742 (pos.container_idx, pos.offset, op)
2743 } else {
2744 (pos.container_idx, pos.offset, None)
2745 }
2746 })
2747 });
2748
2749 // Execute one step
2750 let _result = vm::step::<R>(
2751 &mut self.default.flow,
2752 self.program,
2753 &self.line_tables,
2754 &mut self.default_context,
2755 &mut self.default.stats,
2756 self.resolver.as_deref(),
2757 )?;
2758
2759 match pre_info {
2760 Some((ci, off, Some(op))) => Ok(Some((format!("{op:?}"), ci, off))),
2761 Some((ci, off, None)) => Ok(Some(("(end of container)".to_string(), ci, off))),
2762 None => Ok(None),
2763 }
2764 }
2765}
2766
2767#[cfg(test)]
2768#[expect(clippy::panic)]
2769mod tests {
2770 use super::*;
2771 use crate::link;
2772
2773 fn load_i079_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2774 let json_str = std::fs::read_to_string(
2775 "../../tests/tier1/choices/I079-once-only-choices-can-link-back-to-self/story.ink.json",
2776 )
2777 .unwrap();
2778 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2779 let data = brink_converter::convert(&ink).unwrap();
2780 link(&data).unwrap()
2781 }
2782
2783 /// Step a story until it yields choices, panicking if it ends first.
2784 fn step_until_choices(story: &mut Story) -> Vec<Choice> {
2785 loop {
2786 match story.continue_single().unwrap() {
2787 Line::Choices { choices, .. } => return choices,
2788 Line::Text { .. } => {}
2789 Line::Done { .. } => panic!("story hit Done before presenting choices"),
2790 Line::End { .. } => panic!("story ended before presenting choices"),
2791 }
2792 }
2793 }
2794
2795 /// Step a story, accumulating text, until it stops (choices, done, or
2796 /// end) — returns the accumulated text for content assertions.
2797 fn step_until_choices_or_end(story: &mut Story) -> String {
2798 let mut text = String::new();
2799 loop {
2800 match story.continue_single().unwrap() {
2801 Line::Choices { text: t, .. }
2802 | Line::Done { text: t, .. }
2803 | Line::End { text: t, .. } => {
2804 text.push_str(&t);
2805 return text;
2806 }
2807 Line::Text { text: t, .. } => text.push_str(&t),
2808 }
2809 }
2810 }
2811
2812 /// After selecting a once-only choice, the visit count for its target
2813 /// container must be > 0. Without this, the once-only filter in
2814 /// `handle_begin_choice` can never fire.
2815 #[test]
2816 fn select_choice_increments_visit_count_for_target() {
2817 let (program, line_tables) = load_i079_program();
2818 let mut story = Story::new(&program, line_tables);
2819 let choices = step_until_choices(&mut story);
2820
2821 assert!(!choices.is_empty(), "expected at least one choice");
2822
2823 // Record the target_id of the first pending choice BEFORE selecting.
2824 let target_id = story.default.flow.pending_choices[0].target_id;
2825 let visit_before = story
2826 .default_context
2827 .visit_counts
2828 .get(&target_id)
2829 .copied()
2830 .unwrap_or(0);
2831
2832 story.choose(0).unwrap();
2833
2834 // After selection, the visit count for this target must have increased.
2835 let visit_after = story
2836 .default_context
2837 .visit_counts
2838 .get(&target_id)
2839 .copied()
2840 .unwrap_or(0);
2841 assert!(
2842 visit_after > visit_before,
2843 "visit count for choice target should increment after selection: \
2844 before={visit_before}, after={visit_after}"
2845 );
2846 }
2847
2848 /// Build a linked `Story` directly from `.ink` source (no fixture file),
2849 /// for cases that need a specific choice shape not already in `tests/`.
2850 fn story_from_source(src: &str) -> Story<'static> {
2851 let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
2852 let mut bytes = Vec::new();
2853 brink_format::write_inkb(&out.data, &mut bytes);
2854 let data = brink_format::read_inkb(&bytes).expect("decode");
2855 let (prog, tables) = link(&data).expect("link");
2856 // Leak: test-only, keeps `Story`'s `&'static Program` borrow simple.
2857 let prog: &'static Program = Box::leak(Box::new(prog));
2858 Story::new(prog, tables)
2859 }
2860
2861 /// `Choice.index` (the live, visible choice list) must be the *raw*
2862 /// `pending_choices` position, not the post-filter enumeration position —
2863 /// an invisible-default fallback choice (`* ->`) mixed in with visible
2864 /// choices occupies a `pending_choices` slot but never appears in the
2865 /// visible list, so the visible indices can skip values. This is exactly
2866 /// what `select_choice`/`choose` expects (it indexes `pending_choices`
2867 /// directly) — a caller must never re-derive the index from array
2868 /// position over the visible list alone.
2869 #[test]
2870 fn choice_index_is_raw_pending_choices_position_with_invisible_default_mixed_in() {
2871 let src = "-(start)\n\
2872 * [First] -> a\n\
2873 * -> b\n\
2874 * [Third] -> c\n\
2875 -(a) Went A.\n-> DONE\n\
2876 -(b) Went B.\n-> DONE\n\
2877 -(c) Went C.\n-> DONE\n";
2878 let mut story = story_from_source(src);
2879 let choices = step_until_choices(&mut story);
2880
2881 // The invisible-default fallback (raw index 1) is filtered out of the
2882 // visible list, so the visible choices' indices skip it: 0, then 2.
2883 assert_eq!(
2884 choices.iter().map(|c| c.index).collect::<Vec<_>>(),
2885 vec![0, 2],
2886 "visible choice indices must be the raw pending_choices positions, not 0,1,..: {choices:?}"
2887 );
2888 assert_eq!(story.default.flow.pending_choices.len(), 3);
2889
2890 // Choosing the raw index of the second visible entry must select
2891 // the "Third" branch, not the invisible-default fallback.
2892 story.choose(choices[1].index).expect("choose by raw index");
2893 let text = step_until_choices_or_end(&mut story);
2894 assert!(text.contains("Went C"), "expected the Third branch: {text}");
2895 }
2896
2897 /// `DebugSnapshot.pending_choices[].index` must agree with the live
2898 /// `Choice.index` — both derive from the same pre-filter pass over
2899 /// `pending_choices` (`resolved_choices_for`). A studio consumer restoring
2900 /// a Choice[] from a `DebugSnapshot` (rather than a live `Choice` list)
2901 /// depends on this to dispatch `choose()` correctly.
2902 #[test]
2903 fn debug_snapshot_choice_index_matches_live_choice_index() {
2904 let src = "-(start)\n\
2905 * [First] -> a\n\
2906 * -> b\n\
2907 * [Third] -> c\n\
2908 -(a) Went A.\n-> DONE\n\
2909 -(b) Went B.\n-> DONE\n\
2910 -(c) Went C.\n-> DONE\n";
2911 let mut story = story_from_source(src);
2912 let live_choices = step_until_choices(&mut story);
2913 let snap = story.debug_snapshot();
2914
2915 assert_eq!(snap.pending_choices.len(), live_choices.len());
2916 for (live, dbg) in live_choices.iter().zip(snap.pending_choices.iter()) {
2917 assert_eq!(
2918 dbg.index, live.index,
2919 "DebugChoice.index must match the live Choice.index"
2920 );
2921 }
2922 }
2923
2924 /// On the second pass through a choice set with once-only choices,
2925 /// a choice whose target has already been visited must NOT appear
2926 /// in `pending_choices`.
2927 #[test]
2928 fn once_only_choice_excluded_on_second_pass() {
2929 let (program, line_tables) = load_i079_program();
2930 let mut story = Story::new(&program, line_tables);
2931
2932 let first_choices = step_until_choices(&mut story);
2933 assert!(
2934 first_choices
2935 .iter()
2936 .any(|c| c.text.contains("First choice")),
2937 "first pass should contain 'First choice', got: {first_choices:?}"
2938 );
2939
2940 story.choose(0).unwrap();
2941
2942 let second_choices = step_until_choices(&mut story);
2943 assert!(
2944 !second_choices
2945 .iter()
2946 .any(|c| c.text.contains("First choice")),
2947 "second pass should NOT contain 'First choice' (once-only, already visited), \
2948 got: {second_choices:?}"
2949 );
2950 }
2951
2952 // ── Choice thread forking ──────────────────────────────────────────
2953
2954 fn load_i083_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
2955 let json_str = std::fs::read_to_string(
2956 "../../tests/tier1/choices/I083-choice-thread-forking/story.ink.json",
2957 )
2958 .unwrap();
2959 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
2960 let data = brink_converter::convert(&ink).unwrap();
2961 link(&data).unwrap()
2962 }
2963
2964 /// When a choice is created inside a tunnel, the call stack at that
2965 /// moment (including the tunnel frame with its temps) must be captured.
2966 /// After the tunnel returns and the choice is presented, the snapshot
2967 /// should still reflect the tunnel-era call stack depth (>= 2 frames).
2968 #[test]
2969 fn pending_choice_captures_tunnel_call_stack() {
2970 let (program, line_tables) = load_i083_program();
2971 let mut story = Story::new(&program, line_tables);
2972 let _choices = step_until_choices(&mut story);
2973
2974 // At this point the tunnel has returned, so the live call_stack
2975 // has only the root frame.
2976 let current_thread = story.default.flow.current_thread();
2977 assert_eq!(
2978 current_thread.call_stack.len(),
2979 1,
2980 "live call stack should be 1 frame (root) after tunnel return"
2981 );
2982
2983 // But the pending choice's fork should have captured the
2984 // call stack from inside the tunnel (root + tunnel = 2 frames).
2985 assert!(!story.default.flow.pending_choices.is_empty());
2986 let fork = &story.default.flow.pending_choices[0].thread_fork;
2987 assert!(
2988 fork.call_stack.len() >= 2,
2989 "choice fork should have >= 2 frames (root + tunnel), got {}",
2990 fork.call_stack.len()
2991 );
2992 }
2993
2994 /// After selecting a choice that was created inside a tunnel,
2995 /// `select_choice` must restore the tunnel's call frame so that
2996 /// temp variables from the tunnel scope are accessible.
2997 #[test]
2998 fn select_choice_restores_tunnel_frame_with_temps() {
2999 let (program, line_tables) = load_i083_program();
3000 let mut story = Story::new(&program, line_tables);
3001 let _choices = step_until_choices(&mut story);
3002
3003 // Before choosing: only root frame, no tunnel temps.
3004 assert_eq!(story.default.flow.current_thread().call_stack.len(), 1);
3005
3006 story.choose(0).unwrap();
3007
3008 // After choosing: the tunnel frame should be restored.
3009 // The call stack should have at least 2 frames (root + tunnel).
3010 let call_stack = &story.default.flow.current_thread().call_stack;
3011 assert!(
3012 call_stack.len() >= 2,
3013 "call stack should be restored to tunnel depth after choice selection, \
3014 got {} frame(s)",
3015 call_stack.len()
3016 );
3017
3018 // The tunnel frame (last frame) should have temp x = Int(1).
3019 let tunnel_frame = call_stack.last().unwrap();
3020 assert!(
3021 !tunnel_frame.temps.is_empty(),
3022 "tunnel frame should have temp variables"
3023 );
3024 assert_eq!(
3025 tunnel_frame.temps[0],
3026 Value::Int(1),
3027 "tunnel frame temps[0] should be Int(1) (the parameter x)"
3028 );
3029 }
3030
3031 // ── Tags ──────────────────────────────────────────────────────────
3032
3033 fn load_tags_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3034 let json_str =
3035 std::fs::read_to_string("../../tests/tier3/tags/tags/story.ink.json").unwrap();
3036 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3037 let data = brink_converter::convert(&ink).unwrap();
3038 link(&data).unwrap()
3039 }
3040
3041 fn load_tags_in_choice_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3042 let json_str =
3043 std::fs::read_to_string("../../tests/tier3/tags/tagsInChoice/story.ink.json").unwrap();
3044 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3045 let data = brink_converter::convert(&ink).unwrap();
3046 link(&data).unwrap()
3047 }
3048
3049 #[test]
3050 fn line_exposes_tags() {
3051 let (program, line_tables) = load_tags_program();
3052 let mut story = Story::<crate::FastRng>::new(&program, line_tables);
3053 let lines = story.continue_maximally().unwrap();
3054 // The first line should have both tags.
3055 let first = lines.first().expect("expected at least one line");
3056 assert!(
3057 !matches!(first, Line::Choices { .. }),
3058 "expected Text or End, got Choices"
3059 );
3060 assert_eq!(first.tags(), &["author: Joe", "title: My Great Story"],);
3061 }
3062
3063 #[test]
3064 fn choice_exposes_tags() {
3065 let (program, line_tables) = load_tags_in_choice_program();
3066 let mut story = Story::new(&program, line_tables);
3067 let choices = step_until_choices(&mut story);
3068 assert!(!choices.is_empty());
3069 // The choice in tagsInChoice has tags "one" and "two"
3070 assert!(
3071 !choices[0].tags.is_empty(),
3072 "choice should have tags, got: {choices:?}"
3073 );
3074 }
3075
3076 // ── Thread support ──────────────────────────────────────────────────
3077
3078 fn load_i091_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
3079 let json_str =
3080 std::fs::read_to_string("../../tests/tier1/choices/I091-choice-count/story.ink.json")
3081 .unwrap();
3082 let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
3083 let data = brink_converter::convert(&ink).unwrap();
3084 link(&data).unwrap()
3085 }
3086
3087 /// `<- choices` (thread) must create choices AND return to the main
3088 /// flow so that `CHOICE_COUNT()` can evaluate. The thread body
3089 /// should be called like a tunnel — when its container stack empties,
3090 /// execution returns to the caller. Non-root frames must always pop
3091 /// back to their caller, even when pending choices exist.
3092 #[test]
3093 fn thread_call_returns_to_main_flow() {
3094 let (program, line_tables) = load_i091_program();
3095 let mut story = Story::<crate::FastRng>::new(&program, line_tables);
3096
3097 let lines = story.continue_maximally().unwrap();
3098 // I091 should output "2\n" (CHOICE_COUNT) then present 2 choices.
3099 let full_text: String = lines.iter().map(Line::text).collect();
3100 assert!(
3101 full_text.starts_with('2'),
3102 "output should start with '2' from CHOICE_COUNT(), got: {full_text:?}"
3103 );
3104 let last = lines.last().expect("expected at least one line");
3105 match last {
3106 Line::Choices { choices, .. } => {
3107 assert_eq!(choices.len(), 2, "expected 2 choices");
3108 }
3109 other => panic!("expected Choices, got {other:?}"),
3110 }
3111 }
3112}