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;
#[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,
}
#[derive(Debug, Clone)]
pub struct Context {
pub globals: Vec<Value>,
pub visit_counts: HashMap<DefinitionId, u32>,
pub turn_counts: HashMap<DefinitionId, u32>,
pub turn_index: u32,
pub rng_seed: i32,
pub previous_random: i32,
}
impl Context {
pub fn global(&self, idx: u32) -> &Value {
&self.globals[idx as usize]
}
pub fn set_global(&mut self, idx: u32, value: Value) {
self.globals[idx as usize] = value;
}
pub fn visit_count(&self, id: DefinitionId) -> u32 {
self.visit_counts.get(&id).copied().unwrap_or(0)
}
pub fn increment_visit(&mut self, id: DefinitionId) {
*self.visit_counts.entry(id).or_insert(0) += 1;
}
pub fn turn_count(&self, id: DefinitionId) -> Option<u32> {
self.turn_counts.get(&id).copied()
}
pub fn set_turn_count(&mut self, id: DefinitionId, turn: u32) {
self.turn_counts.insert(id, turn);
}
pub fn turn_index(&self) -> u32 {
self.turn_index
}
pub fn increment_turn_index(&mut self) {
self.turn_index += 1;
}
pub fn rng_seed(&self) -> i32 {
self.rng_seed
}
pub fn set_rng_seed(&mut self, seed: i32) {
self.rng_seed = seed;
}
pub fn previous_random(&self) -> i32 {
self.previous_random
}
pub fn set_previous_random(&mut self, val: i32) {
self.previous_random = val;
}
pub fn next_random<R: StoryRng>(seed: i32) -> i32 {
let mut rng = R::from_seed(seed);
rng.next_int()
}
pub fn random_sequence<R: StoryRng>(seed: i32, count: usize) -> Vec<i32> {
let mut rng = R::from_seed(seed);
(0..count).map(|_| rng.next_int()).collect()
}
}
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,
}
impl FlowInstance {
pub fn new_at_root(program: &Program) -> (Self, Context) {
Self::new_at(program, program.root_idx())
}
pub fn new_at(program: &Program, container_idx: u32) -> (Self, Context) {
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 context = Context {
globals,
visit_counts: HashMap::new(),
turn_counts: HashMap::new(),
turn_index: 0,
rng_seed: 0,
previous_random: 0,
};
(flow_instance, context)
}
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))
}
}
}
#[expect(clippy::too_many_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> {
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 > Self::STEP_LIMIT {
return Err(RuntimeError::StepLimitExceeded(Self::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<'p, R: StoryRng = FastRng> {
program: &'p Program,
pub(crate) default: FlowInstance,
pub(crate) default_context: Context,
line_tables: Vec<Vec<brink_format::LineEntry>>,
instances: HashMap<String, (FlowInstance, Context)>,
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: self.program,
default: self.default.clone(),
default_context: self.default_context.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: Context,
instances: HashMap<String, (FlowInstance, Context)>,
_rng: PhantomData<R>,
}
impl<'p, R: StoryRng> Story<'p, R> {
pub fn new(program: &'p Program, line_tables: Vec<Vec<brink_format::LineEntry>>) -> Self {
let (default, default_context) = FlowInstance::new_at_root(program);
Self {
program,
default,
default_context,
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 fn variable(&self, name: &str) -> Option<&Value> {
let idx = self.program.global_index(name)?;
Some(self.default_context.global(idx))
}
pub fn set_variable(&mut self, name: &str, value: Value) -> bool {
match self.program.global_index(name) {
Some(idx) => {
self.default_context.set_global(idx, value);
true
}
None => false,
}
}
pub fn set_rng_seed(&mut self, seed: i32) {
self.default_context.set_rng_seed(seed);
}
pub fn advance_with(
&mut self,
handler: &dyn ExternalFnHandler,
) -> Result<StepOutcome, RuntimeError> {
let resolver = self.resolver.as_deref();
self.default.advance::<R>(
self.program,
&self.line_tables,
&mut self.default_context,
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 outcome = self.default.begin_function_eval::<R>(
self.program,
&self.line_tables,
&mut self.default_context,
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))
}
}
}
pub fn into_snapshot(self) -> (StorySnapshot<R>, Vec<Vec<brink_format::LineEntry>>) {
let snapshot = StorySnapshot {
default: self.default,
default_context: self.default_context,
instances: self.instances,
_rng: PhantomData,
};
(snapshot, self.line_tables)
}
pub fn from_snapshot(
program: &'p Program,
snapshot: StorySnapshot<R>,
line_tables: Vec<Vec<brink_format::LineEntry>>,
) -> Self {
Self {
program,
default: snapshot.default,
default_context: snapshot.default_context,
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();
self.default.step_single_line::<R>(
self.program,
&self.line_tables,
&mut self.default_context,
&FallbackHandler,
resolver,
)
}
pub fn continue_single_observed(
&mut self,
observer: &mut dyn WriteObserver,
) -> Result<Line, RuntimeError> {
use crate::state::ObservedContext;
let mut obs_ctx = ObservedContext::new(&mut self.default_context, 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();
self.default.step_single_line::<R>(
self.program,
&self.line_tables,
&mut self.default_context,
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)
}
const LINE_LIMIT: usize = 10_000;
fn continue_maximally_impl(
&mut self,
handler: &dyn ExternalFnHandler,
) -> Result<Vec<Line>, RuntimeError> {
let mut lines = Vec::new();
loop {
let resolver = self.resolver.as_deref();
let line = self.default.step_single_line::<R>(
self.program,
&self.line_tables,
&mut self.default_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 continue_maximally_observed(
&mut self,
observer: &mut dyn WriteObserver,
) -> Result<Vec<Line>, RuntimeError> {
use crate::state::ObservedContext;
let mut obs_ctx = ObservedContext::new(&mut self.default_context, observer);
let mut lines = Vec::new();
loop {
let resolver = self.resolver.as_deref();
let line = self.default.step_single_line::<R>(
self.program,
&self.line_tables,
&mut obs_ctx,
&FallbackHandler,
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 choose(&mut self, index: usize) -> Result<(), RuntimeError> {
self.default.choose(&mut self.default_context, index)
}
pub fn choose_path_string(&mut self, path: &str) -> Result<(), RuntimeError> {
self.default
.choose_path_string(self.program, &mut self.default_context, path)
}
pub fn choose_path_string_with_args(
&mut self,
path: &str,
args: &[Value],
) -> Result<(), RuntimeError> {
self.default.choose_path_string_with_args(
self.program,
&mut self.default_context,
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));
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) = self
.instances
.get_mut(name)
.ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
let mut lines = Vec::new();
loop {
let resolver = self.resolver.as_deref();
let line = instance.step_single_line::<R>(
self.program,
&self.line_tables,
ctx,
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 choose_flow(&mut self, name: &str, index: usize) -> Result<(), RuntimeError> {
let (instance, ctx) = self
.instances
.get_mut(name)
.ok_or_else(|| RuntimeError::UnknownFlow(name.to_owned()))?;
instance.choose(ctx, 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()))?;
instance.step_single_line::<R>(
self.program,
&self.line_tables,
&mut self.default_context,
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()))?;
instance.choose(&mut self.default_context, 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)) = 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: &Context) -> 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),
})
.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,
},
}
}
#[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"),
}
}
}
#[test]
fn select_choice_increments_visit_count_for_target() {
let (program, line_tables) = load_i079_program();
let mut story = Story::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}"
);
}
#[test]
fn once_only_choice_excluded_on_second_pass() {
let (program, line_tables) = load_i079_program();
let mut story = Story::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(&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(&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(&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(&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(&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:?}"),
}
}
}