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,