Skip to main content

brink_runtime/story/
mod.rs

1//! Per-instance mutable story state.
2
3use core::marker::PhantomData;
4use core::ops::Range;
5
6use alloc::borrow::ToOwned;
7use alloc::boxed::Box;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::sync::Arc;
11use alloc::vec::Vec;
12
13use brink_format::{DefinitionId, PluralResolver, Value};
14
15use crate::collections::Map as HashMap;
16use crate::error::RuntimeError;
17use crate::program::Program;
18use crate::rng::{FastRng, StoryRng};
19use crate::state::{ContextAccess, WriteObserver};
20#[cfg(feature = "testing")]
21use crate::vm;
22use crate::world::{ContextView, FlowLocal, World};
23
24mod call_stack;
25mod external;
26mod flow_instance;
27mod types;
28
29pub use call_stack::ExecMode;
30pub(crate) use call_stack::{
31    CallFrame, CallFrameType, ChoiceDisplay, ContainerPosition, Flow, PendingChoice,
32    PureCallbackState, classify_ran_out_of_content,
33};
34// Only test fixtures across the op-table modules construct a bare `Flow`
35// literal (production code reaches `pending_terminal` only through
36// `flow_instance.rs`, which imports `PendingTerminal` directly from
37// `call_stack`) — gate the re-export the same way so a plain `cargo check`
38// of the lib target (no `cfg(test)`) doesn't see it as unused.
39#[cfg(test)]
40pub(crate) use call_stack::PendingTerminal;
41pub use external::{ExternalFnHandler, ExternalResult, FallbackHandler, FunctionEval};
42pub use flow_instance::{DriveOutcome, FlowInstance};
43pub use types::{BlockId, Choice, Element, OutputLine, Stats, Step, StepOutcome, StoryStatus};
44
45// ── Story ───────────────────────────────────────────────────────────────────
46
47/// Per-instance mutable state for executing stories.
48///
49/// Created from a [`Program`] via [`Story::new`]. Holds all mutable state
50/// (stacks, globals, output buffer) while the immutable program data lives
51/// in [`Program`].
52///
53/// Generic over `R: StoryRng` — defaults to [`FastRng`]. Use
54/// [`DotNetRng`](crate::DotNetRng) for .NET-compatible deterministic output.
55pub struct Story<R: StoryRng = FastRng> {
56    program: Arc<Program>,
57    pub(crate) default: FlowInstance,
58    pub(crate) default_context: World,
59    /// The default flow's per-flow override layer. Empty in F1.3 (F3 fills
60    /// it in) — the routing view built from `(default_context, default_local)`
61    /// is an all-`World` passthrough, so this contributes nothing yet.
62    default_local: FlowLocal,
63    line_tables: Vec<Vec<brink_format::LineEntry>>,
64    instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
65    /// Named flows that **share** `default_context` (globals / visit counts /
66    /// rng) — true ink concurrent-flow semantics, where one flow's writes are
67    /// visible to the others. Each still has its own call stack + temps (those
68    /// live in the [`FlowInstance`]). Distinct from `instances`, whose flows
69    /// each own an isolated `World` (bevy-brink's per-entity model). Transient
70    /// studio/host state — not persisted in a [`StorySnapshot`].
71    shared_instances: HashMap<String, FlowInstance>,
72    resolver: Option<Box<dyn PluralResolver>>,
73    /// Whether host **semantic** access to `#@private` definitions is refused
74    /// (M-2b, `docs/modules-spec.md` §4 boundary rule 2). `true` by default —
75    /// production hosts respect visibility. Dev tooling (play-from-here) sets
76    /// it `false` via [`set_visibility_enforcement`](Self::set_visibility_enforcement)
77    /// to start flows at private knots. No effect on stories without any
78    /// `#@private` definition (the fast path short-circuits on that).
79    enforce_visibility: bool,
80    /// The dev/prod execution mode (NS-A4, [`ExecMode`]). A host/build
81    /// knob mirrored onto every owned [`FlowInstance`] — see
82    /// [`set_exec_mode`](Self::set_exec_mode). Not persisted in a
83    /// [`StorySnapshot`] (the mode is a property of the host/build, not of
84    /// story state).
85    exec_mode: ExecMode,
86    _rng: PhantomData<R>,
87}
88
89impl<R: StoryRng> Clone for Story<R> {
90    fn clone(&self) -> Self {
91        Self {
92            program: Arc::clone(&self.program),
93            default: self.default.clone(),
94            default_context: self.default_context.clone(),
95            default_local: self.default_local.clone(),
96            line_tables: self.line_tables.clone(),
97            instances: self.instances.clone(),
98            shared_instances: self.shared_instances.clone(),
99            resolver: None,
100            enforce_visibility: self.enforce_visibility,
101            exec_mode: self.exec_mode,
102            _rng: PhantomData,
103        }
104    }
105}
106
107/// Owned story state that can be detached from a `Program` and reattached later.
108///
109/// Created by [`Story::into_snapshot`], consumed by [`Story::from_snapshot`].
110/// This enables locale hot-swapping: detach state, mutate the program's line
111/// tables, then reattach.
112pub struct StorySnapshot<R: StoryRng = FastRng> {
113    default: FlowInstance,
114    default_context: World,
115    default_local: FlowLocal,
116    instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
117    _rng: PhantomData<R>,
118}
119
120impl<R: StoryRng> Story<R> {
121    /// Create a new story instance from a linked program and its line tables.
122    pub fn new(program: Arc<Program>, line_tables: Vec<Vec<brink_format::LineEntry>>) -> Self {
123        let (default, default_context) = FlowInstance::new_at_root(&program);
124        Self {
125            program,
126            default,
127            default_context,
128            default_local: FlowLocal::new(),
129            line_tables,
130            instances: HashMap::new(),
131            shared_instances: HashMap::new(),
132            resolver: None,
133            enforce_visibility: true,
134            exec_mode: ExecMode::default(),
135            _rng: PhantomData,
136        }
137    }
138
139    /// Enable or disable host visibility enforcement (M-2b,
140    /// `docs/modules-spec.md` §4 boundary rule 3). Enforcement is **on** by
141    /// default: host semantic access (variable get/set, entry lookup,
142    /// function eval) to a `#@private` definition returns
143    /// [`RuntimeError::PrivateAccess`] (or `None`/`false` for the infallible
144    /// get/set). Dev tooling — editors, debug hosts, the play-from-here
145    /// affordance — calls this with `false` to start flows at private knots
146    /// and inspect private state. This is a host capability, not a language
147    /// switch; the compiled program is identical either way. Persistence
148    /// (save/load/journal/replay) ignores this flag entirely.
149    ///
150    /// Propagates to every [`FlowInstance`] this `Story` currently owns
151    /// (`default`, every named flow, every shared flow) — each carries its
152    /// own copy of the flag (so `bevy-brink`/[`crate::Speculation`] can
153    /// enforce it when driving a `FlowInstance` directly, without a
154    /// `Story`), and `Story` keeps them synced so a `Story`-mediated dev
155    /// override never diverges from the flows it delegates to. Flows
156    /// spawned after this call ([`spawn_flow`](Self::spawn_flow)/
157    /// [`spawn_flow_shared`](Self::spawn_flow_shared)) inherit the
158    /// `Story`'s current setting at spawn time.
159    pub fn set_visibility_enforcement(&mut self, enforce: bool) {
160        self.enforce_visibility = enforce;
161        self.default.set_visibility_enforcement(enforce);
162        for (flow, _, _) in self.instances.values_mut() {
163            flow.set_visibility_enforcement(enforce);
164        }
165        for flow in self.shared_instances.values_mut() {
166            flow.set_visibility_enforcement(enforce);
167        }
168    }
169
170    /// Whether host visibility enforcement is currently on (default `true`).
171    #[must_use]
172    pub fn visibility_enforced(&self) -> bool {
173        self.enforce_visibility
174    }
175
176    /// Set the dev/prod execution mode (NS-A4, [`ExecMode`] — see its docs
177    /// for the §4b ordering doctrine). **Dev** (the default) faults on a
178    /// float NaN comparand in an ordering context; **Prod** keeps moving
179    /// with the pinned non-fabricating total order. The knob's home is
180    /// project config (`brink.toml` profile) with this host-API override
181    /// (ruled 2026-07-19); the mode is never embedded in `.inkb` and never
182    /// persisted in saves or snapshots.
183    ///
184    /// Propagates to every [`FlowInstance`] this `Story` currently owns
185    /// (`default`, named, shared) — the same sync discipline as
186    /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
187    /// Flows spawned after this call inherit the `Story`'s current setting
188    /// at spawn time.
189    pub fn set_exec_mode(&mut self, mode: ExecMode) {
190        self.exec_mode = mode;
191        self.default.set_exec_mode(mode);
192        for (flow, _, _) in self.instances.values_mut() {
193            flow.set_exec_mode(mode);
194        }
195        for flow in self.shared_instances.values_mut() {
196            flow.set_exec_mode(mode);
197        }
198    }
199
200    /// The current dev/prod execution mode (default [`ExecMode::Dev`]).
201    #[must_use]
202    pub fn exec_mode(&self) -> ExecMode {
203        self.exec_mode
204    }
205
206    /// Set the plural resolver for Select resolution in localized lines.
207    pub fn set_plural_resolver(&mut self, resolver: Box<dyn PluralResolver>) {
208        self.resolver = Some(resolver);
209    }
210
211    /// Replace the active line tables (e.g. for locale swapping).
212    pub fn set_line_tables(&mut self, tables: Vec<Vec<brink_format::LineEntry>>) {
213        self.line_tables = tables;
214    }
215
216    /// Read-only access to the current line tables.
217    pub fn line_tables(&self) -> &[Vec<brink_format::LineEntry>] {
218        &self.line_tables
219    }
220
221    /// The full append-only transcript of all output parts produced so far.
222    pub fn transcript(&self) -> &[crate::output::OutputPart] {
223        self.default.flow.output.transcript()
224    }
225
226    /// Number of parts in the transcript.
227    pub fn transcript_len(&self) -> usize {
228        self.default.flow.output.transcript_len()
229    }
230
231    /// Reset the transcript read cursor to the beginning (for re-rendering).
232    pub fn reset_cursor(&mut self) {
233        self.default.flow.output.reset_cursor();
234    }
235
236    /// Resolve a slice of the transcript against the current line tables.
237    /// Returns `(text, tags)` tuples — one per line in the resolved output.
238    pub fn resolve_transcript_slice(&self, range: Range<usize>) -> Vec<(String, Vec<String>)> {
239        let transcript = self.default.flow.output.transcript();
240        let end = range.end.min(transcript.len());
241        let start = range.start.min(end);
242        let slice = &transcript[start..end];
243        let fragments = self.default.flow.output.fragments();
244        // Element-attachment data (issue #2108) is dropped here — this
245        // method's public contract is `(text, tags)`, unchanged; a caller
246        // that needs per-line element data has no use for a locale-
247        // re-rendering slice taken in isolation from the surrounding
248        // `Step::Line` stream anyway.
249        crate::output::resolve_lines(
250            slice,
251            &self.program,
252            &self.line_tables,
253            self.resolver.as_deref(),
254            fragments,
255        )
256        .into_iter()
257        .map(|(text, tags, _element)| (text, tags))
258        .collect()
259    }
260
261    /// Re-resolve all pending choices against the current line tables.
262    /// Returns the same choices that would appear in `Step::Choices`,
263    /// but freshly resolved (useful after locale switch).
264    pub fn pending_choices(&self) -> Vec<Choice> {
265        self.resolved_choices_for(&self.default.flow)
266    }
267
268    /// Resolve a given flow's pending choices against the current line tables.
269    /// Shared by [`pending_choices`](Self::pending_choices) (default flow) and
270    /// the per-flow debug snapshot (#200 shared flows).
271    fn resolved_choices_for(&self, flow: &Flow) -> Vec<Choice> {
272        flow.pending_choices
273            .iter()
274            .enumerate()
275            .filter(|(_, pc)| !pc.flags.is_invisible_default)
276            .map(|(i, pc)| {
277                let display_text = match &pc.display {
278                    ChoiceDisplay::Text(s) => s.clone(),
279                    ChoiceDisplay::Fragment(idx) => flow.output.resolve_fragment(
280                        *idx,
281                        &self.program,
282                        &self.line_tables,
283                        self.resolver.as_deref(),
284                    ),
285                };
286                let display_text = display_text
287                    .trim_matches(|c: char| c == ' ' || c == '\t')
288                    .to_string();
289                Choice {
290                    text: display_text,
291                    index: i,
292                    tags: pc.tags.clone(),
293                }
294            })
295            .collect()
296    }
297
298    /// Resolve a fragment against the current line tables.
299    pub fn resolve_fragment(&self, idx: u32) -> String {
300        self.default.flow.output.resolve_fragment(
301            idx,
302            &self.program,
303            &self.line_tables,
304            self.resolver.as_deref(),
305        )
306    }
307
308    /// Get the fragment index for a pending choice's display text, if any.
309    pub fn choice_fragment_idx(&self, choice_index: usize) -> Option<u32> {
310        self.default
311            .flow
312            .pending_choices
313            .get(choice_index)
314            .and_then(|pc| match &pc.display {
315                ChoiceDisplay::Fragment(idx) => Some(*idx),
316                ChoiceDisplay::Text(_) => None,
317            })
318    }
319
320    /// Read-only access to the fragment store (for transcript serialization).
321    pub fn fragments(&self) -> &[crate::output::Fragment] {
322        self.default.flow.output.fragments()
323    }
324
325    /// Read-only access to the program.
326    pub fn program(&self) -> &Program {
327        &self.program
328    }
329
330    /// Cheap `Arc` clone of the program, for callers (e.g. [`crate::save`])
331    /// that need a `&Program` alongside a disjoint mutable borrow of another
332    /// field — `self.program()` ties its `&Program` to all of `&self`, which
333    /// conflicts with a simultaneous `&mut self.default_context`.
334    pub(crate) fn program_arc(&self) -> Arc<Program> {
335        Arc::clone(&self.program)
336    }
337
338    // ── Variable access (host-facing) ───────────────────────────────
339
340    /// Read a global variable's current value by name. `None` if no global
341    /// with that name is declared. Reads the default flow's context.
342    ///
343    /// Returns `None` for a `#@private` variable while visibility enforcement
344    /// is on (M-2b) — the host is outside every module, so a private name is
345    /// not host-visible. Dev tooling opts out via
346    /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
347    pub fn variable(&self, name: &str) -> Option<&Value> {
348        let idx = self.program.global_index(name)?;
349        if self.enforce_visibility
350            && self.program.has_private_defs()
351            && self.program.global_is_private(idx)
352        {
353            return None;
354        }
355        Some(ContextAccess::global(&self.default_context, idx))
356    }
357
358    /// Set a global variable by name, returning `false` (no-op) if no global
359    /// with that name is declared. Ink globals are dynamically typed, so the
360    /// host is responsible for passing a sensibly-typed value.
361    ///
362    /// Returns `false` (no write) for a `#@private` variable while visibility
363    /// enforcement is on (M-2b). Dev tooling opts out via
364    /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
365    pub fn set_variable(&mut self, name: &str, value: Value) -> bool {
366        match self.program.global_index(name) {
367            Some(idx) => {
368                if self.enforce_visibility
369                    && self.program.has_private_defs()
370                    && self.program.global_is_private(idx)
371                {
372                    return false;
373                }
374                ContextAccess::set_global(&mut self.default_context, idx, value);
375                true
376            }
377            None => false,
378        }
379    }
380
381    /// Set the RNG seed for the default flow's context. Seeding makes
382    /// `RANDOM`/shuffle output reproducible — set it before running (or after
383    /// a reset) so two runs of the same story on different machines match.
384    pub fn set_rng_seed(&mut self, seed: i32) {
385        ContextAccess::set_rng_seed(&mut self.default_context, seed);
386    }
387
388    // ── Pausable stepping (async externals) ─────────────────────────
389
390    /// Advance the default flow by one step with a custom handler, surfacing a
391    /// deferred external as [`StepOutcome::AwaitingExternal`] rather than
392    /// erroring (unlike [`continue_single_with`](Self::continue_single_with)).
393    ///
394    /// On `AwaitingExternal`, resolve the pending call
395    /// ([`resolve_external`](Self::resolve_external), or
396    /// [`invoke_fallback`](Self::invoke_fallback)) and call `advance_with` again
397    /// to resume. Inspect the pending call via
398    /// [`pending_external_name`](Self::pending_external_name) /
399    /// [`pending_external_args`](Self::pending_external_args).
400    pub fn advance_with(
401        &mut self,
402        handler: &dyn ExternalFnHandler,
403    ) -> Result<StepOutcome, RuntimeError> {
404        let resolver = self.resolver.as_deref();
405        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
406        self.default.advance::<R>(
407            &self.program,
408            &self.line_tables,
409            &mut view,
410            handler,
411            resolver,
412        )
413    }
414
415    /// Name of the external the default flow is paused on, if any.
416    #[must_use]
417    pub fn pending_external_name(&self) -> Option<&str> {
418        self.default.pending_external_name(&self.program)
419    }
420
421    /// Arguments of the external the default flow is paused on.
422    #[must_use]
423    pub fn pending_external_args(&self) -> &[Value] {
424        self.default.pending_external_args()
425    }
426
427    /// Evaluate an ink function by name from engine code, returning its value.
428    ///
429    /// Runs out-of-band on the default flow: output is isolated (the visible
430    /// story is untouched), and the call completes synchronously. Externals the
431    /// function calls are resolved inline by `handler`; an external the handler
432    /// defers ([`ExternalResult::Pending`]) can't be resolved in a synchronous
433    /// call and yields [`RuntimeError::AsyncExternalInCall`] (the paused eval is
434    /// cleaned up first).
435    ///
436    /// # Errors
437    /// [`RuntimeError::FunctionNotFound`] for an unknown name;
438    /// [`RuntimeError::AsyncExternalInCall`] if a called external defers; plus
439    /// any runtime error raised during evaluation.
440    pub fn call_function(
441        &mut self,
442        name: &str,
443        args: &[Value],
444        handler: &dyn ExternalFnHandler,
445    ) -> Result<Value, RuntimeError> {
446        // M-2b: refuse host-driven evaluation of a `#@private` function while
447        // enforcement is on. Checked before resolution details so a private
448        // name reports as private, not as "not found".
449        if self.enforce_visibility
450            && self.program.has_private_defs()
451            && self.program.path_is_private(name)
452        {
453            return Err(RuntimeError::PrivateAccess {
454                name: name.to_owned(),
455            });
456        }
457        let container_idx = self
458            .program
459            .find_address(name)
460            .ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
461            .0;
462        // Arity-check against the function's declared parameters (compiler-built
463        // programs only; converter-built ones record 0 and so accept no args).
464        let expected = self.program.container(container_idx).param_count;
465        if args.len() != expected as usize {
466            return Err(RuntimeError::ArgCountMismatch {
467                target: name.to_owned(),
468                expected,
469                got: args.len(),
470            });
471        }
472        let resolver = self.resolver.as_deref();
473        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
474        let outcome = self.default.begin_function_eval::<R>(
475            &self.program,
476            &self.line_tables,
477            &mut view,
478            handler,
479            container_idx,
480            args,
481            resolver,
482        )?;
483        match outcome {
484            FunctionEval::Returned(value) => Ok(value),
485            FunctionEval::AwaitingExternal => {
486                let name = self
487                    .default
488                    .pending_external_name(&self.program)
489                    .map_or_else(|| name.to_owned(), ToOwned::to_owned);
490                self.default
491                    .abort_eval(&self.program, &self.line_tables, resolver);
492                Err(RuntimeError::AsyncExternalInCall(name))
493            }
494        }
495    }
496
497    /// Fork a [`Speculation`](crate::Speculation) — a sandboxed,
498    /// side-effect-proof speculative run — from the default flow's
499    /// current state.
500    ///
501    /// The speculation owns an independent snapshot: driving it (via its
502    /// own `advance`/`choose`/`go_to_path`/`eval_function` verbs) never
503    /// mutates this `Story`. Dropping it discards everything it did. See
504    /// [`crate::Speculation`] for the full picture, and
505    /// [`crate::Speculation::fork_from`] for forking a non-default flow
506    /// (e.g. a named flow spawned via [`spawn_flow`](Self::spawn_flow)).
507    #[must_use]
508    pub fn speculate(&self) -> crate::Speculation<R> {
509        crate::Speculation::fork_from(
510            Arc::clone(&self.program),
511            &self.default_context,
512            &self.default_local,
513            &self.default,
514            &self.line_tables,
515        )
516    }
517
518    /// Detach story state from the program, consuming the story.
519    pub fn into_snapshot(self) -> (StorySnapshot<R>, Vec<Vec<brink_format::LineEntry>>) {
520        let snapshot = StorySnapshot {
521            default: self.default,
522            default_context: self.default_context,
523            default_local: self.default_local,
524            instances: self.instances,
525            _rng: PhantomData,
526        };
527        (snapshot, self.line_tables)
528    }
529
530    /// Reattach a snapshot to a program with line tables.
531    pub fn from_snapshot(
532        program: Arc<Program>,
533        snapshot: StorySnapshot<R>,
534        line_tables: Vec<Vec<brink_format::LineEntry>>,
535    ) -> Self {
536        let mut story = Self {
537            program,
538            default: snapshot.default,
539            default_context: snapshot.default_context,
540            default_local: snapshot.default_local,
541            line_tables,
542            instances: snapshot.instances,
543            // Shared flows are transient (not persisted) — a reattached story
544            // starts with none.
545            shared_instances: HashMap::new(),
546            resolver: None,
547            // Enforcement is a host capability, not persisted state — a
548            // reattached story defaults to enforcing; the host re-applies a
549            // dev override if it wants one.
550            enforce_visibility: true,
551            // Same posture for the dev/prod mode (NS-A4): a host/build
552            // knob, not persisted state — a reattached story defaults to
553            // Dev; the host re-applies its own setting.
554            exec_mode: ExecMode::default(),
555            _rng: PhantomData,
556        };
557        // `snapshot.default`/`snapshot.instances` carry whatever
558        // `FlowInstance`-level enforcement flag they had at detach time
559        // (e.g. `false`, if `into_snapshot` ran while a play-from-here
560        // session had enforcement off) — force every flow back to the
561        // reattached story's own (enforcing) setting so the two can't
562        // diverge.
563        story.set_visibility_enforcement(true);
564        // Same re-sync for the exec mode (the flows in the snapshot carry
565        // whatever mode they had at detach time).
566        story.set_exec_mode(ExecMode::default());
567        story
568    }
569
570    // ── Execution API ──────────────────────────────────────────────
571
572    /// Execute until one line of content (up to newline), or until a
573    /// yield point (choices/end) if no newline occurs first.
574    ///
575    /// The returned [`Step`] variant tells you what to do next:
576    /// - [`Step::Line`] — more output may follow, keep calling.
577    /// - [`Step::Choices`] — call [`choose`](Self::choose) then resume.
578    /// - [`Step::End`] — the story has permanently ended.
579    pub fn continue_single(&mut self) -> Result<Step, RuntimeError> {
580        let resolver = self.resolver.as_deref();
581        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
582        self.default.step_single_line::<R>(
583            &self.program,
584            &self.line_tables,
585            &mut view,
586            &FallbackHandler,
587            resolver,
588        )
589    }
590
591    /// Like [`continue_single`](Self::continue_single) but with a
592    /// [`WriteObserver`] that receives notifications for every state mutation.
593    pub fn continue_single_observed(
594        &mut self,
595        observer: &mut dyn WriteObserver,
596    ) -> Result<Step, RuntimeError> {
597        use crate::state::ObservedContext;
598        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
599        let mut obs_ctx = ObservedContext::new(&mut view, observer);
600        let resolver = self.resolver.as_deref();
601        self.default.step_single_line::<R>(
602            &self.program,
603            &self.line_tables,
604            &mut obs_ctx,
605            &FallbackHandler,
606            resolver,
607        )
608    }
609
610    /// Like [`continue_single`](Self::continue_single) but with a custom
611    /// external function handler.
612    pub fn continue_single_with(
613        &mut self,
614        handler: &dyn ExternalFnHandler,
615    ) -> Result<Step, RuntimeError> {
616        let resolver = self.resolver.as_deref();
617        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
618        self.default.step_single_line::<R>(
619            &self.program,
620            &self.line_tables,
621            &mut view,
622            handler,
623            resolver,
624        )
625    }
626
627    /// Execute until the next yield point, collecting all lines.
628    ///
629    /// Returns a `Vec<Step>` where the last element is always
630    /// [`Step::Choices`] or [`Step::End`], and all preceding elements
631    /// are [`Step::Line`].
632    pub fn continue_maximally(&mut self) -> Result<Vec<Step>, RuntimeError> {
633        self.continue_maximally_impl(&FallbackHandler)
634    }
635
636    /// Like [`continue_maximally`](Self::continue_maximally) but with a
637    /// custom external function handler.
638    pub fn continue_maximally_with(
639        &mut self,
640        handler: &dyn ExternalFnHandler,
641    ) -> Result<Vec<Step>, RuntimeError> {
642        self.continue_maximally_impl(handler)
643    }
644
645    fn continue_maximally_impl(
646        &mut self,
647        handler: &dyn ExternalFnHandler,
648    ) -> Result<Vec<Step>, RuntimeError> {
649        let resolver = self.resolver.as_deref();
650        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
651        self.default.drive_to_terminal::<R>(
652            &self.program,
653            &self.line_tables,
654            &mut view,
655            handler,
656            resolver,
657        )
658    }
659
660    /// Execute until the next yield point with a [`WriteObserver`] that
661    /// receives notifications for every state mutation.
662    pub fn continue_maximally_observed(
663        &mut self,
664        observer: &mut dyn WriteObserver,
665    ) -> Result<Vec<Step>, RuntimeError> {
666        use crate::state::ObservedContext;
667        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
668        let mut obs_ctx = ObservedContext::new(&mut view, observer);
669        let resolver = self.resolver.as_deref();
670        self.default.drive_to_terminal::<R>(
671            &self.program,
672            &self.line_tables,
673            &mut obs_ctx,
674            &FallbackHandler,
675            resolver,
676        )
677    }
678
679    /// Select a choice by index, then resume with
680    /// [`continue_single`](Self::continue_single) or
681    /// [`continue_maximally`](Self::continue_maximally).
682    pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
683        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
684        self.default.choose(&mut view, index)
685    }
686
687    /// Move the default flow's play head to a named knot/stitch path — ink's
688    /// `ChoosePathString` equivalent. The current flow is force-completed
689    /// (callstack reset, pending choices cleared), the jump counts as a visit
690    /// to the target exactly like a `-> path` divert, and subsequent
691    /// [`continue_single`](Self::continue_single) /
692    /// [`continue_maximally`](Self::continue_maximally) calls run from there.
693    /// See [`FlowInstance::choose_path_string`] for full semantics.
694    ///
695    /// # Errors
696    /// [`UnknownPath`](RuntimeError::UnknownPath) for an unknown path;
697    /// [`JumpWhileAwaitingExternal`](RuntimeError::JumpWhileAwaitingExternal)
698    /// if the flow is parked on an unresolved external call;
699    /// [`AlreadyEvaluatingFunction`](RuntimeError::AlreadyEvaluatingFunction)
700    /// if an engine→ink function evaluation is in progress.
701    pub fn choose_path_string(&mut self, path: &str) -> Result<(), RuntimeError> {
702        self.check_entry_visibility(path)?;
703        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
704        self.default
705            .choose_path_string(&self.program, &mut view, path)
706    }
707
708    /// M-2b: refuse a host-driven entry into a `#@private` knot/stitch while
709    /// visibility enforcement is on. Shared by both `choose_path_string`
710    /// entry points. Dev tooling (play-from-here) disables enforcement via
711    /// [`set_visibility_enforcement`](Self::set_visibility_enforcement).
712    fn check_entry_visibility(&self, path: &str) -> Result<(), RuntimeError> {
713        if self.enforce_visibility
714            && self.program.has_private_defs()
715            && self.program.path_is_private(path)
716        {
717            return Err(RuntimeError::PrivateAccess {
718                name: path.to_owned(),
719            });
720        }
721        Ok(())
722    }
723
724    /// Move the default flow's play head to a parameterized knot/stitch,
725    /// **binding its declared parameters** from `args` — ink's
726    /// `ChoosePathString` with arguments. Otherwise identical to
727    /// [`choose_path_string`](Self::choose_path_string). See
728    /// [`FlowInstance::choose_path_string_with_args`] for full semantics.
729    ///
730    /// # Errors
731    /// As [`choose_path_string`](Self::choose_path_string), plus
732    /// [`ArgCountMismatch`](RuntimeError::ArgCountMismatch) when `args.len()`
733    /// doesn't match the target's declared parameter count.
734    pub fn choose_path_string_with_args(
735        &mut self,
736        path: &str,
737        args: &[Value],
738    ) -> Result<(), RuntimeError> {
739        self.check_entry_visibility(path)?;
740        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
741        self.default
742            .choose_path_string_with_args(&self.program, &mut view, path, args)
743    }
744
745    /// Read-only access to the default flow's VM statistics.
746    pub fn stats(&self) -> &Stats {
747        &self.default.stats
748    }
749
750    /// Returns `true` if the default flow has a pending external call
751    /// (an `External` frame on top of the call stack).
752    pub fn has_pending_external(&self) -> bool {
753        self.default.flow.external_fn_id().is_some()
754    }
755
756    /// Resolve a pending external call on the default flow by providing
757    /// the return value. For fire-and-forget calls, pass `Value::Null`.
758    ///
759    /// After resolving, call [`continue_maximally`](Story::continue_maximally)
760    /// to continue execution.
761    pub fn resolve_external(&mut self, value: Value) {
762        self.default.flow.resolve_external(value);
763    }
764
765    /// Resolve a pending external call on the default flow by invoking
766    /// the ink-defined fallback body. The fallback is a function call
767    /// whose output becomes the return value.
768    ///
769    /// After invoking, call [`continue_maximally`](Story::continue_maximally)
770    /// to continue execution.
771    pub fn invoke_fallback(&mut self) -> Result<(), RuntimeError> {
772        let fn_id = self
773            .default
774            .flow
775            .external_fn_id()
776            .ok_or(RuntimeError::CallStackUnderflow)?;
777        let entry = self.program.external_fn(fn_id);
778        let fallback_id = entry
779            .and_then(|e| e.fallback)
780            .ok_or(RuntimeError::UnresolvedExternalCall(fn_id))?;
781        let container_idx = self
782            .program
783            .resolve_target(fallback_id)
784            .map(|(idx, _)| idx)
785            .ok_or(RuntimeError::UnresolvedDefinition(fallback_id))?;
786        self.default.flow.output.begin_capture();
787        self.default.flow.invoke_fallback(container_idx);
788        Ok(())
789    }
790
791    // ── Named flow API ──────────────────────────────────────────────
792
793    /// Spawn a new flow instance starting at the given entry point.
794    ///
795    /// `entry_point` is the `DefinitionId` of the target container
796    /// (e.g., a knot). Each flow instance gets its own globals, visit
797    /// counts, and execution state.
798    pub fn spawn_flow(
799        &mut self,
800        name: &str,
801        entry_point: DefinitionId,
802    ) -> Result<(), RuntimeError> {
803        // M-2b: refuse host-driven entry into a `#@private` knot/stitch while
804        // visibility enforcement is on (`docs/modules-spec.md` §4 boundary
805        // rule 2). Mirrors `check_entry_visibility`'s refusal on the named
806        // `choose_path_string` path — a host holding a `DefinitionId` (this
807        // by-id entry point) must not be able to bypass it. Checked before
808        // any other error path so a private target reports as private, not
809        // as "already exists" or "unresolved" (#803).
810        if self.enforce_visibility
811            && self.program.has_private_defs()
812            && self.program.is_private(entry_point)
813        {
814            return Err(RuntimeError::PrivateAccess {
815                name: format!("{entry_point}"),
816            });
817        }
818        if self.instances.contains_key(name) {
819            return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
820        }
821        let container_idx = self
822            .program
823            .resolve_target(entry_point)
824            .map(|(idx, _)| idx)
825            .ok_or(RuntimeError::UnresolvedDefinition(entry_point))?;
826        let (mut flow, ctx) = FlowInstance::new_at(&self.program, container_idx);
827        // Inherit this `Story`'s current enforcement setting (a dev override
828        // set before spawning must apply to newly spawned flows too, not
829        // just the flows that existed at override time).
830        flow.set_visibility_enforcement(self.enforce_visibility);
831        flow.set_exec_mode(self.exec_mode);
832        self.instances
833            .insert(name.to_owned(), (flow, ctx, FlowLocal::new()));
834        Ok(())
835    }
836
837    /// Run a named flow instance until the next yield point.
838    pub fn continue_flow_maximally(&mut self, name: &str) -> Result<Vec<Step>, RuntimeError> {
839        self.continue_flow_maximally_with(name, &FallbackHandler)
840    }
841
842    /// Run a named flow instance with an external function handler.
843    pub fn continue_flow_maximally_with(
844        &mut self,
845        name: &str,
846        handler: &dyn ExternalFnHandler,
847    ) -> Result<Vec<Step>, RuntimeError> {
848        let (instance, ctx, local) = self
849            .instances
850            .get_mut(name)
851            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
852        let mut view = ContextView::new(ctx, local);
853        let resolver = self.resolver.as_deref();
854        instance.drive_to_terminal::<R>(
855            &self.program,
856            &self.line_tables,
857            &mut view,
858            handler,
859            resolver,
860        )
861    }
862
863    /// Select a choice in a named flow.
864    pub fn choose_flow(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
865        let (instance, ctx, local) = self
866            .instances
867            .get_mut(name)
868            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
869        let mut view = ContextView::new(ctx, local);
870        instance.choose(&mut view, index)
871    }
872
873    /// Destroy a named flow instance — isolated or shared (#200).
874    pub fn destroy_flow(&mut self, name: &str) -> Result<(), RuntimeError> {
875        if self.shared_instances.remove(name).is_some() || self.instances.remove(name).is_some() {
876            Ok(())
877        } else {
878            Err(RuntimeError::UnknownFlow(name.to_owned()))
879        }
880    }
881
882    /// List active flow names (isolated + shared), sorted for determinism.
883    pub fn flow_names(&self) -> Vec<&str> {
884        let mut names: Vec<&str> = self
885            .instances
886            .keys()
887            .chain(self.shared_instances.keys())
888            .map(String::as_str)
889            .collect();
890        names.sort_unstable();
891        names
892    }
893
894    /// Re-evaluate the wake conditions of parked flows and return the ids
895    /// of the flows that woke, sorted for determinism
896    /// (`docs/flow-suspension-spec.md` §10.2). Waking never auto-continues:
897    /// the host drives a woken flow via [`Story::continue_flow_single`] when
898    /// it wants output.
899    ///
900    /// **Returns an empty list until parks exist (FS-3r).** No flow can be
901    /// parked in today's runtime — the E052 lowering fence keeps `await`
902    /// from producing bytecode ([`Step::Suspended`] is unreachable), so
903    /// there are no conditions to re-evaluate. The method ships now (FS-3w)
904    /// so hosts wire the wake loop against a stable shape; FS-3r fills in
905    /// real condition evaluation + dirty-tracking without changing this
906    /// signature. Dirty-tracking is not built here — this is the free stub.
907    #[must_use]
908    pub fn wake_check(&mut self) -> Vec<String> {
909        // FS-3r: iterate parked flows, re-evaluate each dirty condition in
910        // the owning flow's context via the isolated function-eval
911        // machinery, collect woken ids. No flow can be parked yet, so the
912        // woken set is always empty.
913        Vec::new()
914    }
915
916    // ── Shared flows (#200) ─────────────────────────────────────────
917    // Spawn a flow that **shares** `default_context` (globals / visit counts /
918    // rng) with the default flow — true ink concurrent-flow semantics — while
919    // keeping its own call stack + temps. Distinct from `spawn_flow`, whose
920    // flows each own an isolated context (bevy-brink's per-entity model).
921
922    /// Spawn a shared-context flow at `container_idx` (or the root if `None`).
923    pub fn spawn_flow_shared(
924        &mut self,
925        name: &str,
926        container_idx: Option<u32>,
927    ) -> Result<(), RuntimeError> {
928        // M-2b: same by-id refusal as `spawn_flow` (#803) — a resolved
929        // `container_idx` (e.g. from `Program::find_address`, as the wasm
930        // `spawn_flow` binding in `brink-web` does) must not bypass the
931        // named-lookup refusal either. `None` targets the root, which is
932        // never private.
933        if let Some(idx) = container_idx
934            && self.enforce_visibility
935            && self.program.has_private_defs()
936            && self.program.container_is_private(idx)
937        {
938            return Err(RuntimeError::PrivateAccess {
939                name: format!("{}", self.program.container(idx).id),
940            });
941        }
942        if self.shared_instances.contains_key(name) || self.instances.contains_key(name) {
943            return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
944        }
945        // The fresh context the constructor returns is discarded — a shared
946        // flow runs against `default_context`.
947        let (mut flow, _ctx) = match container_idx {
948            Some(idx) => FlowInstance::new_at(&self.program, idx),
949            None => FlowInstance::new_at_root(&self.program),
950        };
951        // Inherit this `Story`'s current enforcement setting — see
952        // `spawn_flow`'s identical note.
953        flow.set_visibility_enforcement(self.enforce_visibility);
954        flow.set_exec_mode(self.exec_mode);
955        self.shared_instances.insert(name.to_owned(), flow);
956        Ok(())
957    }
958
959    /// Advance a shared flow one line (against the shared context).
960    pub fn continue_flow_single(&mut self, name: &str) -> Result<Step, RuntimeError> {
961        self.continue_flow_single_with(name, &FallbackHandler)
962    }
963
964    /// Advance a shared flow one line with an external-function handler.
965    pub fn continue_flow_single_with(
966        &mut self,
967        name: &str,
968        handler: &dyn ExternalFnHandler,
969    ) -> Result<Step, RuntimeError> {
970        let resolver = self.resolver.as_deref();
971        let instance = self
972            .shared_instances
973            .get_mut(name)
974            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
975        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
976        instance.step_single_line::<R>(
977            &self.program,
978            &self.line_tables,
979            &mut view,
980            handler,
981            resolver,
982        )
983    }
984
985    /// Run a shared flow to its next terminal line (against the shared
986    /// context) — the shared-flow analogue of [`Self::continue_flow_maximally`]
987    /// (which drives an *isolated* flow instead). Bounded by
988    /// [`FlowInstance::LINE_LIMIT`] via
989    /// [`drive_to_terminal`](FlowInstance::drive_to_terminal): an
990    /// infinite-emitting flow errors with [`RuntimeError::LineLimitExceeded`]
991    /// rather than growing the returned `Vec` without bound (guard against
992    /// unbounded growth).
993    pub fn continue_flow_maximally_shared(
994        &mut self,
995        name: &str,
996    ) -> Result<Vec<Step>, RuntimeError> {
997        self.continue_flow_maximally_shared_with(name, &FallbackHandler)
998    }
999
1000    /// Run a shared flow to its next terminal line with an external-function
1001    /// handler. See [`Self::continue_flow_maximally_shared`].
1002    pub fn continue_flow_maximally_shared_with(
1003        &mut self,
1004        name: &str,
1005        handler: &dyn ExternalFnHandler,
1006    ) -> Result<Vec<Step>, RuntimeError> {
1007        let resolver = self.resolver.as_deref();
1008        let instance = self
1009            .shared_instances
1010            .get_mut(name)
1011            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1012        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
1013        instance.drive_to_terminal::<R>(
1014            &self.program,
1015            &self.line_tables,
1016            &mut view,
1017            handler,
1018            resolver,
1019        )
1020    }
1021
1022    /// Select a choice in a shared flow (against the shared context).
1023    pub fn choose_flow_shared(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
1024        let instance = self
1025            .shared_instances
1026            .get_mut(name)
1027            .ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
1028        let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
1029        instance.choose(&mut view, index)
1030    }
1031
1032    /// A structured, name-resolved snapshot of the current runtime state for
1033    /// the studio State View: status, current location, globals, call stack,
1034    /// visit counts, pending choices, and rng. Read-only; built on demand and
1035    /// not on any hot path. See [`DebugSnapshot`](crate::DebugSnapshot).
1036    #[must_use]
1037    pub fn debug_snapshot(&self) -> crate::DebugSnapshot {
1038        self.build_debug_snapshot(&self.default, &self.default_context)
1039    }
1040
1041    /// A debug snapshot of a named shared flow (#200), built against the shared
1042    /// `default_context` — so its globals / visit counts match the default
1043    /// flow's, while its call stack + temps are the flow's own. Falls back to a
1044    /// named isolated flow's own context if `name` is one of those instead.
1045    pub fn debug_snapshot_flow(&self, name: &str) -> Result<crate::DebugSnapshot, RuntimeError> {
1046        if let Some(instance) = self.shared_instances.get(name) {
1047            Ok(self.build_debug_snapshot(instance, &self.default_context))
1048        } else if let Some((instance, ctx, _local)) = self.instances.get(name) {
1049            Ok(self.build_debug_snapshot(instance, ctx))
1050        } else {
1051            Err(RuntimeError::UnknownFlow(name.to_owned()))
1052        }
1053    }
1054
1055    /// Build a debug snapshot from a specific flow instance + context. Backs
1056    /// both [`debug_snapshot`](Self::debug_snapshot) and the per-flow variant.
1057    fn build_debug_snapshot(&self, instance: &FlowInstance, ctx: &World) -> crate::DebugSnapshot {
1058        use crate::debug::{
1059            DebugChoice, DebugFrame, DebugGlobal, DebugRng, DebugSnapshot, DebugVisit, NameResolver,
1060        };
1061
1062        let flow = &instance.flow;
1063        let resolver = NameResolver::new(&self.program);
1064
1065        let status = match instance.status {
1066            StoryStatus::Active => "active",
1067            StoryStatus::WaitingForChoice => "waiting_for_choice",
1068            StoryStatus::Done => "done",
1069            StoryStatus::Ended => "ended",
1070        };
1071
1072        let thread = flow.current_thread();
1073
1074        // Nearest named container the cursor is currently in (innermost-first).
1075        let resolve_frame_location = |frame: &CallFrame| {
1076            frame
1077                .container_stack
1078                .iter()
1079                .rev()
1080                .find_map(|cp| resolver.container_path(cp.container_idx))
1081                .map(str::to_owned)
1082        };
1083
1084        let current_location = thread.call_stack.last().and_then(resolve_frame_location);
1085
1086        // Globals, skipping unnamed slots.
1087        let globals = ctx
1088            .globals
1089            .iter()
1090            .enumerate()
1091            .filter_map(|(i, value)| {
1092                self.program.global_slot_name(i).map(|name| DebugGlobal {
1093                    name: name.to_owned(),
1094                    value: resolver.format_value(value),
1095                })
1096            })
1097            .collect();
1098
1099        // Call stack, innermost (current) frame first.
1100        let depth = thread.call_stack.len();
1101        let mut call_stack = Vec::with_capacity(depth);
1102        for i in (0..depth).rev() {
1103            if let Some(frame) = thread.call_stack.get(i) {
1104                let kind = match frame.frame_type {
1105                    CallFrameType::Root => "root",
1106                    CallFrameType::Function => "function",
1107                    CallFrameType::Tunnel => "tunnel",
1108                    CallFrameType::Thread => "thread",
1109                    CallFrameType::External => "external",
1110                    CallFrameType::FunctionEvalFromGame => "eval",
1111                };
1112                call_stack.push(DebugFrame {
1113                    kind,
1114                    location: resolve_frame_location(frame),
1115                    temps: frame.temps.len(),
1116                });
1117            }
1118        }
1119
1120        // Visit counts, resolved and sorted by path for determinism.
1121        let mut visit_counts: Vec<DebugVisit> = ctx
1122            .visit_counts
1123            .iter()
1124            .filter_map(|(id, &count)| {
1125                resolver.def_path(*id).map(|path| DebugVisit {
1126                    path: path.to_owned(),
1127                    count,
1128                })
1129            })
1130            .collect();
1131        visit_counts.sort_by(|a, b| a.path.cmp(&b.path));
1132
1133        // Pending choices: visible texts (resolved) paired with target paths.
1134        let visible_targets: Vec<DefinitionId> = flow
1135            .pending_choices
1136            .iter()
1137            .filter(|pc| !pc.flags.is_invisible_default)
1138            .map(|pc| pc.target_id)
1139            .collect();
1140        let pending_choices = self
1141            .resolved_choices_for(flow)
1142            .into_iter()
1143            .enumerate()
1144            .map(|(i, ch)| DebugChoice {
1145                text: ch.text,
1146                target: visible_targets
1147                    .get(i)
1148                    .and_then(|id| resolver.def_path(*id))
1149                    .map(str::to_owned),
1150                // `ch.index` is the pre-filter `flow.pending_choices` position
1151                // (see `resolved_choices_for`) — the same index `choose()`
1152                // expects, not the post-filter enumeration position `i`.
1153                index: ch.index,
1154            })
1155            .collect();
1156
1157        DebugSnapshot {
1158            status,
1159            current_location,
1160            turn_index: ctx.turn_index,
1161            globals,
1162            call_stack,
1163            visit_counts,
1164            pending_choices,
1165            rng: DebugRng {
1166                seed: ctx.rng_seed,
1167                previous: ctx.previous_random,
1168            },
1169        }
1170    }
1171
1172    // ── Session support (crate-internal) ────────────────────────────
1173
1174    /// Whether the default flow is in the `Active` status (mid-turn, more
1175    /// content pending). Used by [`StorySession`](crate::StorySession) for the
1176    /// turn-boundary mutation gate.
1177    pub(crate) fn status_is_active(&self) -> bool {
1178        self.default.status == StoryStatus::Active
1179    }
1180
1181    /// Whether the default flow is waiting for a choice selection. Used by
1182    /// [`StorySession`](crate::StorySession) replay.
1183    pub(crate) fn status_is_waiting_for_choice(&self) -> bool {
1184        self.default.status == StoryStatus::WaitingForChoice
1185    }
1186
1187    /// Build a typed [`StateSnapshot`](crate::StateSnapshot) of the default
1188    /// flow's game state — a NEW typed serialization path (globals with list
1189    /// membership, turn counts, callstack summary), distinct from the
1190    /// string-valued [`DebugSnapshot`](crate::DebugSnapshot).
1191    ///
1192    /// Known projection limit (deliberate, not a silent bug): visit/turn-count
1193    /// entries whose scope has no resolvable author path (anonymous counted
1194    /// containers — gathers, choice points — keyed only by hash id) are
1195    /// **omitted** from the snapshot's path-keyed maps. The full id-keyed
1196    /// counts remain available via [`Story::save_state`].
1197    pub(crate) fn state_snapshot(&self) -> crate::session::StateSnapshot {
1198        use alloc::collections::BTreeMap;
1199
1200        use crate::debug::NameResolver;
1201        use crate::session::{SnapshotFrame, SnapshotList, StateSnapshot};
1202
1203        let flow = &self.default.flow;
1204        let ctx = &self.default_context;
1205        let resolver = NameResolver::new(&self.program);
1206
1207        // Typed globals + resolved list membership.
1208        let mut globals: BTreeMap<String, Value> = BTreeMap::new();
1209        let mut lists: BTreeMap<String, SnapshotList> = BTreeMap::new();
1210        for (i, value) in ctx.globals.iter().enumerate() {
1211            if let Some(name) = self.program.global_slot_name(i) {
1212                if let Value::List(list) = value {
1213                    let mut items: Vec<String> = list
1214                        .items
1215                        .iter()
1216                        .filter_map(|id| self.program.list_item_name(*id).map(str::to_owned))
1217                        .collect();
1218                    items.sort_unstable();
1219                    lists.insert(name.to_owned(), SnapshotList { items });
1220                }
1221                globals.insert(name.to_owned(), value.clone());
1222            }
1223        }
1224
1225        // Visit / turn counts by resolved path (deterministic BTreeMap).
1226        let mut visit_counts: BTreeMap<String, u32> = BTreeMap::new();
1227        for (id, &count) in &ctx.visit_counts {
1228            if let Some(path) = resolver.def_path(*id) {
1229                visit_counts.insert(path.to_owned(), count);
1230            }
1231        }
1232        let mut turn_counts: BTreeMap<String, u32> = BTreeMap::new();
1233        for (id, &count) in &ctx.turn_counts {
1234            if let Some(path) = resolver.def_path(*id) {
1235                turn_counts.insert(path.to_owned(), count);
1236            }
1237        }
1238
1239        // Callstack summary, innermost frame first.
1240        let resolve_frame_location = |frame: &CallFrame| {
1241            frame
1242                .container_stack
1243                .iter()
1244                .rev()
1245                .find_map(|cp| resolver.container_path(cp.container_idx))
1246                .map(str::to_owned)
1247        };
1248        let thread = flow.current_thread();
1249        let depth = thread.call_stack.len();
1250        let mut call_stack = Vec::with_capacity(depth);
1251        for i in (0..depth).rev() {
1252            if let Some(frame) = thread.call_stack.get(i) {
1253                let kind = match frame.frame_type {
1254                    CallFrameType::Root => "root",
1255                    CallFrameType::Function => "function",
1256                    CallFrameType::Tunnel => "tunnel",
1257                    CallFrameType::Thread => "thread",
1258                    CallFrameType::External => "external",
1259                    CallFrameType::FunctionEvalFromGame => "eval",
1260                };
1261                call_stack.push(SnapshotFrame {
1262                    kind: kind.to_owned(),
1263                    location: resolve_frame_location(frame),
1264                    temps: frame.temps.len(),
1265                });
1266            }
1267        }
1268
1269        StateSnapshot {
1270            globals,
1271            lists,
1272            turn_index: ctx.turn_index,
1273            visit_counts,
1274            turn_counts,
1275            call_stack,
1276            status: self.default.status.into(),
1277        }
1278    }
1279
1280    // ── Testing / instrumentation API ───────────────────────────────
1281
1282    /// Dump the current execution state for debugging.
1283    ///
1284    /// Returns a human-readable summary of the call stack, current position,
1285    /// value stack, output buffer, globals, and pending choices.
1286    #[cfg(feature = "testing")]
1287    pub fn debug_state(&self) -> String {
1288        use core::fmt::Write;
1289        let mut out = String::new();
1290        let flow = &self.default.flow;
1291        let ctx = &self.default_context;
1292
1293        let _ = writeln!(out, "=== Story Debug State ===");
1294        let _ = writeln!(out, "status: {:?}", self.default.status);
1295
1296        // Current position
1297        let thread = flow.current_thread();
1298        if let Some(frame) = thread.call_stack.last()
1299            && let Some(cp) = frame.container_stack.last()
1300        {
1301            let id = self.program.container(cp.container_idx).id;
1302            let _ = writeln!(
1303                out,
1304                "position: container_idx={} id={id:?} offset={}",
1305                cp.container_idx, cp.offset,
1306            );
1307        }
1308
1309        // Call stack
1310        let depth = thread.call_stack.len();
1311        let _ = writeln!(out, "\ncall stack ({depth} frames):");
1312        for i in 0..depth {
1313            if let Some(frame) = thread.call_stack.get(i) {
1314                let ret = frame
1315                    .return_address
1316                    .map(|r| format!("idx={} off={}", r.container_idx, r.offset));
1317                let _ = writeln!(
1318                    out,
1319                    "  [{i}] {:?} ret={} temps={} containers={}",
1320                    frame.frame_type,
1321                    ret.as_deref().unwrap_or("none"),
1322                    frame.temps.len(),
1323                    frame.container_stack.len(),
1324                );
1325                for (j, cp) in frame.container_stack.iter().enumerate() {
1326                    let id = self.program.container(cp.container_idx).id;
1327                    let _ = writeln!(
1328                        out,
1329                        "       container_stack[{j}]: idx={} id={id:?} off={}",
1330                        cp.container_idx, cp.offset,
1331                    );
1332                }
1333            }
1334        }
1335
1336        // Value stack
1337        let _ = writeln!(out, "\nvalue stack ({}):", flow.value_stack.len());
1338        for (i, v) in flow.value_stack.iter().enumerate() {
1339            let _ = writeln!(out, "  [{i}] {v:?}");
1340        }
1341
1342        // Output buffer (unread transcript)
1343        let unread_start = flow.output.cursor;
1344        let transcript = &flow.output.transcript[unread_start..];
1345        let _ = writeln!(
1346            out,
1347            "\noutput buffer (cursor={unread_start}, {} unread parts):",
1348            transcript.len(),
1349        );
1350        for (i, part) in transcript.iter().enumerate() {
1351            let _ = writeln!(out, "  [{i}] {part:?}");
1352        }
1353
1354        // Globals
1355        let _ = writeln!(out, "\nglobals:");
1356        for (i, v) in ctx.globals.iter().enumerate() {
1357            #[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
1358            if let Some(name) = self.program.global_name(i as u32) {
1359                let _ = writeln!(out, "  {name} = {v:?}");
1360            }
1361        }
1362
1363        // Flow flags
1364        let _ = writeln!(out, "\nskipping_choice: {}", flow.skipping_choice);
1365
1366        // Pending choices
1367        let _ = writeln!(out, "\npending choices ({}):", flow.pending_choices.len());
1368        for (i, c) in flow.pending_choices.iter().enumerate() {
1369            let _ = writeln!(out, "  [{i}] {:?} -> {:?}", c.display, c.target_id);
1370        }
1371
1372        out
1373    }
1374
1375    /// Returns whether the last execution cycle of the **default** flow
1376    /// ended with a safe exit (explicit `-> DONE` opcode). If false after a
1377    /// `Done` line, the story ran out of content — the next
1378    /// `continue_single` call will return [`RuntimeError::RanOutOfContent`]
1379    /// instead of more text. See [`FlowInstance::did_safe_exit`] for the
1380    /// full contract.
1381    ///
1382    /// This reads only `self.default` — for a named flow (spawned via
1383    /// [`spawn_flow`](Self::spawn_flow) or one of the isolated
1384    /// `instances`), use [`did_safe_exit_flow`](Self::did_safe_exit_flow)
1385    /// instead. Calling this after `continue_flow*` on a named flow
1386    /// silently returns the default flow's stale value.
1387    #[must_use]
1388    pub fn did_safe_exit(&self) -> bool {
1389        self.default.did_safe_exit()
1390    }
1391
1392    /// Like [`did_safe_exit`](Self::did_safe_exit), but for a named flow
1393    /// (shared or isolated) rather than the default flow. Mirrors
1394    /// [`debug_snapshot_flow`](Self::debug_snapshot_flow)'s lookup shape:
1395    /// checks `shared_instances` first, then falls back to the isolated
1396    /// `instances`.
1397    ///
1398    /// # Errors
1399    /// [`UnknownFlow`](RuntimeError::UnknownFlow) if no flow named `name`
1400    /// exists (shared or isolated).
1401    pub fn did_safe_exit_flow(&self, name: &str) -> Result<bool, RuntimeError> {
1402        if let Some(instance) = self.shared_instances.get(name) {
1403            Ok(instance.did_safe_exit())
1404        } else if let Some((instance, _ctx, _local)) = self.instances.get(name) {
1405            Ok(instance.did_safe_exit())
1406        } else {
1407            Err(RuntimeError::UnknownFlow(name.to_owned()))
1408        }
1409    }
1410
1411    /// Returns whether the last execution cycle passed through an empty
1412    /// choice set (a `Yield` opcode with no pending choices).
1413    #[cfg(feature = "testing")]
1414    pub fn did_unsafe_yield(&self) -> bool {
1415        self.default.flow.did_unsafe_yield
1416    }
1417
1418    /// Execute a single VM step and return a debug trace of what happened.
1419    ///
1420    /// Returns `(opcode_description, container_idx, offset_before)` or None
1421    /// if the step didn't decode an opcode (frame exhaustion, thread completion, etc).
1422    #[cfg(feature = "testing")]
1423    pub fn step_once(&mut self) -> Result<Option<(String, u32, usize)>, RuntimeError> {
1424        use brink_format::Opcode;
1425
1426        let flow = &self.default.flow;
1427        let thread = flow.current_thread();
1428
1429        // Capture position before step
1430        let pre_info = thread.call_stack.last().and_then(|frame| {
1431            frame.container_stack.last().map(|pos| {
1432                let container = self.program.container(pos.container_idx);
1433                if pos.offset < container.bytecode.len() {
1434                    let mut off = pos.offset;
1435                    let op = Opcode::decode(&container.bytecode, &mut off).ok();
1436                    (pos.container_idx, pos.offset, op)
1437                } else {
1438                    (pos.container_idx, pos.offset, None)
1439                }
1440            })
1441        });
1442
1443        // Execute one step
1444        let _result = vm::step::<R>(
1445            &mut self.default.flow,
1446            &self.program,
1447            &self.line_tables,
1448            &mut self.default_context,
1449            &mut self.default.stats,
1450            self.resolver.as_deref(),
1451        )?;
1452
1453        match pre_info {
1454            Some((ci, off, Some(op))) => Ok(Some((format!("{op:?}"), ci, off))),
1455            Some((ci, off, None)) => Ok(Some(("(end of container)".to_string(), ci, off))),
1456            None => Ok(None),
1457        }
1458    }
1459}
1460
1461#[cfg(test)]
1462#[expect(clippy::panic)]
1463mod tests {
1464    use super::*;
1465    use crate::link;
1466
1467    fn load_i079_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
1468        let data = brink_compiler::compile_path(std::path::Path::new(
1469            "../../tests/tier1/choices/I079-once-only-choices-can-link-back-to-self/story.ink",
1470        ))
1471        .unwrap()
1472        .data;
1473        link(&data).unwrap()
1474    }
1475
1476    /// Step a story until it yields choices, panicking if it ends first.
1477    fn step_until_choices(story: &mut Story) -> Vec<Choice> {
1478        loop {
1479            match story.continue_single().unwrap() {
1480                Step::Choices(choices) => return choices,
1481                Step::Line(_) => {}
1482                Step::Done => panic!("story hit Done before presenting choices"),
1483                Step::End => panic!("story ended before presenting choices"),
1484                Step::Suspended => panic!("story parked before presenting choices"),
1485            }
1486        }
1487    }
1488
1489    /// Step a story, accumulating text, until it stops (choices, done, or
1490    /// end) — returns the accumulated text for content assertions. Terminals
1491    /// carry no text themselves; any trailing content already arrived as
1492    /// its own preceding `Step::Line`.
1493    fn step_until_choices_or_end(story: &mut Story) -> String {
1494        let mut text = String::new();
1495        loop {
1496            match story.continue_single().unwrap() {
1497                Step::Choices(_) | Step::Done | Step::End | Step::Suspended => return text,
1498                Step::Line(line) => text.push_str(&line.text),
1499            }
1500        }
1501    }
1502
1503    /// After selecting a once-only choice, the visit count for its target
1504    /// container must be > 0. Without this, the once-only filter in
1505    /// `handle_begin_choice` can never fire.
1506    #[test]
1507    fn select_choice_increments_visit_count_for_target() {
1508        let (program, line_tables) = load_i079_program();
1509        let mut story = Story::new(Arc::new(program), line_tables);
1510        let choices = step_until_choices(&mut story);
1511
1512        assert!(!choices.is_empty(), "expected at least one choice");
1513
1514        // Record the target_id of the first pending choice BEFORE selecting.
1515        let target_id = story.default.flow.pending_choices[0].target_id;
1516        let visit_before = story
1517            .default_context
1518            .visit_counts
1519            .get(&target_id)
1520            .copied()
1521            .unwrap_or(0);
1522
1523        story.choose(0).unwrap();
1524
1525        // After selection, the visit count for this target must have increased.
1526        let visit_after = story
1527            .default_context
1528            .visit_counts
1529            .get(&target_id)
1530            .copied()
1531            .unwrap_or(0);
1532        assert!(
1533            visit_after > visit_before,
1534            "visit count for choice target should increment after selection: \
1535             before={visit_before}, after={visit_after}"
1536        );
1537    }
1538
1539    /// Build a linked `Story` directly from `.ink` source (no fixture file),
1540    /// for cases that need a specific choice shape not already in `tests/`.
1541    fn story_from_source(src: &str) -> Story {
1542        let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
1543        let mut bytes = Vec::new();
1544        brink_format::write_inkb(&out.data, &mut bytes);
1545        let data = brink_format::read_inkb(&bytes).expect("decode");
1546        let (prog, tables) = link(&data).expect("link");
1547        Story::new(Arc::new(prog), tables)
1548    }
1549
1550    /// FS-3w guard (`docs/flow-suspension-spec.md` §10.1): `Step::Suspended`
1551    /// ships on the `Step` surface now but is **runtime-unreachable until
1552    /// FS-3r** — the E052 lowering fence keeps `await` from producing
1553    /// bytecode, so no `park`/`spill`/`resume` path exists to construct it.
1554    /// This pins both halves: the variant's terminal contract (terminals
1555    /// carry no payload — §7), and that driving a representative story
1556    /// (including one that spins up a shared flow) never yields a
1557    /// `Suspended` step, and that `wake_check` reports no woken flows
1558    /// because none can park.
1559    #[test]
1560    fn step_suspended_is_terminal_and_never_constructed_in_runtime() {
1561        // The variant behaves like any other terminal: no payload, reports
1562        // terminal.
1563        let parked = Step::Suspended;
1564        assert_eq!(parked.text(), "");
1565        assert!(parked.tags().is_empty());
1566        assert!(parked.is_terminal(), "a park is a turn boundary");
1567
1568        // Drive a small story with a shared flow to a terminal; nothing the
1569        // runtime produces is ever `Suspended`.
1570        let src = "Hello -> knot\n== knot ==\nWorld\n-> DONE\n";
1571        let mut story = story_from_source(src);
1572        story
1573            .spawn_flow_shared("f", None)
1574            .expect("spawn shared flow");
1575        for _ in 0..64 {
1576            let step = story.continue_single().expect("continue");
1577            assert!(
1578                !matches!(step, Step::Suspended),
1579                "runtime must never construct Step::Suspended before FS-3r"
1580            );
1581            if step.is_terminal() {
1582                break;
1583            }
1584        }
1585        for _ in 0..64 {
1586            let step = story.continue_flow_single("f").expect("continue flow");
1587            assert!(
1588                !matches!(step, Step::Suspended),
1589                "a shared flow must never construct Step::Suspended before FS-3r"
1590            );
1591            if step.is_terminal() {
1592                break;
1593            }
1594        }
1595
1596        // No flow can park, so `wake_check` reports an empty woken set.
1597        assert!(
1598            story.wake_check().is_empty(),
1599            "wake_check returns no woken flows until parks exist (FS-3r)"
1600        );
1601    }
1602
1603    /// #999: a shared flow that emits text forever must error at
1604    /// `FlowInstance::LINE_LIMIT` rather than growing `continue_flow_maximally_shared`'s
1605    /// returned `Vec<Step>` without bound — the shared-flow analogue of
1606    /// `drive_to_terminal_errors_at_line_limit` above, exercised through the
1607    /// `Story`-level entry point the wasm leg (`brink-web`) actually calls.
1608    #[test]
1609    fn continue_flow_maximally_shared_errors_at_line_limit() {
1610        let src = "-> spam\n\n=== spam ===\nLine.\n-> spam\n";
1611        let mut story = story_from_source(src);
1612        story
1613            .spawn_flow_shared("f", None)
1614            .expect("spawn shared flow at the root (immediately diverts into `spam`)");
1615        let err = story
1616            .continue_flow_maximally_shared("f")
1617            .expect_err("infinite-emitting flow should hit the line limit rather than hang");
1618        match err {
1619            RuntimeError::LineLimitExceeded(n) => {
1620                assert_eq!(n, FlowInstance::LINE_LIMIT);
1621            }
1622            other => panic!("expected LineLimitExceeded, got {other:?}"),
1623        }
1624    }
1625
1626    /// `Choice.index` (the live, visible choice list) must be the *raw*
1627    /// `pending_choices` position, not the post-filter enumeration position —
1628    /// an invisible-default fallback choice (`* ->`) mixed in with visible
1629    /// choices occupies a `pending_choices` slot but never appears in the
1630    /// visible list, so the visible indices can skip values. This is exactly
1631    /// what `select_choice`/`choose` expects (it indexes `pending_choices`
1632    /// directly) — a caller must never re-derive the index from array
1633    /// position over the visible list alone.
1634    #[test]
1635    fn choice_index_is_raw_pending_choices_position_with_invisible_default_mixed_in() {
1636        let src = "-(start)\n\
1637             * [First] -> a\n\
1638             * -> b\n\
1639             * [Third] -> c\n\
1640             -(a) Went A.\n-> DONE\n\
1641             -(b) Went B.\n-> DONE\n\
1642             -(c) Went C.\n-> DONE\n";
1643        let mut story = story_from_source(src);
1644        let choices = step_until_choices(&mut story);
1645
1646        // The invisible-default fallback (raw index 1) is filtered out of the
1647        // visible list, so the visible choices' indices skip it: 0, then 2.
1648        assert_eq!(
1649            choices.iter().map(|c| c.index).collect::<Vec<_>>(),
1650            vec![0, 2],
1651            "visible choice indices must be the raw pending_choices positions, not 0,1,..: {choices:?}"
1652        );
1653        assert_eq!(story.default.flow.pending_choices.len(), 3);
1654
1655        // Choosing the raw index of the second visible entry must select
1656        // the "Third" branch, not the invisible-default fallback.
1657        story.choose(choices[1].index).expect("choose by raw index");
1658        let text = step_until_choices_or_end(&mut story);
1659        assert!(text.contains("Went C"), "expected the Third branch: {text}");
1660    }
1661
1662    /// `DebugSnapshot.pending_choices[].index` must agree with the live
1663    /// `Choice.index` — both derive from the same pre-filter pass over
1664    /// `pending_choices` (`resolved_choices_for`). A studio consumer restoring
1665    /// a Choice[] from a `DebugSnapshot` (rather than a live `Choice` list)
1666    /// depends on this to dispatch `choose()` correctly.
1667    #[test]
1668    fn debug_snapshot_choice_index_matches_live_choice_index() {
1669        let src = "-(start)\n\
1670             * [First] -> a\n\
1671             * -> b\n\
1672             * [Third] -> c\n\
1673             -(a) Went A.\n-> DONE\n\
1674             -(b) Went B.\n-> DONE\n\
1675             -(c) Went C.\n-> DONE\n";
1676        let mut story = story_from_source(src);
1677        let live_choices = step_until_choices(&mut story);
1678        let snap = story.debug_snapshot();
1679
1680        assert_eq!(snap.pending_choices.len(), live_choices.len());
1681        for (live, dbg) in live_choices.iter().zip(snap.pending_choices.iter()) {
1682            assert_eq!(
1683                dbg.index, live.index,
1684                "DebugChoice.index must match the live Choice.index"
1685            );
1686        }
1687    }
1688
1689    /// On the second pass through a choice set with once-only choices,
1690    /// a choice whose target has already been visited must NOT appear
1691    /// in `pending_choices`.
1692    #[test]
1693    fn once_only_choice_excluded_on_second_pass() {
1694        let (program, line_tables) = load_i079_program();
1695        let mut story = Story::new(Arc::new(program), line_tables);
1696
1697        let first_choices = step_until_choices(&mut story);
1698        assert!(
1699            first_choices
1700                .iter()
1701                .any(|c| c.text.contains("First choice")),
1702            "first pass should contain 'First choice', got: {first_choices:?}"
1703        );
1704
1705        story.choose(0).unwrap();
1706
1707        let second_choices = step_until_choices(&mut story);
1708        assert!(
1709            !second_choices
1710                .iter()
1711                .any(|c| c.text.contains("First choice")),
1712            "second pass should NOT contain 'First choice' (once-only, already visited), \
1713             got: {second_choices:?}"
1714        );
1715    }
1716
1717    // ── Choice thread forking ──────────────────────────────────────────
1718
1719    fn load_i083_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
1720        let data = brink_compiler::compile_path(std::path::Path::new(
1721            "../../tests/tier1/choices/I083-choice-thread-forking/story.ink",
1722        ))
1723        .unwrap()
1724        .data;
1725        link(&data).unwrap()
1726    }
1727
1728    /// When a choice is created inside a tunnel, the call stack at that
1729    /// moment (including the tunnel frame with its temps) must be captured.
1730    /// After the tunnel returns and the choice is presented, the snapshot
1731    /// should still reflect the tunnel-era call stack depth (>= 2 frames).
1732    #[test]
1733    fn pending_choice_captures_tunnel_call_stack() {
1734        let (program, line_tables) = load_i083_program();
1735        let mut story = Story::new(Arc::new(program), line_tables);
1736        let _choices = step_until_choices(&mut story);
1737
1738        // At this point the tunnel has returned, so the live call_stack
1739        // has only the root frame.
1740        let current_thread = story.default.flow.current_thread();
1741        assert_eq!(
1742            current_thread.call_stack.len(),
1743            1,
1744            "live call stack should be 1 frame (root) after tunnel return"
1745        );
1746
1747        // But the pending choice's fork should have captured the
1748        // call stack from inside the tunnel (root + tunnel = 2 frames).
1749        assert!(!story.default.flow.pending_choices.is_empty());
1750        let fork = &story.default.flow.pending_choices[0].thread_fork;
1751        assert!(
1752            fork.call_stack.len() >= 2,
1753            "choice fork should have >= 2 frames (root + tunnel), got {}",
1754            fork.call_stack.len()
1755        );
1756    }
1757
1758    /// After selecting a choice that was created inside a tunnel,
1759    /// `select_choice` must restore the tunnel's call frame so that
1760    /// temp variables from the tunnel scope are accessible.
1761    #[test]
1762    fn select_choice_restores_tunnel_frame_with_temps() {
1763        let (program, line_tables) = load_i083_program();
1764        let mut story = Story::new(Arc::new(program), line_tables);
1765        let _choices = step_until_choices(&mut story);
1766
1767        // Before choosing: only root frame, no tunnel temps.
1768        assert_eq!(story.default.flow.current_thread().call_stack.len(), 1);
1769
1770        story.choose(0).unwrap();
1771
1772        // After choosing: the tunnel frame should be restored.
1773        // The call stack should have at least 2 frames (root + tunnel).
1774        let call_stack = &story.default.flow.current_thread().call_stack;
1775        assert!(
1776            call_stack.len() >= 2,
1777            "call stack should be restored to tunnel depth after choice selection, \
1778             got {} frame(s)",
1779            call_stack.len()
1780        );
1781
1782        // The tunnel frame (last frame) should have temp x = Int(1).
1783        let tunnel_frame = call_stack.last().unwrap();
1784        assert!(
1785            !tunnel_frame.temps.is_empty(),
1786            "tunnel frame should have temp variables"
1787        );
1788        assert_eq!(
1789            tunnel_frame.temps[0],
1790            Value::Int(1),
1791            "tunnel frame temps[0] should be Int(1) (the parameter x)"
1792        );
1793    }
1794
1795    // ── Tags ──────────────────────────────────────────────────────────
1796
1797    fn load_tags_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
1798        let data = brink_compiler::compile_path(std::path::Path::new(
1799            "../../tests/tier3/tags/tags/story.ink",
1800        ))
1801        .unwrap()
1802        .data;
1803        link(&data).unwrap()
1804    }
1805
1806    fn load_tags_in_choice_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
1807        let data = brink_compiler::compile_path(std::path::Path::new(
1808            "../../tests/tier3/tags/tagsInChoice/story.ink",
1809        ))
1810        .unwrap()
1811        .data;
1812        link(&data).unwrap()
1813    }
1814
1815    #[test]
1816    fn line_exposes_tags() {
1817        let (program, line_tables) = load_tags_program();
1818        let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
1819        let lines = story.continue_maximally().unwrap();
1820        // The first line should have both tags.
1821        let first = lines.first().expect("expected at least one line");
1822        assert!(
1823            !matches!(first, Step::Choices(_)),
1824            "expected Text or End, got Choices"
1825        );
1826        assert_eq!(first.tags(), &["author: Joe", "title: My Great Story"],);
1827    }
1828
1829    #[test]
1830    fn choice_exposes_tags() {
1831        let (program, line_tables) = load_tags_in_choice_program();
1832        let mut story = Story::new(Arc::new(program), line_tables);
1833        let choices = step_until_choices(&mut story);
1834        assert!(!choices.is_empty());
1835        // The choice in tagsInChoice has tags "one" and "two"
1836        assert!(
1837            !choices[0].tags.is_empty(),
1838            "choice should have tags, got: {choices:?}"
1839        );
1840    }
1841
1842    // ── Thread support ──────────────────────────────────────────────────
1843
1844    fn load_i091_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
1845        let data = brink_compiler::compile_path(std::path::Path::new(
1846            "../../tests/tier1/choices/I091-choice-count/story.ink",
1847        ))
1848        .unwrap()
1849        .data;
1850        link(&data).unwrap()
1851    }
1852
1853    /// `<- choices` (thread) must create choices AND return to the main
1854    /// flow so that `CHOICE_COUNT()` can evaluate. The thread body
1855    /// should be called like a tunnel — when its container stack empties,
1856    /// execution returns to the caller. Non-root frames must always pop
1857    /// back to their caller, even when pending choices exist.
1858    #[test]
1859    fn thread_call_returns_to_main_flow() {
1860        let (program, line_tables) = load_i091_program();
1861        let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
1862
1863        let lines = story.continue_maximally().unwrap();
1864        // I091 should output "2\n" (CHOICE_COUNT) then present 2 choices.
1865        let full_text: String = lines.iter().map(Step::text).collect();
1866        assert!(
1867            full_text.starts_with('2'),
1868            "output should start with '2' from CHOICE_COUNT(), got: {full_text:?}"
1869        );
1870        let last = lines.last().expect("expected at least one line");
1871        match last {
1872            Step::Choices(choices) => {
1873                assert_eq!(choices.len(), 2, "expected 2 choices");
1874            }
1875            other => panic!("expected Choices, got {other:?}"),
1876        }
1877    }
1878
1879    // ── FlowInstance::drive_to_terminal (F6.1a shared drive-to-terminal op) ──
1880
1881    /// Compile `.ink` source directly into a linked `(Program, line_tables)`
1882    /// pair, bypassing `Story` so tests can drive a bare `FlowInstance`
1883    /// directly — the way a `Story`-free consumer (e.g. an engine
1884    /// integration) would.
1885    fn compile_source_for_flow(src: &str) -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
1886        let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
1887        let mut bytes = Vec::new();
1888        brink_format::write_inkb(&out.data, &mut bytes);
1889        let data = brink_format::read_inkb(&bytes).expect("decode");
1890        link(&data).expect("link")
1891    }
1892
1893    #[test]
1894    fn drive_to_terminal_stops_at_done() {
1895        let (program, tables) = compile_source_for_flow("Hello.\n-> DONE\n");
1896        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
1897        let mut local = FlowLocal::new();
1898        let mut view = ContextView::new(&mut world, &mut local);
1899        let lines = flow
1900            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
1901            .expect("drive succeeds");
1902        let (last, rest) = lines.split_last().expect("at least one line");
1903        assert!(matches!(last, Step::Done), "expected Done, got {last:?}");
1904        assert!(
1905            rest.iter().all(|l| matches!(l, Step::Line(_))),
1906            "every line before the terminal one should be Text, got {rest:?}"
1907        );
1908    }
1909
1910    #[test]
1911    fn drive_to_terminal_stops_at_choices() {
1912        let (program, tables) =
1913            compile_source_for_flow("Hello.\n* Pick me\n    Picked.\n    -> DONE\n");
1914        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
1915        let mut local = FlowLocal::new();
1916        let mut view = ContextView::new(&mut world, &mut local);
1917        let lines = flow
1918            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
1919            .expect("drive succeeds");
1920        let (last, rest) = lines.split_last().expect("at least one line");
1921        assert!(
1922            matches!(last, Step::Choices(_)),
1923            "expected Choices, got {last:?}"
1924        );
1925        assert!(
1926            rest.iter().all(|l| matches!(l, Step::Line(_))),
1927            "every line before the terminal one should be Text, got {rest:?}"
1928        );
1929    }
1930
1931    #[test]
1932    fn drive_to_terminal_stops_at_end() {
1933        let (program, tables) = compile_source_for_flow("Hello.\n-> END\n");
1934        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
1935        let mut local = FlowLocal::new();
1936        let mut view = ContextView::new(&mut world, &mut local);
1937        let lines = flow
1938            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
1939            .expect("drive succeeds");
1940        let (last, rest) = lines.split_last().expect("at least one line");
1941        assert!(matches!(last, Step::End), "expected End, got {last:?}");
1942        assert!(
1943            rest.iter().all(|l| matches!(l, Step::Line(_))),
1944            "every line before the terminal one should be Text, got {rest:?}"
1945        );
1946    }
1947
1948    /// A knot that prints and re-diverts into itself forever never reaches a
1949    /// terminal line, so `drive_to_terminal` must give up at
1950    /// `FlowInstance::LINE_LIMIT` rather than looping forever — proving the
1951    /// extracted op kept `Story::continue_maximally_impl`'s safety cap.
1952    #[test]
1953    fn drive_to_terminal_errors_at_line_limit() {
1954        let (program, tables) =
1955            compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
1956        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
1957        let mut local = FlowLocal::new();
1958        let mut view = ContextView::new(&mut world, &mut local);
1959        let err = flow
1960            .drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
1961            .expect_err("infinite content should hit the line limit rather than hang");
1962        match err {
1963            RuntimeError::LineLimitExceeded(n) => {
1964                assert_eq!(n, FlowInstance::LINE_LIMIT);
1965            }
1966            other => panic!("expected LineLimitExceeded, got {other:?}"),
1967        }
1968    }
1969
1970    // ── FlowInstance::drive (F6.2 pausable Layer-2 drive op) ─────────────
1971
1972    /// Defers (`Pending`) its first call, then resolves — mirrors the
1973    /// `DeferOnce` pattern used for the flow-level resume gap elsewhere in
1974    /// the runtime's test suite (`tests/session.rs`, `tests/speculation.rs`).
1975    struct DeferOnce {
1976        deferred: std::cell::Cell<bool>,
1977    }
1978
1979    impl ExternalFnHandler for DeferOnce {
1980        fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
1981            if name == "pause_once" && !self.deferred.get() {
1982                self.deferred.set(true);
1983                ExternalResult::Pending
1984            } else {
1985                ExternalResult::Resolved(Value::Int(2))
1986            }
1987        }
1988    }
1989
1990    /// `drive` pauses cleanly (no error) on a deferred external, and resuming
1991    /// after [`FlowInstance::resolve_external`] continues the *same* logical
1992    /// drive to its terminal line — the pausable sibling of
1993    /// `drive_to_terminal`, which instead errors on a deferred external.
1994    #[test]
1995    fn drive_pauses_on_awaiting_external_then_resumes() {
1996        let (program, tables) = compile_source_for_flow(
1997            "EXTERNAL pause_once(x)\nHello.\nWorld.\nValue: {pause_once(1)}.\n-> DONE\n",
1998        );
1999        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
2000        let mut local = FlowLocal::new();
2001        let mut view = ContextView::new(&mut world, &mut local);
2002        let handler = DeferOnce {
2003            deferred: std::cell::Cell::new(false),
2004        };
2005        let mut budget = 10usize;
2006
2007        let outcome = flow
2008            .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
2009            .expect("first drive call succeeds");
2010        let paused_lines = match outcome {
2011            DriveOutcome::AwaitingExternal(lines) => lines,
2012            other @ DriveOutcome::Terminal(_) => panic!("expected AwaitingExternal, got {other:?}"),
2013        };
2014        let paused_text: String = paused_lines.iter().map(Step::text).collect();
2015        assert!(
2016            paused_text.contains("Hello"),
2017            "text produced before the pause should include 'Hello.'; got {paused_text:?}"
2018        );
2019        assert!(
2020            !paused_text.contains("Value"),
2021            "the line calling the deferred external should not have completed yet; got {paused_text:?}"
2022        );
2023        assert_eq!(
2024            budget,
2025            10 - paused_lines.len(),
2026            "budget should decrement by exactly the lines produced before the pause"
2027        );
2028
2029        flow.resolve_external(Value::Int(2));
2030        let outcome = flow
2031            .drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
2032            .expect("second drive call resumes and completes");
2033        let resumed_lines = match outcome {
2034            DriveOutcome::Terminal(lines) => lines,
2035            other @ DriveOutcome::AwaitingExternal(_) => panic!("expected Terminal, got {other:?}"),
2036        };
2037        let resumed_text: String = resumed_lines.iter().map(Step::text).collect();
2038        assert!(
2039            resumed_text.contains("Value: 2"),
2040            "the resolved external's value should be inlined; got {resumed_text:?}"
2041        );
2042        assert!(
2043            matches!(resumed_lines.last(), Some(Step::Done)),
2044            "expected the drive to finish at Done, got {resumed_lines:?}"
2045        );
2046        assert_eq!(
2047            budget,
2048            10 - paused_lines.len() - resumed_lines.len(),
2049            "budget must keep decrementing across the resume — not reset to a fresh cap \
2050             (the whole point of the caller-owned budget: one bound per logical drive, not \
2051             per resume)"
2052        );
2053    }
2054
2055    /// A knot that prints and re-diverts into itself forever never reaches a
2056    /// terminal line. `drive` must give up when the caller's `budget` is
2057    /// exhausted — which can be far smaller than
2058    /// [`FlowInstance::LINE_LIMIT`] — rather than looping until the much
2059    /// larger production default, proving the budget is a real per-call
2060    /// parameter and not just a relabeling of the constant.
2061    #[test]
2062    fn drive_errors_when_caller_budget_exhausted() {
2063        let (program, tables) =
2064            compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
2065        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
2066        let mut local = FlowLocal::new();
2067        let mut view = ContextView::new(&mut world, &mut local);
2068        let mut budget = 3usize;
2069        let err = flow
2070            .drive::<FastRng>(
2071                &program,
2072                &tables,
2073                &mut view,
2074                &FallbackHandler,
2075                None,
2076                &mut budget,
2077            )
2078            .expect_err("infinite content should hit the caller's small budget");
2079        match err {
2080            RuntimeError::LineLimitExceeded(n) => assert_eq!(
2081                n, 3,
2082                "reported limit should be the caller's budget, not the unrelated LINE_LIMIT constant"
2083            ),
2084            other => panic!("expected LineLimitExceeded, got {other:?}"),
2085        }
2086        assert_eq!(budget, 0, "budget should be fully consumed, not partially");
2087    }
2088
2089    // ── free `save_state`/`load_state` (F6.1b) ───────────────────────────
2090
2091    /// A `Story`-free save/load roundtrip: drive a bare `FlowInstance` +
2092    /// `ContextView` (no `Story` anywhere), capture state via the lifted
2093    /// `save_state` free function, mutate the live context, then restore via
2094    /// `load_state` and confirm the mutation is undone. Proves the lifted
2095    /// functions work for a consumer (e.g. `bevy-brink`) that never
2096    /// constructs a `Story`.
2097    #[test]
2098    fn free_fn_save_load_roundtrip_without_story() {
2099        let (program, tables) = compile_source_for_flow(
2100            "VAR gold = 0\n\
2101             -> shrine\n\
2102             === shrine ===\n\
2103             ~ gold = 5\n\
2104             Shrine text.\n\
2105             -> DONE\n\
2106             === reader ===\n\
2107             {READ_COUNT(-> shrine)}\n\
2108             -> DONE\n",
2109            // `reader` is never entered — it exists only so the compiler's
2110            // counting-flags pass sees a visit-count read of `shrine` and
2111            // sets `CountingFlags::VISITS` on it (a knot with no read of its
2112            // own visit count anywhere in the program has counting disabled
2113            // entirely, an existing compiler optimization).
2114        );
2115        let (mut flow, mut world) = FlowInstance::new_at_root(&program);
2116        let mut local = FlowLocal::new();
2117        {
2118            let mut view = ContextView::new(&mut world, &mut local);
2119            flow.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
2120                .expect("drive succeeds");
2121        }
2122
2123        let gold_slot = program.global_index("gold").expect("gold declared");
2124        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
2125
2126        let save = {
2127            let view = ContextView::new(&mut world, &mut local);
2128            crate::save_state(&program, &view)
2129        };
2130        assert_eq!(save.globals.get("gold"), Some(&Value::Int(5)));
2131        assert_eq!(
2132            save.visits
2133                .iter()
2134                .find(|e| e.id == shrine_id)
2135                .map(|e| e.count),
2136            Some(1),
2137            "shrine should have a captured visit entry"
2138        );
2139
2140        // Mutate the live context directly through the trait.
2141        {
2142            let mut view = ContextView::new(&mut world, &mut local);
2143            view.set_global(gold_slot, Value::Int(999));
2144            view.set_visit_count(shrine_id, 42);
2145        }
2146        {
2147            let view = ContextView::new(&mut world, &mut local);
2148            assert_eq!(view.global(gold_slot), &Value::Int(999));
2149            assert_eq!(view.visit_count(shrine_id), 42);
2150        }
2151
2152        // Restore via the lifted `load_state` and confirm the mutation is
2153        // undone.
2154        let report = {
2155            let mut view = ContextView::new(&mut world, &mut local);
2156            crate::load_state(&program, &mut view, &save)
2157        };
2158        assert!(report.unknown_globals.is_empty(), "clean load: {report:?}");
2159
2160        let view = ContextView::new(&mut world, &mut local);
2161        assert_eq!(view.global(gold_slot), &Value::Int(5));
2162        assert_eq!(view.visit_count(shrine_id), 1);
2163    }
2164}