brink-runtime 0.0.7

Runtime/VM for executing compiled ink stories
Documentation
//! Shared-context flows (#200): a named flow that shares `default_context`
//! (globals / visit counts / rng) with the default flow, while keeping its own
//! call stack. Drives the studio's "+ new flow" feature.

#![expect(clippy::unwrap_used, clippy::panic)]

use brink_converter::convert;
use brink_json::InkJson;
use brink_runtime::{DotNetRng, Line, Story};

fn story_from(case: &str) -> (brink_runtime::Program, Vec<Vec<brink_format::LineEntry>>) {
    let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../tests/tier1")
        .join(case)
        .join("story.ink.json");
    let json = std::fs::read_to_string(&path).unwrap();
    let ink: InkJson = serde_json::from_str(&json).unwrap();
    let data = convert(&ink).unwrap();
    brink_runtime::link(&data).unwrap()
}

/// Run a shared flow to a terminal line.
fn run_flow(story: &mut Story<'_, DotNetRng>, name: &str) {
    for _ in 0..1000 {
        match story.continue_flow_single(name).unwrap() {
            Line::Text { .. } => {}
            Line::Done { .. } | Line::End { .. } | Line::Choices { .. } => return,
        }
    }
    panic!("flow did not terminate");
}

#[test]
fn shared_flow_writes_are_visible_in_the_default_context() {
    let (program, line_tables) = story_from("knots/knot-stitch-gather-counts");
    let mut story = Story::<DotNetRng>::new(&program, line_tables);

    // Spawn a shared flow at the root and run it — WITHOUT advancing the
    // default flow at all.
    story.spawn_flow_shared("b", None).unwrap();
    run_flow(&mut story, "b");

    // The default flow never ran, yet its context records the shared flow's
    // visits — proving the context (visit counts) is shared (#200).
    let default_snapshot = story.debug_snapshot();
    assert!(
        !default_snapshot.visit_counts.is_empty(),
        "shared flow's visits must appear in the default (shared) context",
    );

    // The two flows are nonetheless distinct: the shared flow has reached a
    // terminal status while the default flow is still untouched.
    let flow_snapshot = story.debug_snapshot_flow("b").unwrap();
    assert_ne!(
        flow_snapshot.status, "active",
        "the shared flow ran to a terminal status",
    );
    assert_eq!(
        default_snapshot.status, "active",
        "the default flow was never advanced",
    );
}

#[test]
fn shared_flows_are_listed_and_destroyable() {
    let (program, line_tables) = story_from("knots/knot-stitch-gather-counts");
    let mut story = Story::<DotNetRng>::new(&program, line_tables);

    story.spawn_flow_shared("alpha", None).unwrap();
    story.spawn_flow_shared("beta", None).unwrap();
    assert_eq!(story.flow_names(), vec!["alpha", "beta"]); // sorted, deterministic

    // Re-spawning the same name errors.
    assert!(story.spawn_flow_shared("alpha", None).is_err());

    story.destroy_flow("alpha").unwrap();
    assert_eq!(story.flow_names(), vec!["beta"]);
    assert!(story.debug_snapshot_flow("alpha").is_err()); // gone
    assert!(story.destroy_flow("alpha").is_err()); // already gone
}