use std::collections::HashMap;
use std::marker::PhantomData;
use std::sync::Arc;
use brink_format::{ChoiceFlags, DefinitionId, PluralResolver, Value};
use crate::error::RuntimeError;
use crate::output::OutputBuffer;
use crate::program::Program;
use crate::rng::{FastRng, StoryRng};
use crate::state::{ContextAccess, WriteObserver};
use crate::vm;
use crate::world::{ContextView, FlowLocal, ResolvedPolicy, World};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoryStatus {
Active,
WaitingForChoice,
Done,
Ended,
}
#[derive(Debug, Clone)]
pub enum Line {
Text { text: String, tags: Vec<String> },
Done { text: String, tags: Vec<String> },
Choices {
text: String,
tags: Vec<String>,
choices: Vec<Choice>,
},
End { text: String, tags: Vec<String> },
}
impl Line {
pub fn text(&self) -> &str {
match self {
Self::Text { text, .. }
| Self::Done { text, .. }
| Self::Choices { text, .. }
| Self::End { text, .. } => text,
}
}
pub fn tags(&self) -> &[String] {
match self {
Self::Text { tags, .. }
| Self::Done { tags, .. }
| Self::Choices { tags, .. }
| Self::End { tags, .. } => tags,
}
}
pub fn is_terminal(&self) -> bool {
!matches!(self, Self::Text { .. })
}
}
#[derive(Debug, Clone)]
pub enum StepOutcome {
Line(Line),
AwaitingExternal,
}
#[derive(Debug, Clone)]
pub struct Choice {
pub text: String,
pub index: usize,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Stats {
pub opcodes: u64,
pub steps: u64,
pub threads_created: u64,
pub threads_completed: u64,
pub frames_pushed: u64,
pub frames_popped: u64,
pub choices_presented: u64,
pub choices_selected: u64,
pub snapshot_cache_hits: u64,
pub snapshot_cache_misses: u64,
pub materializations: u64,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ContainerPosition {
pub container_idx: u32,
pub offset: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CallFrameType {
Root,
Function,
Tunnel,
Thread,
External,
FunctionEvalFromGame,
}
#[derive(Debug, Clone)]
pub(crate) struct CallFrame {
pub return_address: Option<ContainerPosition>,
pub temps: Vec<Value>,
pub container_stack: Vec<ContainerPosition>,
pub frame_type: CallFrameType,
pub external_fn_id: Option<DefinitionId>,
pub function_output_start: Option<usize>,
}
#[derive(Debug, Clone)]
pub(crate) struct CallStack {
inherited: Option<Arc<[CallFrame]>>,
own: Vec<CallFrame>,
cached_snapshot: Option<Arc<[CallFrame]>>,
pub(crate) materialization_count: u64,
}
impl CallStack {
pub fn new(frame: CallFrame) -> Self {
Self {
inherited: None,
own: vec![frame],
cached_snapshot: None,
materialization_count: 0,
}
}
pub fn push(&mut self, frame: CallFrame) {
self.cached_snapshot = None;
self.own.push(frame);
}
pub fn pop(&mut self) -> Option<CallFrame> {
self.cached_snapshot = None;
if let Some(f) = self.own.pop() {
return Some(f);
}
self.materialize();
self.own.pop()
}
pub fn last(&self) -> Option<&CallFrame> {
self.own
.last()
.or_else(|| self.inherited.as_ref().and_then(|h| h.last()))
}
pub fn last_mut(&mut self) -> Option<&mut CallFrame> {
if !self.own.is_empty() {
return self.own.last_mut();
}
self.materialize();
self.own.last_mut()
}
pub fn len(&self) -> usize {
self.inherited.as_ref().map_or(0, |h| h.len()) + self.own.len()
}
pub fn is_empty(&self) -> bool {
self.own.is_empty() && self.inherited.as_ref().is_none_or(|h| h.is_empty())
}
pub fn get(&self, index: usize) -> Option<&CallFrame> {
let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
if index < inherited_len {
self.inherited.as_ref().and_then(|h| h.get(index))
} else {
self.own.get(index - inherited_len)
}
}
pub fn get_mut(&mut self, index: usize) -> Option<&mut CallFrame> {
let inherited_len = self.inherited.as_ref().map_or(0, |h| h.len());
if index < inherited_len {
self.materialize();
self.own.get_mut(index)
} else {
self.own.get_mut(index - inherited_len)
}
}
pub fn snapshot(&mut self) -> (Arc<[CallFrame]>, bool) {
if let Some(ref cached) = self.cached_snapshot {
return (Arc::clone(cached), true);
}
let rc = match &self.inherited {
None => Arc::from(self.own.as_slice()),
Some(prefix) if self.own.is_empty() => Arc::clone(prefix),
Some(prefix) => {
let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
combined.extend_from_slice(prefix);
combined.extend_from_slice(&self.own);
Arc::from(combined)
}
};
self.cached_snapshot = Some(Arc::clone(&rc));
(rc, false)
}
fn materialize(&mut self) -> bool {
self.cached_snapshot = None;
if let Some(prefix) = self.inherited.take() {
let mut combined = Vec::with_capacity(prefix.len() + self.own.len());
combined.extend_from_slice(&prefix);
combined.append(&mut self.own);
self.own = combined;
self.materialization_count += 1;
true
} else {
false
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Thread {
pub call_stack: CallStack,
}
#[derive(Debug, Clone)]
pub(crate) enum ChoiceDisplay {
Text(String),
Fragment(u32),
}
#[derive(Debug, Clone)]
pub(crate) struct PendingChoice {
pub display: ChoiceDisplay,
pub target_id: DefinitionId,
pub target_idx: u32,
pub target_offset: usize,
pub flags: ChoiceFlags,
#[expect(
dead_code,
reason = "needs research — likely needed for structured output / voice acting"
)]
pub original_index: usize,
pub tags: Vec<String>,
pub thread_fork: Thread,
}
#[derive(Debug, Clone)]
#[expect(
clippy::struct_excessive_bools,
reason = "VM flags are inherently boolean"
)]
pub(crate) struct Flow {
pub threads: Vec<Thread>,
pub value_stack: Vec<Value>,
pub output: OutputBuffer,
pub pending_choices: Vec<PendingChoice>,
pub current_tags: Vec<String>,
pub in_tag: bool,
pub skipping_choice: bool,
pub did_safe_exit: bool,
pub did_unsafe_yield: bool,
}
impl Flow {
#[expect(clippy::expect_used)]
pub fn current_thread(&self) -> &Thread {
self.threads
.last()
.expect("flow must always have at least one thread")
}
#[expect(clippy::expect_used)]
pub fn current_thread_mut(&mut self) -> &mut Thread {
self.threads
.last_mut()
.expect("flow must always have at least one thread")
}
pub fn can_pop_thread(&self) -> bool {
self.threads.len() > 1
}
pub fn has_eval_boundary(&self) -> bool {
let cs = &self.current_thread().call_stack;
(0..cs.len())
.filter_map(|i| cs.get(i))
.any(|f| f.frame_type == CallFrameType::FunctionEvalFromGame)
}
pub fn pop_thread(&mut self) {
self.threads.pop();
}
pub fn fork_thread(&mut self) -> (Thread, bool) {
let (shared, cache_hit) = self.current_thread_mut().call_stack.snapshot();
(
Thread {
call_stack: CallStack {
inherited: Some(shared),
own: Vec::new(),
cached_snapshot: None,
materialization_count: 0,
},
},
cache_hit,
)
}
pub fn drain_materializations(&mut self) -> u64 {
let mut total = 0;
for thread in &mut self.threads {
total += thread.call_stack.materialization_count;
thread.call_stack.materialization_count = 0;
}
total
}
pub fn external_args(&self) -> &[Value] {
let frame = self.current_thread().call_stack.last();
match frame {
Some(f) if f.frame_type == CallFrameType::External => &f.temps,
_ => &[],
}
}
pub fn external_fn_id(&self) -> Option<DefinitionId> {
let frame = self.current_thread().call_stack.last()?;
if frame.frame_type == CallFrameType::External {
frame.external_fn_id
} else {
None
}
}
pub fn resolve_external(&mut self, value: Value) {
let thread = self.current_thread_mut();
if let Some(frame) = thread.call_stack.last()
&& frame.frame_type == CallFrameType::External
{
let ret_addr = frame.return_address;
thread.call_stack.pop();
self.value_stack.push(value);
if let Some(pos) = ret_addr
&& let Some(f) = self.current_thread_mut().call_stack.last_mut()
&& let Some(top) = f.container_stack.last_mut()
{
*top = pos;
}
}
}
pub fn invoke_fallback(&mut self, container_idx: u32) {
let output_start = self.output.target_len();
let thread = self.current_thread_mut();
if let Some(frame) = thread.call_stack.last_mut()
&& frame.frame_type == CallFrameType::External
{
let args = core::mem::take(&mut frame.temps);
frame.frame_type = CallFrameType::Function;
frame.container_stack = vec![ContainerPosition {
container_idx,
offset: 0,
}];
frame.external_fn_id = None;
frame.function_output_start = Some(output_start);
self.value_stack.extend(args);
}
}
pub fn pop_value(&mut self) -> Result<Value, RuntimeError> {
self.value_stack.pop().ok_or(RuntimeError::StackUnderflow)
}
pub fn peek_value(&self) -> Result<&Value, RuntimeError> {
self.value_stack.last().ok_or(RuntimeError::StackUnderflow)
}
}
#[derive(Debug, Clone)]
pub enum ExternalResult {
Resolved(Value),
Fallback,
Pending,
}
pub trait ExternalFnHandler {
fn call(&self, name: &str, args: &[Value]) -> ExternalResult;
}
pub struct FallbackHandler;
impl ExternalFnHandler for FallbackHandler {
fn call(&self, _name: &str, _args: &[Value]) -> ExternalResult {
ExternalResult::Fallback
}
}
#[derive(Debug, Clone)]
pub enum FunctionEval {
Returned(Value),
AwaitingExternal,
}
#[derive(Clone, Debug)]
pub struct FlowInstance {
pub(crate) flow: Flow,
pub(crate) status: StoryStatus,
pub(crate) stats: Stats,
pub(crate) eval: Option<EvalState>,
}
#[derive(Debug, Clone)]
pub(crate) struct EvalState {
pub value_floor: usize,
pub choice_floor: usize,
}
#[derive(Debug, Clone)]
pub enum DriveOutcome {
Terminal(Vec<Line>),
AwaitingExternal(Vec<Line>),
}
impl FlowInstance {
pub fn new_at_root(program: &Program) -> (Self, World) {
Self::new_at(program, program.root_idx())
}
pub fn new_at(program: &Program, container_idx: u32) -> (Self, World) {
let globals = program.global_defaults();
let initial_frame = CallFrame {
return_address: None,
temps: Vec::new(),
container_stack: vec![ContainerPosition {
container_idx,
offset: 0,
}],
frame_type: CallFrameType::Root,
external_fn_id: None,
function_output_start: None,
};
let initial_thread = Thread {
call_stack: CallStack::new(initial_frame),
};
let flow_instance = Self {
flow: Flow {
threads: vec![initial_thread],
value_stack: Vec::new(),
output: OutputBuffer::new(),
pending_choices: Vec::new(),
current_tags: Vec::new(),
in_tag: false,
skipping_choice: false,
did_safe_exit: false,
did_unsafe_yield: false,
},
status: StoryStatus::Active,
stats: Stats::default(),
eval: None,
};
let world = World::from_globals(globals, ResolvedPolicy::all_world());
(flow_instance, world)
}
const STEP_LIMIT: u64 = 1_000_000;
pub fn step_single_line<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
resolver: Option<&dyn PluralResolver>,
) -> Result<Line, RuntimeError> {
match self.advance::<R>(program, line_tables, context, handler, resolver)? {
StepOutcome::Line(line) => Ok(line),
StepOutcome::AwaitingExternal => {
let id = self
.flow
.external_fn_id()
.ok_or(RuntimeError::CallStackUnderflow)?;
Err(RuntimeError::UnresolvedExternalCall(id))
}
}
}
pub const LINE_LIMIT: usize = 10_000;
pub fn drive_to_terminal<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
resolver: Option<&dyn PluralResolver>,
) -> Result<Vec<Line>, RuntimeError> {
let mut lines = Vec::new();
loop {
let line =
self.step_single_line::<R>(program, line_tables, context, handler, resolver)?;
let terminal = line.is_terminal();
lines.push(line);
if terminal {
return Ok(lines);
}
if lines.len() >= Self::LINE_LIMIT {
return Err(RuntimeError::LineLimitExceeded(Self::LINE_LIMIT));
}
}
}
pub fn drive<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
resolver: Option<&dyn PluralResolver>,
budget: &mut usize,
) -> Result<DriveOutcome, RuntimeError> {
let starting_budget = *budget;
let mut lines = Vec::new();
loop {
if *budget == 0 {
return Err(RuntimeError::LineLimitExceeded(starting_budget));
}
match self.advance::<R>(program, line_tables, context, handler, resolver)? {
StepOutcome::AwaitingExternal => return Ok(DriveOutcome::AwaitingExternal(lines)),
StepOutcome::Line(line) => {
let terminal = line.is_terminal();
*budget -= 1;
lines.push(line);
if terminal {
return Ok(DriveOutcome::Terminal(lines));
}
}
}
}
}
pub fn advance<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
resolver: Option<&dyn PluralResolver>,
) -> Result<StepOutcome, RuntimeError> {
self.advance_with_limit::<R>(
program,
line_tables,
context,
handler,
resolver,
Self::STEP_LIMIT,
)
}
#[expect(clippy::too_many_lines)]
pub(crate) fn advance_with_limit<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
resolver: Option<&dyn PluralResolver>,
step_limit: u64,
) -> Result<StepOutcome, RuntimeError> {
if self.flow.output.has_completed_line()
&& let Some((text, tags)) =
self.flow
.output
.take_first_line(program, line_tables, resolver)
{
return Ok(StepOutcome::Line(Line::Text { text, tags }));
}
if self.flow.output.has_unread() && self.status != StoryStatus::Active {
let (text, tags) = flush_remaining(&mut self.flow, program, line_tables, resolver);
return Ok(StepOutcome::Line(make_yield_line(
self.status,
text,
tags,
&self.flow,
program,
line_tables,
resolver,
)));
}
if self.status == StoryStatus::Ended {
return Err(RuntimeError::StoryEnded);
}
if self.status == StoryStatus::WaitingForChoice {
return Err(RuntimeError::NotWaitingForChoice);
}
if self.status == StoryStatus::Done {
if !self.flow.did_safe_exit {
return Err(RuntimeError::RanOutOfContent);
}
self.status = StoryStatus::Active;
}
self.flow.did_safe_exit = false;
self.flow.did_unsafe_yield = false;
let Self {
flow,
status,
stats,
..
} = self;
let step_start = stats.steps;
loop {
stats.steps += 1;
if stats.steps - step_start > step_limit {
return Err(RuntimeError::StepLimitExceeded(step_limit));
}
let stepped = vm::step::<R>(flow, program, line_tables, context, stats, resolver)?;
stats.materializations += flow.drain_materializations();
match stepped {
vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {
if flow.output.has_completed_line()
&& let Some((text, tags)) =
flow.output.take_first_line(program, line_tables, resolver)
{
return Ok(StepOutcome::Line(Line::Text { text, tags }));
}
}
vm::Stepped::ExternalCall => {
if !resolve_external_call(flow, program, handler)? {
return Ok(StepOutcome::AwaitingExternal);
}
if flow.output.has_completed_line()
&& let Some((text, tags)) =
flow.output.take_first_line(program, line_tables, resolver)
{
return Ok(StepOutcome::Line(Line::Text { text, tags }));
}
}
vm::Stepped::Done => {
context.increment_turn_index();
if !flow.pending_choices.is_empty() {
let all_invisible = flow
.pending_choices
.iter()
.all(|pc| pc.flags.is_invisible_default);
if all_invisible {
select_choice(flow, context, status, stats, 0)?;
if flow.output.has_completed_line()
&& let Some((text, tags)) =
flow.output.take_first_line(program, line_tables, resolver)
{
return Ok(StepOutcome::Line(Line::Text { text, tags }));
}
continue;
}
}
if flow.pending_choices.is_empty() {
*status = StoryStatus::Done;
} else {
*status = StoryStatus::WaitingForChoice;
stats.choices_presented += 1;
}
if flow.output.has_completed_line()
&& let Some((text, tags)) =
flow.output.take_first_line(program, line_tables, resolver)
{
return Ok(StepOutcome::Line(Line::Text { text, tags }));
}
let (text, tags) = flush_remaining(flow, program, line_tables, resolver);
return Ok(StepOutcome::Line(make_yield_line(
*status,
text,
tags,
flow,
program,
line_tables,
resolver,
)));
}
vm::Stepped::Ended => {
context.increment_turn_index();
*status = StoryStatus::Ended;
if flow.output.has_completed_line()
&& let Some((text, tags)) =
flow.output.take_first_line(program, line_tables, resolver)
{
return Ok(StepOutcome::Line(Line::Text { text, tags }));
}
let (text, tags) = flush_remaining(flow, program, line_tables, resolver);
return Ok(StepOutcome::Line(Line::End { text, tags }));
}
}
}
}
pub fn choose(
&mut self,
context: &mut (impl ContextAccess + ?Sized),
index: usize,
) -> Result<(), RuntimeError> {
if self.status != StoryStatus::WaitingForChoice {
return Err(RuntimeError::NotWaitingForChoice);
}
select_choice(
&mut self.flow,
context,
&mut self.status,
&mut self.stats,
index,
)
}
pub fn choose_path_string(
&mut self,
program: &Program,
context: &mut (impl ContextAccess + ?Sized),
path: &str,
) -> Result<(), RuntimeError> {
self.choose_path_string_with_args(program, context, path, &[])
}
pub fn choose_path_string_with_args(
&mut self,
program: &Program,
context: &mut (impl ContextAccess + ?Sized),
path: &str,
args: &[Value],
) -> Result<(), RuntimeError> {
if let Some(id) = self.flow.external_fn_id() {
let external = program
.external_fn(id)
.map_or_else(|| format!("{id}"), |e| program.name(e.name).to_owned());
return Err(RuntimeError::JumpWhileAwaitingExternal {
path: path.to_owned(),
external,
});
}
if self.eval.is_some() {
return Err(RuntimeError::AlreadyEvaluatingFunction);
}
let target_id = program
.find_path_target(path)
.ok_or_else(|| RuntimeError::UnknownPath(path.to_owned()))?;
let expected = program.path_param_count(path).unwrap_or(0);
if args.len() != expected as usize {
return Err(RuntimeError::ArgCountMismatch {
target: path.to_owned(),
expected,
got: args.len(),
});
}
let root_frame = CallFrame {
return_address: None,
temps: Vec::new(),
container_stack: Vec::new(),
frame_type: CallFrameType::Root,
external_fn_id: None,
function_output_start: None,
};
self.flow.threads = vec![Thread {
call_stack: CallStack::new(root_frame),
}];
self.flow.pending_choices.clear();
self.flow.skipping_choice = false;
self.flow.in_tag = false;
self.flow.did_safe_exit = true;
self.flow.value_stack.extend_from_slice(args);
vm::goto_target(&mut self.flow, program, context, target_id)?;
self.status = StoryStatus::Active;
Ok(())
}
#[must_use]
pub fn status(&self) -> StoryStatus {
self.status
}
#[must_use]
pub fn stats(&self) -> &Stats {
&self.stats
}
#[must_use]
pub fn transcript(&self) -> &[crate::output::OutputPart] {
self.flow.output.transcript()
}
#[must_use]
pub fn transcript_len(&self) -> usize {
self.flow.output.transcript_len()
}
pub fn reset_cursor(&mut self) {
self.flow.output.reset_cursor();
}
#[must_use]
pub fn fragments(&self) -> &[crate::output::Fragment] {
self.flow.output.fragments()
}
#[must_use]
pub fn has_pending_external(&self) -> bool {
self.flow.external_fn_id().is_some()
}
#[must_use]
pub fn pending_external_fn_id(&self) -> Option<DefinitionId> {
self.flow.external_fn_id()
}
#[must_use]
pub fn pending_external_args(&self) -> &[Value] {
self.flow.external_args()
}
#[must_use]
pub fn pending_external_name<'p>(&self, program: &'p Program) -> Option<&'p str> {
let id = self.flow.external_fn_id()?;
let entry = program.external_fn(id)?;
Some(program.name(entry.name))
}
pub fn resolve_external(&mut self, value: Value) {
self.flow.resolve_external(value);
}
#[expect(
clippy::too_many_arguments,
reason = "the VM environment (program, line tables, context, handler, resolver) plus the call target and args"
)]
pub fn begin_function_eval<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
container_idx: u32,
args: &[Value],
resolver: Option<&dyn PluralResolver>,
) -> Result<FunctionEval, RuntimeError> {
if self.eval.is_some() {
return Err(RuntimeError::AlreadyEvaluatingFunction);
}
let value_floor = self.flow.value_stack.len();
let choice_floor = self.flow.pending_choices.len();
self.flow.output.begin_capture();
let output_start = self.flow.output.target_len();
let boundary = CallFrame {
return_address: None,
temps: Vec::new(),
container_stack: vec![ContainerPosition {
container_idx,
offset: 0,
}],
frame_type: CallFrameType::FunctionEvalFromGame,
external_fn_id: None,
function_output_start: Some(output_start),
};
self.flow.current_thread_mut().call_stack.push(boundary);
self.stats.frames_pushed += 1;
self.flow.value_stack.extend_from_slice(args);
self.eval = Some(EvalState {
value_floor,
choice_floor,
});
self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
}
pub fn resume_function_eval<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
resolver: Option<&dyn PluralResolver>,
) -> Result<FunctionEval, RuntimeError> {
if self.eval.is_none() {
return Err(RuntimeError::NotEvaluatingFunction);
}
self.drive_function_eval::<R>(program, line_tables, context, handler, resolver)
}
#[must_use]
pub fn is_evaluating_function(&self) -> bool {
self.eval.is_some()
}
fn drive_function_eval<R: StoryRng>(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
resolver: Option<&dyn PluralResolver>,
) -> Result<FunctionEval, RuntimeError> {
let step_start = self.stats.steps;
loop {
self.stats.steps += 1;
if self.stats.steps - step_start > Self::STEP_LIMIT {
self.abort_eval(program, line_tables, resolver);
return Err(RuntimeError::StepLimitExceeded(Self::STEP_LIMIT));
}
let stepped = vm::step::<R>(
&mut self.flow,
program,
line_tables,
context,
&mut self.stats,
resolver,
)?;
self.stats.materializations += self.flow.drain_materializations();
match stepped {
vm::Stepped::Done | vm::Stepped::Ended => {
self.abort_eval(program, line_tables, resolver);
return Err(RuntimeError::FunctionYielded);
}
vm::Stepped::ExternalCall => {
if let Some(pending) =
self.resolve_eval_external(program, line_tables, resolver, handler)?
{
return Ok(pending);
}
}
vm::Stepped::Continue | vm::Stepped::ThreadCompleted => {}
}
if !self.flow.has_eval_boundary() {
let _captured = self.flow.output.end_capture(program, line_tables, resolver);
let floor = self.eval.take().map_or(0, |e| e.value_floor);
let mut ret: Option<Value> = None;
while self.flow.value_stack.len() > floor {
let v = self.flow.value_stack.pop();
if ret.is_none() {
ret = v; }
}
return Ok(FunctionEval::Returned(ret.unwrap_or(Value::Null)));
}
let choice_floor = self.eval.as_ref().map_or(0, |e| e.choice_floor);
if self.flow.pending_choices.len() > choice_floor {
self.abort_eval(program, line_tables, resolver);
return Err(RuntimeError::FunctionYielded);
}
}
}
fn resolve_eval_external(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
resolver: Option<&dyn PluralResolver>,
handler: &dyn ExternalFnHandler,
) -> Result<Option<FunctionEval>, RuntimeError> {
let fn_id = self
.flow
.external_fn_id()
.ok_or(RuntimeError::CallStackUnderflow)?;
let entry = program.external_fn(fn_id);
let fn_name = entry.map_or("?", |e| program.name(e.name));
match handler.call(fn_name, self.flow.external_args()) {
ExternalResult::Resolved(value) => {
self.flow.resolve_external(value);
Ok(None)
}
ExternalResult::Fallback => {
if let Some(fb_id) = entry.and_then(|e| e.fallback) {
let container_idx = program
.resolve_target(fb_id)
.map(|(idx, _)| idx)
.ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
self.flow.invoke_fallback(container_idx);
Ok(None)
} else {
self.abort_eval(program, line_tables, resolver);
Err(RuntimeError::UnresolvedExternalCall(fn_id))
}
}
ExternalResult::Pending => Ok(Some(FunctionEval::AwaitingExternal)),
}
}
fn abort_eval(
&mut self,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
resolver: Option<&dyn PluralResolver>,
) {
if self.eval.take().is_some() {
let _ = self.flow.output.end_capture(program, line_tables, resolver);
}
}
}
#[expect(clippy::similar_names)]
fn select_choice(
flow: &mut Flow,
context: &mut (impl ContextAccess + ?Sized),
status: &mut StoryStatus,
stats: &mut Stats,
index: usize,
) -> Result<(), RuntimeError> {
let available = flow.pending_choices.len();
if index >= available {
return Err(RuntimeError::InvalidChoiceIndex { index, available });
}
let choice = flow.pending_choices.swap_remove(index);
let target_id = choice.target_id;
context.increment_visit(target_id);
context.set_turn_count(target_id, context.turn_index());
let current = flow.current_thread_mut();
*current = choice.thread_fork;
let frame = current
.call_stack
.last_mut()
.ok_or(RuntimeError::CallStackUnderflow)?;
frame.container_stack.clear();
frame.container_stack.push(ContainerPosition {
container_idx: choice.target_idx,
offset: choice.target_offset,
});
flow.pending_choices.clear();
*status = StoryStatus::Active;
stats.choices_selected += 1;
Ok(())
}
fn resolve_external_call(
flow: &mut Flow,
program: &Program,
handler: &dyn ExternalFnHandler,
) -> Result<bool, RuntimeError> {
let fn_id = flow
.external_fn_id()
.ok_or(RuntimeError::CallStackUnderflow)?;
let entry = program.external_fn(fn_id);
let fn_name = entry.map_or("?", |e| program.name(e.name));
let result = handler.call(fn_name, flow.external_args());
match result {
ExternalResult::Resolved(value) => {
flow.resolve_external(value);
Ok(true)
}
ExternalResult::Fallback => {
let fallback_id = entry.and_then(|e| e.fallback);
if let Some(fb_id) = fallback_id {
let container_idx = program
.resolve_target(fb_id)
.map(|(idx, _)| idx)
.ok_or(RuntimeError::UnresolvedDefinition(fb_id))?;
flow.invoke_fallback(container_idx);
Ok(true)
} else {
Err(RuntimeError::UnresolvedExternalCall(fn_id))
}
}
ExternalResult::Pending => {
Ok(false)
}
}
}
fn flush_remaining(
flow: &mut Flow,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
resolver: Option<&dyn brink_format::PluralResolver>,
) -> (String, Vec<String>) {
let lines = flow.output.flush_lines(program, line_tables, resolver);
let mut text = String::new();
let mut tags = Vec::new();
for (i, (line_text, line_tags)) in lines.iter().enumerate() {
if i > 0 {
text.push('\n');
}
text.push_str(line_text);
tags.extend_from_slice(line_tags);
}
(text, tags)
}
fn make_yield_line(
status: StoryStatus,
text: String,
tags: Vec<String>,
flow: &Flow,
program: &Program,
line_tables: &[Vec<brink_format::LineEntry>],
resolver: Option<&dyn brink_format::PluralResolver>,
) -> Line {
match status {
StoryStatus::WaitingForChoice => {
let choices = flow
.pending_choices
.iter()
.enumerate()
.filter(|(_, pc)| !pc.flags.is_invisible_default)
.map(|(i, pc)| {
let display_text = match &pc.display {
ChoiceDisplay::Text(s) => s.clone(),
ChoiceDisplay::Fragment(idx) => {
flow.output
.resolve_fragment(*idx, program, line_tables, resolver)
}
};
let display_text = display_text
.trim_matches(|c: char| c == ' ' || c == '\t')
.to_string();
Choice {
text: display_text,
index: i,
tags: pc.tags.clone(),
}
})
.collect();
Line::Choices {
text,
tags,
choices,
}
}
StoryStatus::Ended => Line::End { text, tags },
StoryStatus::Done => Line::Done { text, tags },
StoryStatus::Active => Line::Text { text, tags },
}
}
pub struct Story<R: StoryRng = FastRng> {
program: Arc<Program>,
pub(crate) default: FlowInstance,
pub(crate) default_context: World,
default_local: FlowLocal,
line_tables: Vec<Vec<brink_format::LineEntry>>,
instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
shared_instances: HashMap<String, FlowInstance>,
resolver: Option<Box<dyn PluralResolver>>,
_rng: PhantomData<R>,
}
impl<R: StoryRng> Clone for Story<R> {
fn clone(&self) -> Self {
Self {
program: Arc::clone(&self.program),
default: self.default.clone(),
default_context: self.default_context.clone(),
default_local: self.default_local.clone(),
line_tables: self.line_tables.clone(),
instances: self.instances.clone(),
shared_instances: self.shared_instances.clone(),
resolver: None,
_rng: PhantomData,
}
}
}
pub struct StorySnapshot<R: StoryRng = FastRng> {
default: FlowInstance,
default_context: World,
default_local: FlowLocal,
instances: HashMap<String, (FlowInstance, World, FlowLocal)>,
_rng: PhantomData<R>,
}
impl<R: StoryRng> Story<R> {
pub fn new(program: Arc<Program>, line_tables: Vec<Vec<brink_format::LineEntry>>) -> Self {
let (default, default_context) = FlowInstance::new_at_root(&program);
Self {
program,
default,
default_context,
default_local: FlowLocal::new(),
line_tables,
instances: HashMap::new(),
shared_instances: HashMap::new(),
resolver: None,
_rng: PhantomData,
}
}
pub fn set_plural_resolver(&mut self, resolver: Box<dyn PluralResolver>) {
self.resolver = Some(resolver);
}
pub fn set_line_tables(&mut self, tables: Vec<Vec<brink_format::LineEntry>>) {
self.line_tables = tables;
}
pub fn line_tables(&self) -> &[Vec<brink_format::LineEntry>] {
&self.line_tables
}
pub fn transcript(&self) -> &[crate::output::OutputPart] {
self.default.flow.output.transcript()
}
pub fn transcript_len(&self) -> usize {
self.default.flow.output.transcript_len()
}
pub fn reset_cursor(&mut self) {
self.default.flow.output.reset_cursor();
}
pub fn resolve_transcript_slice(
&self,
range: std::ops::Range<usize>,
) -> Vec<(String, Vec<String>)> {
let transcript = self.default.flow.output.transcript();
let end = range.end.min(transcript.len());
let start = range.start.min(end);
let slice = &transcript[start..end];
let fragments = self.default.flow.output.fragments();
crate::output::resolve_lines(
slice,
&self.program,
&self.line_tables,
self.resolver.as_deref(),
fragments,
)
}
pub fn pending_choices(&self) -> Vec<Choice> {
self.resolved_choices_for(&self.default.flow)
}
fn resolved_choices_for(&self, flow: &Flow) -> Vec<Choice> {
flow.pending_choices
.iter()
.enumerate()
.filter(|(_, pc)| !pc.flags.is_invisible_default)
.map(|(i, pc)| {
let display_text = match &pc.display {
ChoiceDisplay::Text(s) => s.clone(),
ChoiceDisplay::Fragment(idx) => flow.output.resolve_fragment(
*idx,
&self.program,
&self.line_tables,
self.resolver.as_deref(),
),
};
let display_text = display_text
.trim_matches(|c: char| c == ' ' || c == '\t')
.to_string();
Choice {
text: display_text,
index: i,
tags: pc.tags.clone(),
}
})
.collect()
}
pub fn resolve_fragment(&self, idx: u32) -> String {
self.default.flow.output.resolve_fragment(
idx,
&self.program,
&self.line_tables,
self.resolver.as_deref(),
)
}
pub fn choice_fragment_idx(&self, choice_index: usize) -> Option<u32> {
self.default
.flow
.pending_choices
.get(choice_index)
.and_then(|pc| match &pc.display {
ChoiceDisplay::Fragment(idx) => Some(*idx),
ChoiceDisplay::Text(_) => None,
})
}
pub fn fragments(&self) -> &[crate::output::Fragment] {
self.default.flow.output.fragments()
}
pub fn program(&self) -> &Program {
&self.program
}
pub(crate) fn program_arc(&self) -> Arc<Program> {
Arc::clone(&self.program)
}
pub fn variable(&self, name: &str) -> Option<&Value> {
let idx = self.program.global_index(name)?;
Some(ContextAccess::global(&self.default_context, idx))
}
pub fn set_variable(&mut self, name: &str, value: Value) -> bool {
match self.program.global_index(name) {
Some(idx) => {
ContextAccess::set_global(&mut self.default_context, idx, value);
true
}
None => false,
}
}
pub fn set_rng_seed(&mut self, seed: i32) {
ContextAccess::set_rng_seed(&mut self.default_context, seed);
}
pub fn advance_with(
&mut self,
handler: &dyn ExternalFnHandler,
) -> Result<StepOutcome, RuntimeError> {
let resolver = self.resolver.as_deref();
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
self.default.advance::<R>(
&self.program,
&self.line_tables,
&mut view,
handler,
resolver,
)
}
#[must_use]
pub fn pending_external_name(&self) -> Option<&str> {
self.default.pending_external_name(&self.program)
}
#[must_use]
pub fn pending_external_args(&self) -> &[Value] {
self.default.pending_external_args()
}
pub fn call_function(
&mut self,
name: &str,
args: &[Value],
handler: &dyn ExternalFnHandler,
) -> Result<Value, RuntimeError> {
let container_idx = self
.program
.find_address(name)
.ok_or_else(|| RuntimeError::FunctionNotFound(name.to_owned()))?
.0;
let expected = self.program.container(container_idx).param_count;
if args.len() != expected as usize {
return Err(RuntimeError::ArgCountMismatch {
target: name.to_owned(),
expected,
got: args.len(),
});
}
let resolver = self.resolver.as_deref();
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
let outcome = self.default.begin_function_eval::<R>(
&self.program,
&self.line_tables,
&mut view,
handler,
container_idx,
args,
resolver,
)?;
match outcome {
FunctionEval::Returned(value) => Ok(value),
FunctionEval::AwaitingExternal => {
let name = self
.default
.pending_external_name(&self.program)
.map_or_else(|| name.to_owned(), ToOwned::to_owned);
self.default
.abort_eval(&self.program, &self.line_tables, resolver);
Err(RuntimeError::AsyncExternalInCall(name))
}
}
}
#[must_use]
pub fn speculate(&self) -> crate::Speculation<R> {
crate::Speculation::fork_from(
Arc::clone(&self.program),
&self.default_context,
&self.default_local,
&self.default,
&self.line_tables,
)
}
pub fn into_snapshot(self) -> (StorySnapshot<R>, Vec<Vec<brink_format::LineEntry>>) {
let snapshot = StorySnapshot {
default: self.default,
default_context: self.default_context,
default_local: self.default_local,
instances: self.instances,
_rng: PhantomData,
};
(snapshot, self.line_tables)
}
pub fn from_snapshot(
program: Arc<Program>,
snapshot: StorySnapshot<R>,
line_tables: Vec<Vec<brink_format::LineEntry>>,
) -> Self {
Self {
program,
default: snapshot.default,
default_context: snapshot.default_context,
default_local: snapshot.default_local,
line_tables,
instances: snapshot.instances,
shared_instances: HashMap::new(),
resolver: None,
_rng: PhantomData,
}
}
pub fn continue_single(&mut self) -> Result<Line, RuntimeError> {
let resolver = self.resolver.as_deref();
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
self.default.step_single_line::<R>(
&self.program,
&self.line_tables,
&mut view,
&FallbackHandler,
resolver,
)
}
pub fn continue_single_observed(
&mut self,
observer: &mut dyn WriteObserver,
) -> Result<Line, RuntimeError> {
use crate::state::ObservedContext;
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
let mut obs_ctx = ObservedContext::new(&mut view, observer);
let resolver = self.resolver.as_deref();
self.default.step_single_line::<R>(
&self.program,
&self.line_tables,
&mut obs_ctx,
&FallbackHandler,
resolver,
)
}
pub fn continue_single_with(
&mut self,
handler: &dyn ExternalFnHandler,
) -> Result<Line, RuntimeError> {
let resolver = self.resolver.as_deref();
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
self.default.step_single_line::<R>(
&self.program,
&self.line_tables,
&mut view,
handler,
resolver,
)
}
pub fn continue_maximally(&mut self) -> Result<Vec<Line>, RuntimeError> {
self.continue_maximally_impl(&FallbackHandler)
}
pub fn continue_maximally_with(
&mut self,
handler: &dyn ExternalFnHandler,
) -> Result<Vec<Line>, RuntimeError> {
self.continue_maximally_impl(handler)
}
fn continue_maximally_impl(
&mut self,
handler: &dyn ExternalFnHandler,
) -> Result<Vec<Line>, RuntimeError> {
let resolver = self.resolver.as_deref();
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
self.default.drive_to_terminal::<R>(
&self.program,
&self.line_tables,
&mut view,
handler,
resolver,
)
}
pub fn continue_maximally_observed(
&mut self,
observer: &mut dyn WriteObserver,
) -> Result<Vec<Line>, RuntimeError> {
use crate::state::ObservedContext;
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
let mut obs_ctx = ObservedContext::new(&mut view, observer);
let resolver = self.resolver.as_deref();
self.default.drive_to_terminal::<R>(
&self.program,
&self.line_tables,
&mut obs_ctx,
&FallbackHandler,
resolver,
)
}
pub fn choose(&mut self, index: usize) -> Result<(), RuntimeError> {
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
self.default.choose(&mut view, index)
}
pub fn choose_path_string(&mut self, path: &str) -> Result<(), RuntimeError> {
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
self.default
.choose_path_string(&self.program, &mut view, path)
}
pub fn choose_path_string_with_args(
&mut self,
path: &str,
args: &[Value],
) -> Result<(), RuntimeError> {
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
self.default
.choose_path_string_with_args(&self.program, &mut view, path, args)
}
pub fn stats(&self) -> &Stats {
&self.default.stats
}
pub fn has_pending_external(&self) -> bool {
self.default.flow.external_fn_id().is_some()
}
pub fn resolve_external(&mut self, value: Value) {
self.default.flow.resolve_external(value);
}
pub fn invoke_fallback(&mut self) -> Result<(), RuntimeError> {
let fn_id = self
.default
.flow
.external_fn_id()
.ok_or(RuntimeError::CallStackUnderflow)?;
let entry = self.program.external_fn(fn_id);
let fallback_id = entry
.and_then(|e| e.fallback)
.ok_or(RuntimeError::UnresolvedExternalCall(fn_id))?;
let container_idx = self
.program
.resolve_target(fallback_id)
.map(|(idx, _)| idx)
.ok_or(RuntimeError::UnresolvedDefinition(fallback_id))?;
self.default.flow.output.begin_capture();
self.default.flow.invoke_fallback(container_idx);
Ok(())
}
pub fn spawn_flow(
&mut self,
name: &str,
entry_point: DefinitionId,
) -> Result<(), RuntimeError> {
if self.instances.contains_key(name) {
return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
}
let container_idx = self
.program
.resolve_target(entry_point)
.map(|(idx, _)| idx)
.ok_or(RuntimeError::UnresolvedDefinition(entry_point))?;
let (flow, ctx) = FlowInstance::new_at(&self.program, container_idx);
self.instances
.insert(name.to_owned(), (flow, ctx, FlowLocal::new()));
Ok(())
}
pub fn continue_flow_maximally(&mut self, name: &str) -> Result<Vec<Line>, RuntimeError> {
self.continue_flow_maximally_with(name, &FallbackHandler)
}
pub fn continue_flow_maximally_with(
&mut self,
name: &str,
handler: &dyn ExternalFnHandler,
) -> Result<Vec<Line>, RuntimeError> {
let (instance, ctx, local) = self
.instances
.get_mut(name)
.ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
let mut view = ContextView::new(ctx, local);
let resolver = self.resolver.as_deref();
instance.drive_to_terminal::<R>(
&self.program,
&self.line_tables,
&mut view,
handler,
resolver,
)
}
pub fn choose_flow(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
let (instance, ctx, local) = self
.instances
.get_mut(name)
.ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
let mut view = ContextView::new(ctx, local);
instance.choose(&mut view, index)
}
pub fn destroy_flow(&mut self, name: &str) -> Result<(), RuntimeError> {
if self.shared_instances.remove(name).is_some() || self.instances.remove(name).is_some() {
Ok(())
} else {
Err(RuntimeError::UnknownFlow(name.to_owned()))
}
}
pub fn flow_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self
.instances
.keys()
.chain(self.shared_instances.keys())
.map(String::as_str)
.collect();
names.sort_unstable();
names
}
pub fn spawn_flow_shared(
&mut self,
name: &str,
container_idx: Option<u32>,
) -> Result<(), RuntimeError> {
if self.shared_instances.contains_key(name) || self.instances.contains_key(name) {
return Err(RuntimeError::FlowAlreadyExists(name.to_owned()));
}
let (flow, _ctx) = match container_idx {
Some(idx) => FlowInstance::new_at(&self.program, idx),
None => FlowInstance::new_at_root(&self.program),
};
self.shared_instances.insert(name.to_owned(), flow);
Ok(())
}
pub fn continue_flow_single(&mut self, name: &str) -> Result<Line, RuntimeError> {
self.continue_flow_single_with(name, &FallbackHandler)
}
pub fn continue_flow_single_with(
&mut self,
name: &str,
handler: &dyn ExternalFnHandler,
) -> Result<Line, RuntimeError> {
let resolver = self.resolver.as_deref();
let instance = self
.shared_instances
.get_mut(name)
.ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
instance.step_single_line::<R>(
&self.program,
&self.line_tables,
&mut view,
handler,
resolver,
)
}
pub fn choose_flow_shared(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
let instance = self
.shared_instances
.get_mut(name)
.ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
let mut view = ContextView::new(&mut self.default_context, &mut self.default_local);
instance.choose(&mut view, index)
}
#[must_use]
pub fn debug_snapshot(&self) -> crate::DebugSnapshot {
self.build_debug_snapshot(&self.default, &self.default_context)
}
pub fn debug_snapshot_flow(&self, name: &str) -> Result<crate::DebugSnapshot, RuntimeError> {
if let Some(instance) = self.shared_instances.get(name) {
Ok(self.build_debug_snapshot(instance, &self.default_context))
} else if let Some((instance, ctx, _local)) = self.instances.get(name) {
Ok(self.build_debug_snapshot(instance, ctx))
} else {
Err(RuntimeError::UnknownFlow(name.to_owned()))
}
}
fn build_debug_snapshot(&self, instance: &FlowInstance, ctx: &World) -> crate::DebugSnapshot {
use crate::debug::{
DebugChoice, DebugFrame, DebugGlobal, DebugRng, DebugSnapshot, DebugVisit, NameResolver,
};
let flow = &instance.flow;
let resolver = NameResolver::new(&self.program);
let status = match instance.status {
StoryStatus::Active => "active",
StoryStatus::WaitingForChoice => "waiting_for_choice",
StoryStatus::Done => "done",
StoryStatus::Ended => "ended",
};
let thread = flow.current_thread();
let resolve_frame_location = |frame: &CallFrame| {
frame
.container_stack
.iter()
.rev()
.find_map(|cp| resolver.container_path(cp.container_idx))
.map(str::to_owned)
};
let current_location = thread.call_stack.last().and_then(resolve_frame_location);
let globals = ctx
.globals
.iter()
.enumerate()
.filter_map(|(i, value)| {
self.program.global_slot_name(i).map(|name| DebugGlobal {
name: name.to_owned(),
value: resolver.format_value(value),
})
})
.collect();
let depth = thread.call_stack.len();
let mut call_stack = Vec::with_capacity(depth);
for i in (0..depth).rev() {
if let Some(frame) = thread.call_stack.get(i) {
let kind = match frame.frame_type {
CallFrameType::Root => "root",
CallFrameType::Function => "function",
CallFrameType::Tunnel => "tunnel",
CallFrameType::Thread => "thread",
CallFrameType::External => "external",
CallFrameType::FunctionEvalFromGame => "eval",
};
call_stack.push(DebugFrame {
kind,
location: resolve_frame_location(frame),
temps: frame.temps.len(),
});
}
}
let mut visit_counts: Vec<DebugVisit> = ctx
.visit_counts
.iter()
.filter_map(|(id, &count)| {
resolver.def_path(*id).map(|path| DebugVisit {
path: path.to_owned(),
count,
})
})
.collect();
visit_counts.sort_by(|a, b| a.path.cmp(&b.path));
let visible_targets: Vec<DefinitionId> = flow
.pending_choices
.iter()
.filter(|pc| !pc.flags.is_invisible_default)
.map(|pc| pc.target_id)
.collect();
let pending_choices = self
.resolved_choices_for(flow)
.into_iter()
.enumerate()
.map(|(i, ch)| DebugChoice {
text: ch.text,
target: visible_targets
.get(i)
.and_then(|id| resolver.def_path(*id))
.map(str::to_owned),
index: ch.index,
})
.collect();
DebugSnapshot {
status,
current_location,
turn_index: ctx.turn_index,
globals,
call_stack,
visit_counts,
pending_choices,
rng: DebugRng {
seed: ctx.rng_seed,
previous: ctx.previous_random,
},
}
}
pub(crate) fn status_is_active(&self) -> bool {
self.default.status == StoryStatus::Active
}
pub(crate) fn status_is_waiting_for_choice(&self) -> bool {
self.default.status == StoryStatus::WaitingForChoice
}
pub(crate) fn state_snapshot(&self) -> crate::session::StateSnapshot {
use std::collections::BTreeMap;
use crate::debug::NameResolver;
use crate::session::{SnapshotFrame, SnapshotList, StateSnapshot};
let flow = &self.default.flow;
let ctx = &self.default_context;
let resolver = NameResolver::new(&self.program);
let mut globals: BTreeMap<String, Value> = BTreeMap::new();
let mut lists: BTreeMap<String, SnapshotList> = BTreeMap::new();
for (i, value) in ctx.globals.iter().enumerate() {
if let Some(name) = self.program.global_slot_name(i) {
if let Value::List(list) = value {
let mut items: Vec<String> = list
.items
.iter()
.filter_map(|id| self.program.list_item_name(*id).map(str::to_owned))
.collect();
items.sort_unstable();
lists.insert(name.to_owned(), SnapshotList { items });
}
globals.insert(name.to_owned(), value.clone());
}
}
let mut visit_counts: BTreeMap<String, u32> = BTreeMap::new();
for (id, &count) in &ctx.visit_counts {
if let Some(path) = resolver.def_path(*id) {
visit_counts.insert(path.to_owned(), count);
}
}
let mut turn_counts: BTreeMap<String, u32> = BTreeMap::new();
for (id, &count) in &ctx.turn_counts {
if let Some(path) = resolver.def_path(*id) {
turn_counts.insert(path.to_owned(), count);
}
}
let resolve_frame_location = |frame: &CallFrame| {
frame
.container_stack
.iter()
.rev()
.find_map(|cp| resolver.container_path(cp.container_idx))
.map(str::to_owned)
};
let thread = flow.current_thread();
let depth = thread.call_stack.len();
let mut call_stack = Vec::with_capacity(depth);
for i in (0..depth).rev() {
if let Some(frame) = thread.call_stack.get(i) {
let kind = match frame.frame_type {
CallFrameType::Root => "root",
CallFrameType::Function => "function",
CallFrameType::Tunnel => "tunnel",
CallFrameType::Thread => "thread",
CallFrameType::External => "external",
CallFrameType::FunctionEvalFromGame => "eval",
};
call_stack.push(SnapshotFrame {
kind: kind.to_owned(),
location: resolve_frame_location(frame),
temps: frame.temps.len(),
});
}
}
StateSnapshot {
globals,
lists,
turn_index: ctx.turn_index,
visit_counts,
turn_counts,
call_stack,
status: self.default.status.into(),
}
}
#[cfg(feature = "testing")]
pub fn debug_state(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let flow = &self.default.flow;
let ctx = &self.default_context;
let _ = writeln!(out, "=== Story Debug State ===");
let _ = writeln!(out, "status: {:?}", self.default.status);
let thread = flow.current_thread();
if let Some(frame) = thread.call_stack.last()
&& let Some(cp) = frame.container_stack.last()
{
let id = self.program.container(cp.container_idx).id;
let _ = writeln!(
out,
"position: container_idx={} id={id:?} offset={}",
cp.container_idx, cp.offset,
);
}
let depth = thread.call_stack.len();
let _ = writeln!(out, "\ncall stack ({depth} frames):");
for i in 0..depth {
if let Some(frame) = thread.call_stack.get(i) {
let ret = frame
.return_address
.map(|r| format!("idx={} off={}", r.container_idx, r.offset));
let _ = writeln!(
out,
" [{i}] {:?} ret={} temps={} containers={}",
frame.frame_type,
ret.as_deref().unwrap_or("none"),
frame.temps.len(),
frame.container_stack.len(),
);
for (j, cp) in frame.container_stack.iter().enumerate() {
let id = self.program.container(cp.container_idx).id;
let _ = writeln!(
out,
" container_stack[{j}]: idx={} id={id:?} off={}",
cp.container_idx, cp.offset,
);
}
}
}
let _ = writeln!(out, "\nvalue stack ({}):", flow.value_stack.len());
for (i, v) in flow.value_stack.iter().enumerate() {
let _ = writeln!(out, " [{i}] {v:?}");
}
let unread_start = flow.output.cursor;
let transcript = &flow.output.transcript[unread_start..];
let _ = writeln!(
out,
"\noutput buffer (cursor={unread_start}, {} unread parts):",
transcript.len(),
);
for (i, part) in transcript.iter().enumerate() {
let _ = writeln!(out, " [{i}] {part:?}");
}
let _ = writeln!(out, "\nglobals:");
for (i, v) in ctx.globals.iter().enumerate() {
#[expect(clippy::cast_possible_truncation, reason = "global count fits in u32")]
if let Some(name) = self.program.global_name(i as u32) {
let _ = writeln!(out, " {name} = {v:?}");
}
}
let _ = writeln!(out, "\nskipping_choice: {}", flow.skipping_choice);
let _ = writeln!(out, "\npending choices ({}):", flow.pending_choices.len());
for (i, c) in flow.pending_choices.iter().enumerate() {
let _ = writeln!(out, " [{i}] {:?} -> {:?}", c.display, c.target_id);
}
out
}
#[cfg(feature = "testing")]
pub fn did_safe_exit(&self) -> bool {
self.default.flow.did_safe_exit
}
#[cfg(feature = "testing")]
pub fn did_unsafe_yield(&self) -> bool {
self.default.flow.did_unsafe_yield
}
#[cfg(feature = "testing")]
pub fn step_once(&mut self) -> Result<Option<(String, u32, usize)>, RuntimeError> {
use brink_format::Opcode;
let flow = &self.default.flow;
let thread = flow.current_thread();
let pre_info = thread.call_stack.last().and_then(|frame| {
frame.container_stack.last().map(|pos| {
let container = self.program.container(pos.container_idx);
if pos.offset < container.bytecode.len() {
let mut off = pos.offset;
let op = Opcode::decode(&container.bytecode, &mut off).ok();
(pos.container_idx, pos.offset, op)
} else {
(pos.container_idx, pos.offset, None)
}
})
});
let _result = vm::step::<R>(
&mut self.default.flow,
&self.program,
&self.line_tables,
&mut self.default_context,
&mut self.default.stats,
self.resolver.as_deref(),
)?;
match pre_info {
Some((ci, off, Some(op))) => Ok(Some((format!("{op:?}"), ci, off))),
Some((ci, off, None)) => Ok(Some(("(end of container)".to_string(), ci, off))),
None => Ok(None),
}
}
}
#[cfg(test)]
#[expect(clippy::panic)]
mod tests {
use super::*;
use crate::link;
fn load_i079_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
let json_str = std::fs::read_to_string(
"../../tests/tier1/choices/I079-once-only-choices-can-link-back-to-self/story.ink.json",
)
.unwrap();
let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
let data = brink_converter::convert(&ink).unwrap();
link(&data).unwrap()
}
fn step_until_choices(story: &mut Story) -> Vec<Choice> {
loop {
match story.continue_single().unwrap() {
Line::Choices { choices, .. } => return choices,
Line::Text { .. } => {}
Line::Done { .. } => panic!("story hit Done before presenting choices"),
Line::End { .. } => panic!("story ended before presenting choices"),
}
}
}
fn step_until_choices_or_end(story: &mut Story) -> String {
let mut text = String::new();
loop {
match story.continue_single().unwrap() {
Line::Choices { text: t, .. }
| Line::Done { text: t, .. }
| Line::End { text: t, .. } => {
text.push_str(&t);
return text;
}
Line::Text { text: t, .. } => text.push_str(&t),
}
}
}
#[test]
fn select_choice_increments_visit_count_for_target() {
let (program, line_tables) = load_i079_program();
let mut story = Story::new(Arc::new(program), line_tables);
let choices = step_until_choices(&mut story);
assert!(!choices.is_empty(), "expected at least one choice");
let target_id = story.default.flow.pending_choices[0].target_id;
let visit_before = story
.default_context
.visit_counts
.get(&target_id)
.copied()
.unwrap_or(0);
story.choose(0).unwrap();
let visit_after = story
.default_context
.visit_counts
.get(&target_id)
.copied()
.unwrap_or(0);
assert!(
visit_after > visit_before,
"visit count for choice target should increment after selection: \
before={visit_before}, after={visit_after}"
);
}
fn story_from_source(src: &str) -> Story {
let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
let mut bytes = Vec::new();
brink_format::write_inkb(&out.data, &mut bytes);
let data = brink_format::read_inkb(&bytes).expect("decode");
let (prog, tables) = link(&data).expect("link");
Story::new(Arc::new(prog), tables)
}
#[test]
fn choice_index_is_raw_pending_choices_position_with_invisible_default_mixed_in() {
let src = "-(start)\n\
* [First] -> a\n\
* -> b\n\
* [Third] -> c\n\
-(a) Went A.\n-> DONE\n\
-(b) Went B.\n-> DONE\n\
-(c) Went C.\n-> DONE\n";
let mut story = story_from_source(src);
let choices = step_until_choices(&mut story);
assert_eq!(
choices.iter().map(|c| c.index).collect::<Vec<_>>(),
vec![0, 2],
"visible choice indices must be the raw pending_choices positions, not 0,1,..: {choices:?}"
);
assert_eq!(story.default.flow.pending_choices.len(), 3);
story.choose(choices[1].index).expect("choose by raw index");
let text = step_until_choices_or_end(&mut story);
assert!(text.contains("Went C"), "expected the Third branch: {text}");
}
#[test]
fn debug_snapshot_choice_index_matches_live_choice_index() {
let src = "-(start)\n\
* [First] -> a\n\
* -> b\n\
* [Third] -> c\n\
-(a) Went A.\n-> DONE\n\
-(b) Went B.\n-> DONE\n\
-(c) Went C.\n-> DONE\n";
let mut story = story_from_source(src);
let live_choices = step_until_choices(&mut story);
let snap = story.debug_snapshot();
assert_eq!(snap.pending_choices.len(), live_choices.len());
for (live, dbg) in live_choices.iter().zip(snap.pending_choices.iter()) {
assert_eq!(
dbg.index, live.index,
"DebugChoice.index must match the live Choice.index"
);
}
}
#[test]
fn once_only_choice_excluded_on_second_pass() {
let (program, line_tables) = load_i079_program();
let mut story = Story::new(Arc::new(program), line_tables);
let first_choices = step_until_choices(&mut story);
assert!(
first_choices
.iter()
.any(|c| c.text.contains("First choice")),
"first pass should contain 'First choice', got: {first_choices:?}"
);
story.choose(0).unwrap();
let second_choices = step_until_choices(&mut story);
assert!(
!second_choices
.iter()
.any(|c| c.text.contains("First choice")),
"second pass should NOT contain 'First choice' (once-only, already visited), \
got: {second_choices:?}"
);
}
fn load_i083_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
let json_str = std::fs::read_to_string(
"../../tests/tier1/choices/I083-choice-thread-forking/story.ink.json",
)
.unwrap();
let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
let data = brink_converter::convert(&ink).unwrap();
link(&data).unwrap()
}
#[test]
fn pending_choice_captures_tunnel_call_stack() {
let (program, line_tables) = load_i083_program();
let mut story = Story::new(Arc::new(program), line_tables);
let _choices = step_until_choices(&mut story);
let current_thread = story.default.flow.current_thread();
assert_eq!(
current_thread.call_stack.len(),
1,
"live call stack should be 1 frame (root) after tunnel return"
);
assert!(!story.default.flow.pending_choices.is_empty());
let fork = &story.default.flow.pending_choices[0].thread_fork;
assert!(
fork.call_stack.len() >= 2,
"choice fork should have >= 2 frames (root + tunnel), got {}",
fork.call_stack.len()
);
}
#[test]
fn select_choice_restores_tunnel_frame_with_temps() {
let (program, line_tables) = load_i083_program();
let mut story = Story::new(Arc::new(program), line_tables);
let _choices = step_until_choices(&mut story);
assert_eq!(story.default.flow.current_thread().call_stack.len(), 1);
story.choose(0).unwrap();
let call_stack = &story.default.flow.current_thread().call_stack;
assert!(
call_stack.len() >= 2,
"call stack should be restored to tunnel depth after choice selection, \
got {} frame(s)",
call_stack.len()
);
let tunnel_frame = call_stack.last().unwrap();
assert!(
!tunnel_frame.temps.is_empty(),
"tunnel frame should have temp variables"
);
assert_eq!(
tunnel_frame.temps[0],
Value::Int(1),
"tunnel frame temps[0] should be Int(1) (the parameter x)"
);
}
fn load_tags_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
let json_str =
std::fs::read_to_string("../../tests/tier3/tags/tags/story.ink.json").unwrap();
let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
let data = brink_converter::convert(&ink).unwrap();
link(&data).unwrap()
}
fn load_tags_in_choice_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
let json_str =
std::fs::read_to_string("../../tests/tier3/tags/tagsInChoice/story.ink.json").unwrap();
let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
let data = brink_converter::convert(&ink).unwrap();
link(&data).unwrap()
}
#[test]
fn line_exposes_tags() {
let (program, line_tables) = load_tags_program();
let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
let lines = story.continue_maximally().unwrap();
let first = lines.first().expect("expected at least one line");
assert!(
!matches!(first, Line::Choices { .. }),
"expected Text or End, got Choices"
);
assert_eq!(first.tags(), &["author: Joe", "title: My Great Story"],);
}
#[test]
fn choice_exposes_tags() {
let (program, line_tables) = load_tags_in_choice_program();
let mut story = Story::new(Arc::new(program), line_tables);
let choices = step_until_choices(&mut story);
assert!(!choices.is_empty());
assert!(
!choices[0].tags.is_empty(),
"choice should have tags, got: {choices:?}"
);
}
fn load_i091_program() -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
let json_str =
std::fs::read_to_string("../../tests/tier1/choices/I091-choice-count/story.ink.json")
.unwrap();
let ink: brink_json::InkJson = serde_json::from_str(&json_str).unwrap();
let data = brink_converter::convert(&ink).unwrap();
link(&data).unwrap()
}
#[test]
fn thread_call_returns_to_main_flow() {
let (program, line_tables) = load_i091_program();
let mut story = Story::<crate::FastRng>::new(Arc::new(program), line_tables);
let lines = story.continue_maximally().unwrap();
let full_text: String = lines.iter().map(Line::text).collect();
assert!(
full_text.starts_with('2'),
"output should start with '2' from CHOICE_COUNT(), got: {full_text:?}"
);
let last = lines.last().expect("expected at least one line");
match last {
Line::Choices { choices, .. } => {
assert_eq!(choices.len(), 2, "expected 2 choices");
}
other => panic!("expected Choices, got {other:?}"),
}
}
fn compile_source_for_flow(src: &str) -> (crate::Program, Vec<Vec<brink_format::LineEntry>>) {
let out = brink_compiler::compile("main.ink", |_p| Ok(src.to_owned())).expect("compiles");
let mut bytes = Vec::new();
brink_format::write_inkb(&out.data, &mut bytes);
let data = brink_format::read_inkb(&bytes).expect("decode");
link(&data).expect("link")
}
#[test]
fn drive_to_terminal_stops_at_done() {
let (program, tables) = compile_source_for_flow("Hello.\n-> DONE\n");
let (mut flow, mut world) = FlowInstance::new_at_root(&program);
let mut local = FlowLocal::new();
let mut view = ContextView::new(&mut world, &mut local);
let lines = flow
.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
.expect("drive succeeds");
let (last, rest) = lines.split_last().expect("at least one line");
assert!(
matches!(last, Line::Done { .. }),
"expected Done, got {last:?}"
);
assert!(
rest.iter().all(|l| matches!(l, Line::Text { .. })),
"every line before the terminal one should be Text, got {rest:?}"
);
}
#[test]
fn drive_to_terminal_stops_at_choices() {
let (program, tables) =
compile_source_for_flow("Hello.\n* Pick me\n Picked.\n -> DONE\n");
let (mut flow, mut world) = FlowInstance::new_at_root(&program);
let mut local = FlowLocal::new();
let mut view = ContextView::new(&mut world, &mut local);
let lines = flow
.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
.expect("drive succeeds");
let (last, rest) = lines.split_last().expect("at least one line");
assert!(
matches!(last, Line::Choices { .. }),
"expected Choices, got {last:?}"
);
assert!(
rest.iter().all(|l| matches!(l, Line::Text { .. })),
"every line before the terminal one should be Text, got {rest:?}"
);
}
#[test]
fn drive_to_terminal_stops_at_end() {
let (program, tables) = compile_source_for_flow("Hello.\n-> END\n");
let (mut flow, mut world) = FlowInstance::new_at_root(&program);
let mut local = FlowLocal::new();
let mut view = ContextView::new(&mut world, &mut local);
let lines = flow
.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
.expect("drive succeeds");
let (last, rest) = lines.split_last().expect("at least one line");
assert!(
matches!(last, Line::End { .. }),
"expected End, got {last:?}"
);
assert!(
rest.iter().all(|l| matches!(l, Line::Text { .. })),
"every line before the terminal one should be Text, got {rest:?}"
);
}
#[test]
fn drive_to_terminal_errors_at_line_limit() {
let (program, tables) =
compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
let (mut flow, mut world) = FlowInstance::new_at_root(&program);
let mut local = FlowLocal::new();
let mut view = ContextView::new(&mut world, &mut local);
let err = flow
.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
.expect_err("infinite content should hit the line limit rather than hang");
match err {
RuntimeError::LineLimitExceeded(n) => {
assert_eq!(n, FlowInstance::LINE_LIMIT);
}
other => panic!("expected LineLimitExceeded, got {other:?}"),
}
}
struct DeferOnce {
deferred: std::cell::Cell<bool>,
}
impl ExternalFnHandler for DeferOnce {
fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
if name == "pause_once" && !self.deferred.get() {
self.deferred.set(true);
ExternalResult::Pending
} else {
ExternalResult::Resolved(Value::Int(2))
}
}
}
#[test]
fn drive_pauses_on_awaiting_external_then_resumes() {
let (program, tables) = compile_source_for_flow(
"EXTERNAL pause_once(x)\nHello.\nWorld.\nValue: {pause_once(1)}.\n-> DONE\n",
);
let (mut flow, mut world) = FlowInstance::new_at_root(&program);
let mut local = FlowLocal::new();
let mut view = ContextView::new(&mut world, &mut local);
let handler = DeferOnce {
deferred: std::cell::Cell::new(false),
};
let mut budget = 10usize;
let outcome = flow
.drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
.expect("first drive call succeeds");
let paused_lines = match outcome {
DriveOutcome::AwaitingExternal(lines) => lines,
other @ DriveOutcome::Terminal(_) => panic!("expected AwaitingExternal, got {other:?}"),
};
let paused_text: String = paused_lines.iter().map(Line::text).collect();
assert!(
paused_text.contains("Hello"),
"text produced before the pause should include 'Hello.'; got {paused_text:?}"
);
assert!(
!paused_text.contains("Value"),
"the line calling the deferred external should not have completed yet; got {paused_text:?}"
);
assert_eq!(
budget,
10 - paused_lines.len(),
"budget should decrement by exactly the lines produced before the pause"
);
flow.resolve_external(Value::Int(2));
let outcome = flow
.drive::<FastRng>(&program, &tables, &mut view, &handler, None, &mut budget)
.expect("second drive call resumes and completes");
let resumed_lines = match outcome {
DriveOutcome::Terminal(lines) => lines,
other @ DriveOutcome::AwaitingExternal(_) => panic!("expected Terminal, got {other:?}"),
};
let resumed_text: String = resumed_lines.iter().map(Line::text).collect();
assert!(
resumed_text.contains("Value: 2"),
"the resolved external's value should be inlined; got {resumed_text:?}"
);
assert!(
matches!(resumed_lines.last(), Some(Line::Done { .. })),
"expected the drive to finish at Done, got {resumed_lines:?}"
);
assert_eq!(
budget,
10 - paused_lines.len() - resumed_lines.len(),
"budget must keep decrementing across the resume — not reset to a fresh cap \
(the whole point of the caller-owned budget: one bound per logical drive, not \
per resume)"
);
}
#[test]
fn drive_errors_when_caller_budget_exhausted() {
let (program, tables) =
compile_source_for_flow("-> spam\n\n=== spam ===\nLine.\n-> spam\n");
let (mut flow, mut world) = FlowInstance::new_at_root(&program);
let mut local = FlowLocal::new();
let mut view = ContextView::new(&mut world, &mut local);
let mut budget = 3usize;
let err = flow
.drive::<FastRng>(
&program,
&tables,
&mut view,
&FallbackHandler,
None,
&mut budget,
)
.expect_err("infinite content should hit the caller's small budget");
match err {
RuntimeError::LineLimitExceeded(n) => assert_eq!(
n, 3,
"reported limit should be the caller's budget, not the unrelated LINE_LIMIT constant"
),
other => panic!("expected LineLimitExceeded, got {other:?}"),
}
assert_eq!(budget, 0, "budget should be fully consumed, not partially");
}
#[test]
fn free_fn_save_load_roundtrip_without_story() {
let (program, tables) = compile_source_for_flow(
"VAR gold = 0\n\
-> shrine\n\
=== shrine ===\n\
~ gold = 5\n\
Shrine text.\n\
-> DONE\n\
=== reader ===\n\
{READ_COUNT(-> shrine)}\n\
-> DONE\n",
);
let (mut flow, mut world) = FlowInstance::new_at_root(&program);
let mut local = FlowLocal::new();
{
let mut view = ContextView::new(&mut world, &mut local);
flow.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
.expect("drive succeeds");
}
let gold_slot = program.global_index("gold").expect("gold declared");
let shrine_id = program.find_path_target("shrine").expect("shrine exists");
let save = {
let view = ContextView::new(&mut world, &mut local);
crate::save_state(&program, &view)
};
assert_eq!(save.globals.get("gold"), Some(&Value::Int(5)));
assert_eq!(
save.visits
.iter()
.find(|e| e.id == shrine_id)
.map(|e| e.count),
Some(1),
"shrine should have a captured visit entry"
);
{
let mut view = ContextView::new(&mut world, &mut local);
view.set_global(gold_slot, Value::Int(999));
view.set_visit_count(shrine_id, 42);
}
{
let view = ContextView::new(&mut world, &mut local);
assert_eq!(view.global(gold_slot), &Value::Int(999));
assert_eq!(view.visit_count(shrine_id), 42);
}
let report = {
let mut view = ContextView::new(&mut world, &mut local);
crate::load_state(&program, &mut view, &save)
};
assert!(report.unknown_globals.is_empty(), "clean load: {report:?}");
let view = ContextView::new(&mut world, &mut local);
assert_eq!(view.global(gold_slot), &Value::Int(5));
assert_eq!(view.visit_count(shrine_id), 1);
}
}