From 4b275078dc54fb2e19b304739bb9bdde2fe22fa4 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 4 Aug 2026 07:07:46 -0400 Subject: [PATCH] Let a sandbox open from a scenario file storied sandbox takes an optional SCENARIO file whose contents submit as the player's first line, before the first key, the same as a typed line. Testing in the sandbox meant retyping the same opening prompt every session; now a canned file like scenarios/l3-cleric-and-zombies.md starts the scene by itself. The line goes through the normal submit path, so it shows on the prompt prefix in the transcript, records in the terminal history for recall with Up, and holds the prompt until the opening turn ends. An unreadable or empty scenario file fails before the terminal changes anything. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- src/cli.rs | 24 +++++++++++- src/play/screen.rs | 11 +++++- src/play/screen_sync_tests.rs | 1 + src/play/screen_tests.rs | 70 +++++++++++++++++++++++++++++++++-- src/play/terminal.rs | 11 +++++- 5 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 95e6fed..a6a2a4d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -64,6 +64,10 @@ enum Command { // needs. See `src/play/mod.rs`. #[cfg(not(coverage))] Sandbox { + /// A file whose contents go to the DM as the player's first + /// line, so a test scenario starts without retyping it. + #[arg(value_name = "SCENARIO")] + scenario: Option, /// The base URL of the OpenAI-compatible API to talk to. #[arg(long, value_name = "URL")] api_base: Option, @@ -112,6 +116,7 @@ pub fn run( Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] Some(Command::Sandbox { + scenario, api_base, model, reasoning_effort, @@ -122,19 +127,34 @@ pub fn run( reasoning_effort, }, session_layers, + scenario.as_deref(), ), } } /// Boots a fresh, temporary world for a sandbox session, prints where it -/// lives, and plays until the player quits. +/// lives, and plays until the player quits. When `scenario` names a file, +/// its contents open the session as the player's first line. #[cfg(not(coverage))] fn run_sandbox( overrides: &crate::config::Overrides, session_layers: &[PathBuf], + scenario: Option<&Path>, ) -> Result<(), String> { use crate::play::sandbox::Sandbox; + let opening = match scenario { + Some(path) => { + let text = std::fs::read_to_string(path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + if text.trim().is_empty() { + return Err(format!("{} is empty", path.display())); + } + Some(text) + } + None => None, + }; + let sandbox = Sandbox::new()?; let mut layers = session_layers.to_vec(); layers.push(sandbox.world_dir()); @@ -148,7 +168,7 @@ fn run_sandbox( println!(" {}", layer.display()); } - crate::play::run(overrides, &layers, &sandbox.world_dir()) + crate::play::run(overrides, &layers, &sandbox.world_dir(), opening) } fn run_srd_fetch(sources: &SrdSources) -> Result<(), String> { diff --git a/src/play/screen.rs b/src/play/screen.rs index d9e10e5..09c7739 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -128,6 +128,9 @@ pub struct Session<'a> { pub history: &'a mut History, /// Where the screen reads the campaign clock. pub clock: Clock<'a>, + /// A line that submits itself before the first key, as if the player + /// typed it. A canned scenario opens the session through this. + pub opening: Option, } /// Runs the game until the player quits. @@ -143,7 +146,9 @@ pub struct Session<'a> { /// `guard` brackets every render pass in a synchronized update, so the /// terminal paints each pass in one go instead of painting whatever has /// landed on the wire so far. The opening banner queues before the loop -/// and paints with the first pass, inside that pass's update. +/// and paints with the first pass, inside that pass's update. An opening +/// line in `session` submits right after the banner, before the first +/// key, the same as a line the player typed. /// /// `viewport` changes the viewport's height when the prompt needs more or /// fewer rows. Each resize sits inside the same guard as the repaint that @@ -180,6 +185,10 @@ pub fn play>( screen .transcript .insert(terminal, session.banner, aside(), Kind::Aside)?; + if let Some(opening) = session.opening.take() { + screen.input.set_text(opening); + screen.submit(terminal, worker, session, guard, viewport)?; + } loop { screen.tick = screen.tick.wrapping_add(1); guard.begin(); diff --git a/src/play/screen_sync_tests.rs b/src/play/screen_sync_tests.rs index 970ce81..98c7306 100644 --- a/src/play/screen_sync_tests.rs +++ b/src/play/screen_sync_tests.rs @@ -231,6 +231,7 @@ fn submitting_repaints_the_shrunk_viewport_inside_its_own_guard() { slash_context: "", history: &mut history, clock: &mut || None, + opening: None, }; screen .submit( diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index cbc0ec5..124f843 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -265,6 +265,36 @@ pub(in crate::play) fn play_script_on(height: u16, steps: Vec) -> Played { /// Runs the loop the way [`play_script_on`] does, with `clock` as the /// campaign's time of day. pub(in crate::play) fn play_clocked_on(height: u16, clock: Clock<'_>, steps: Vec) -> Played { + play_session_on(height, clock, None, steps) +} + +/// Runs the loop the way [`play_script`] does, with `opening` as the line +/// that submits itself before the first key. +pub(in crate::play) fn play_opening(opening: &str, steps: Vec) -> Played { + play_session_on( + VIEWPORT_HEIGHT, + &mut || None, + Some(opening.to_string()), + steps, + ) +} + +/// What the entry points vary about the session itself: where it reads +/// the campaign clock, and the opening line that submits before the +/// first key. +struct SessionSetup<'a> { + clock: Clock<'a>, + opening: Option, +} + +/// The one place a non-pinned harness opens its terminal and runs the +/// loop; the entry points above differ only in what they pass here. +fn play_session_on( + height: u16, + clock: Clock<'_>, + opening: Option, + steps: Vec, +) -> Played { let mut backend = TestBackend::new(40, height); backend.set_cursor_position(Position::new(0, 0)).unwrap(); let terminal = Terminal::with_options( @@ -286,7 +316,7 @@ pub(in crate::play) fn play_clocked_on(height: u16, clock: Clock<'_>, steps: Vec &mut guard, requested, Resized::default(), - clock, + SessionSetup { clock, opening }, steps, ) } @@ -325,7 +355,10 @@ pub(in crate::play) fn play_pinned(height: u16, steps: Vec) -> Played { &mut guard, requested, resized, - &mut || None, + SessionSetup { + clock: &mut || None, + opening: None, + }, steps, ) } @@ -340,7 +373,7 @@ fn run_script>( guard: &mut RecordingGuard, requested: Rc>>, resized: Resized, - clock: Clock<'_>, + setup: SessionSetup<'_>, steps: Vec, ) -> Played { let (input_sender, inputs) = mpsc::channel(); @@ -367,7 +400,8 @@ fn run_script>( banner: "a banner", slash_context: SLASH_CONTEXT, history: &mut history, - clock, + clock: setup.clock, + opening: setup.opening, }, guard, viewport, @@ -480,3 +514,31 @@ fn enter_submits_nothing_while_a_turn_runs() { pub(in crate::play) fn done() -> Step { Step::Turn(TurnEvent::Done("ok".to_string())) } + +#[test] +fn an_opening_line_submits_before_the_first_key() { + let played = play_opening("You wake in a ditch", vec![]); + + assert_eq!(played.submitted(), "You wake in a ditch"); + assert!(played.transcript().contains("> You wake in a ditch")); +} + +#[test] +fn the_opening_turn_holds_the_prompt_until_it_ends() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + + let played = play_opening("You wake in a ditch", steps); + + assert_eq!(played.submitted(), "You wake in a ditch"); + assert!(played.nothing_submitted()); +} + +#[test] +fn the_player_recalls_the_opening_line_with_up() { + let steps = vec![done(), press(Key::Up)]; + + let played = play_opening("You wake in a ditch", steps); + + assert_eq!(played.prompt(), "> You wake in a ditch"); +} diff --git a/src/play/terminal.rs b/src/play/terminal.rs index aa100d7..0a61e67 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -38,7 +38,8 @@ use super::worker::Worker; const POLL_INTERVAL: Duration = Duration::from_millis(50); /// Loads the config, opens the knowledge mount over `layers`, lowest -/// first, starts the DM, and plays until the player quits. +/// first, starts the DM, and plays until the player quits. When `opening` +/// holds a line, it submits as the player's first turn before any key. /// /// The terminal goes back to how it was before the game, with the /// transcript still on the screen. An init that fails part way through has @@ -48,7 +49,12 @@ const POLL_INTERVAL: Duration = Duration::from_millis(50); /// Pinning the viewport to the bottom happens after all three succeed /// and before the terminal enters raw mode, so a failure there ends the /// game the same way. -pub fn run(overrides: &Overrides, layers: &[PathBuf], world_root: &Path) -> Result<(), String> { +pub fn run( + overrides: &Overrides, + layers: &[PathBuf], + world_root: &Path, + opening: Option, +) -> Result<(), String> { let config = config::load(overrides).map_err(|error| error.to_string())?; let mount = Arc::new(Mount::open(layers)?); let campaign = Campaign::open(world_root)?; @@ -88,6 +94,7 @@ pub fn run(overrides: &Overrides, layers: &[PathBuf], world_root: &Path) -> Resu slash_context: &slash_context, history: &mut history, clock: &mut clock, + opening, }, &mut CrosstermSyncGuard, &mut CrosstermViewport, -- 2.51.2