diff --git a/plans/0011-session-state.md b/plans/0011-session-state.md index 2867eca..60940d3 100644 --- a/plans/0011-session-state.md +++ b/plans/0011-session-state.md @@ -157,6 +157,19 @@ Archivist will write the brief each pass ([0012](0012-the-archivist.md)); until it exists, a hand-written one works. +The replay is context, not output. The terminal never prints the old +turns back. Instead, a resumed session opens with a recap: the engine +submits a trigger line through the same path a canned scenario uses, +and the trigger tells the DM to open with a short, dramatic +"Previously, on …" summary of the prior session. The DM names the tale +itself, recaps only what the record holds, ends on where things stand, +and does not move the clock. The trigger line prints as a player line +and lands in the transcript, because it is part of the record. The +recap lands in the transcript as narration, like any other turn. When +the recap turn ends, the engine draws a rule in the terminal, and play +begins at the prompt. The DM speaks first whenever there is something +to say. + ## Rendered wikilinks In the terminal, a wikilink renders as bold text using the display form @@ -191,9 +204,15 @@ the engine can re-scan it on restart. next turn's prompt - [x] 4. Wikilink rendering — format as bold in the terminal, preserve raw syntax in the transcript -- [ ] 5. Restart mechanism — replay last N turns of the transcript, scan - wikilinks and `session.md` when one exists, inject context, and - route asides to the DM alone ([0013](0013-the-aside.md)) +- [x] 5. Transcript replay — replay last N turns of the transcript into + the message history, scan wikilinks and `session.md` when one + exists, and inject context. Routing asides to the DM alone waits + for the aside itself ([0013](0013-the-aside.md)) +- [ ] 6. The recap turn — on a resumed session, auto-submit a trigger + that has the DM open with a "Previously, on …" recap, then draw a + rule before play begins. `storied sandbox --world DIR` reuses a + directory across runs so a resumed session is playable before + phase 10 picks the real world root ## Relationship to the Python prototype diff --git a/src/cli.rs b/src/cli.rs index b1c474f..884d36d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -69,6 +69,12 @@ enum Command { /// line, so a test scenario starts without retyping it. #[arg(value_name = "SCENARIO", value_hint = ValueHint::FilePath)] scenario: Option, + /// A directory to keep the world and player knowledge in, instead + /// of a temporary one. A world already there resumes; one that + /// does not exist yet is created. Nothing under it is deleted + /// when the session ends. + #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)] + world: Option, /// The base URL of the OpenAI-compatible API to talk to. #[arg(long, value_name = "URL")] api_base: Option, @@ -130,6 +136,7 @@ pub fn run( #[cfg(not(coverage))] Some(Command::Sandbox { scenario, + world, api_base, model, reasoning_effort, @@ -141,6 +148,7 @@ pub fn run( }, session_layers, scenario.as_deref(), + world, ), Some(Command::Completions { shell }) => run_completions(shell), } @@ -157,14 +165,18 @@ fn run_completions(shell: Shell) -> Result<(), String> { Ok(()) } -/// Boots a fresh, temporary world for a sandbox session, prints where it -/// lives, and plays until the player quits. When `scenario` names a file, -/// its contents open the session as the player's first line. +/// Boots a world for a sandbox session, prints where it lives, and plays +/// until the player quits. When `scenario` names a file, its contents open +/// the session as the player's first line. When `world` names a +/// directory, the session keeps its world and player knowledge there +/// instead of a temporary one deleted on exit, so a later run with the +/// same directory resumes it. #[cfg(not(coverage))] fn run_sandbox( overrides: &crate::config::Overrides, session_layers: &[PathBuf], scenario: Option<&Path>, + world: Option, ) -> Result<(), String> { use crate::play::sandbox::Sandbox; @@ -180,12 +192,20 @@ fn run_sandbox( None => None, }; - let sandbox = Sandbox::new()?; + let kept = world.is_some(); + let sandbox = match world { + Some(root) => Sandbox::named(root)?, + None => Sandbox::new()?, + }; let mut layers = session_layers.to_vec(); layers.push(sandbox.world_dir()); layers.push(sandbox.player_dir()); - println!("sandbox uses a fresh world this session:"); + if kept { + println!("sandbox uses a kept world this session:"); + } else { + println!("sandbox uses a fresh world this session:"); + } println!(" world: {}", sandbox.world_dir().display()); println!(" player: {}", sandbox.player_dir().display()); println!("mount stack, lowest first:"); diff --git a/src/dm/dm_session_start_tests.rs b/src/dm/dm_session_start_tests.rs index 4a71b8a..e746a7c 100644 --- a/src/dm/dm_session_start_tests.rs +++ b/src/dm/dm_session_start_tests.rs @@ -8,7 +8,9 @@ use std::sync::mpsc::Receiver; use tempfile::TempDir; -use super::tests::{CapturedRequest, dm_with_campaign, fake_server, sent_messages, sse_response}; +use super::tests::{ + CapturedRequest, dm_for, dm_with_campaign, fake_server, sent_messages, sse_response, +}; use super::{Campaign, Config, Dm}; use crate::knowledge::fixtures; @@ -254,6 +256,35 @@ fn an_empty_world_replays_nothing_and_still_turns() { assert_eq!(messages[1]["content"], "I open the door."); } +/// An address no server listens on. These tests build a `Dm` and read +/// its trigger without ever taking a turn. +const UNUSED: &str = "http://127.0.0.1:0"; + +#[test] +fn a_resumed_session_offers_the_recap_trigger() { + let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); + + let dm = dm_with_campaign(UNUSED.to_string(), &world); + + assert!(dm.recap_trigger().unwrap().contains("Previously, on")); +} + +#[test] +fn a_fresh_campaign_offers_no_recap_trigger() { + let world = TempDir::new().unwrap(); + + let dm = dm_with_campaign(UNUSED.to_string(), &world); + + assert_eq!(dm.recap_trigger(), None); +} + +#[test] +fn a_dm_with_no_campaign_offers_no_recap_trigger() { + let dm = dm_for(UNUSED.to_string()); + + assert_eq!(dm.recap_trigger(), None); +} + #[test] fn a_transcript_that_cannot_be_read_fails_the_dm_at_startup() { let world = TempDir::new().unwrap(); diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 5fb871f..c44dfa7 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -35,6 +35,12 @@ pub mod tools; /// return next turn. const TOOLS_WITHHELD: &str = include_str!("tools-withheld.md"); +/// Submitted as the player's first line when the session opened on a +/// record, so the DM speaks first: it asks for a cold-open recap of the +/// turns the transcript replayed and the brief that follows them. The +/// player sees this line on screen, so it stays short. +const RECAP: &str = include_str!("recap.md"); + /// How many consecutive tool-only rounds, rounds that called tools and /// narrated nothing, are allowed before the tools are withheld for one /// round. @@ -139,6 +145,10 @@ pub struct Dm { /// clock fresh at the start of every turn. `None` when there is no /// campaign, in which case a turn's prompt carries no clock line. campaign: Option, + /// Whether this session opened on a record: the campaign's transcript + /// replayed at least one message into the history. Read by + /// [`Self::recap_trigger`]. + resumed: bool, } impl Dm { @@ -225,6 +235,7 @@ impl Dm { let entity_root = campaign .as_ref() .map(|campaign| campaign.world().to_path_buf()); + let mut resumed = false; if let Some(campaign) = &campaign { let mut scan = scan.lock().unwrap(); // Every note already on disk registers before the first turn, @@ -234,7 +245,9 @@ impl Dm { // The last session's tail becomes this session's opening // history, and whatever its last narration and the session // brief name becomes the first turn's entity notes. - history.extend(session_start::resume(campaign, &mut scan)?); + let replayed = session_start::resume(campaign, &mut scan)?; + resumed = !replayed.is_empty(); + history.extend(replayed); } let mut bus = TurnBus::new(); bus.add(Box::new(Scanner::new( @@ -254,9 +267,22 @@ impl Dm { scan, entity_root, campaign, + resumed, }) } + /// The line to submit on the player's behalf to open the session, or + /// `None` when the player opens it. + /// + /// A session that replayed a transcript starts with the DM speaking, + /// and this is what asks it to: a recap of the turns the replay put + /// in the history and of the session brief. A fresh world, an empty + /// transcript, and a session with no campaign have nothing to recap, + /// so they answer `None`. + pub fn recap_trigger(&self) -> Option { + self.resumed.then(|| RECAP.trim_end().to_string()) + } + /// The `/context` report: the system prompt as the context stack /// composes it now, and every tool the model can call, rendered for /// a player who asks what the DM was told. diff --git a/src/dm/recap.md b/src/dm/recap.md new file mode 100644 index 0000000..5379fb4 --- /dev/null +++ b/src/dm/recap.md @@ -0,0 +1,5 @@ +Open with a short, dramatic recap, in the style of a television cold open: "Previously, on ...". Pick a title for this tale yourself. + +Recap only what the record holds: the turns above and the session brief. Invent nothing. End on where things stand right now. + +A recap takes no game time, so do not mark the clock. Then stop and wait for me. diff --git a/src/play/sandbox.rs b/src/play/sandbox.rs index 2e894d4..9d64ca5 100644 --- a/src/play/sandbox.rs +++ b/src/play/sandbox.rs @@ -1,29 +1,50 @@ -//! The transient world a sandbox session plays in. +//! The world a sandbox session plays in. //! -//! A sandbox boots a fresh, empty world every time: a `world/` directory -//! for the campaign and a `player/` directory for the player's own -//! knowledge, both under one temp root that vanishes when the session -//! ends. Nothing persists from one sandbox to the next. +//! A sandbox always exposes a `world/` directory for the campaign and a +//! `player/` directory for the player's own knowledge. By default those +//! live under a temp root that vanishes when the session ends, so nothing +//! persists from one sandbox to the next. Naming a root with +//! [`Sandbox::named`] keeps the tree instead, so a later sandbox under the +//! same root resumes it. use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; -/// A throwaway world and player directory for one sandbox session. +/// Where a [`Sandbox`] keeps its directory tree. +enum Root { + /// A temporary directory, removed when the sandbox is dropped. + Temporary(TempDir), + /// A directory the caller named, left in place when the sandbox is + /// dropped. + Named(PathBuf), +} + +impl Root { + fn path(&self) -> &Path { + match self { + Root::Temporary(dir) => dir.path(), + Root::Named(path) => path.as_path(), + } + } +} + +/// A world and player directory for one sandbox session. /// -/// The root is a temporary directory, so dropping the sandbox removes the -/// whole tree. The two directories it exposes are empty on creation and -/// are what the session mounts into the DM's knowledge and writes its -/// campaign to. +/// A sandbox built with [`Sandbox::new`] owns a temporary root, so dropping +/// it removes the whole tree. One built with [`Sandbox::named`] owns a +/// root the caller chose, so dropping it leaves the tree in place. Either +/// way, the two directories it exposes hold whatever the campaign already +/// wrote there, and are empty when there is nothing yet. pub struct Sandbox { - root: TempDir, + root: Root, } impl Sandbox { /// Creates a fresh sandbox with empty `world` and `player` /// directories, under the directory the system keeps temporary files - /// in. + /// in. The tree is removed when the sandbox is dropped. pub fn new() -> Result { Self::inside(&std::env::temp_dir()) } @@ -38,7 +59,19 @@ impl Sandbox { .prefix("storied-sandbox-") .tempdir_in(parent) .map_err(|error| format!("could not create a sandbox directory: {error}"))?; - Self::under(root) + Self::under(Root::Temporary(root)) + } + + /// Creates a sandbox rooted at `root`, creating `root` itself and its + /// `world` and `player` directories when they do not already exist. + /// + /// The tree is left in place when the sandbox is dropped, so a later + /// sandbox named at the same root resumes whatever the campaign + /// already wrote there. + pub fn named(root: PathBuf) -> Result { + fs::create_dir_all(&root) + .map_err(|error| format!("could not create {}: {error}", root.display()))?; + Self::under(Root::Named(root)) } /// Creates the `world` and `player` directories inside `root`, and @@ -48,7 +81,7 @@ impl Sandbox { /// already holds a file of one of those names, which is what the /// failure this reports means: the name is taken by something that is /// not a directory. - fn under(root: TempDir) -> Result { + fn under(root: Root) -> Result { for name in ["world", "player"] { let directory = root.path().join(name); fs::create_dir_all(&directory) @@ -99,9 +132,58 @@ mod tests { let root = TempDir::new().unwrap(); fs::write(root.path().join("world"), "x").unwrap(); - let error = Sandbox::under(root).err().unwrap(); + let error = Sandbox::under(Root::Temporary(root)).err().unwrap(); assert!(error.contains("world")); assert!(error.contains("could not create")); } + + #[test] + fn a_named_sandbox_creates_its_root_and_empty_directories() { + let parent = TempDir::new().unwrap(); + let named_root = parent.path().join("kept-world"); + + let sandbox = Sandbox::named(named_root.clone()).unwrap(); + + assert!(sandbox.world_dir().is_dir()); + assert!(sandbox.player_dir().is_dir()); + assert!(named_root.is_dir()); + } + + #[test] + fn a_named_sandbox_survives_being_dropped() { + let parent = TempDir::new().unwrap(); + let named_root = parent.path().join("kept-world"); + + Sandbox::named(named_root.clone()).unwrap(); + + assert!(named_root.join("world").is_dir()); + assert!(named_root.join("player").is_dir()); + } + + #[test] + fn a_named_sandbox_resumes_what_is_already_there() { + let parent = TempDir::new().unwrap(); + let named_root = parent.path().join("kept-world"); + let sandbox = Sandbox::named(named_root.clone()).unwrap(); + fs::write(sandbox.world_dir().join("scene.md"), "the tavern").unwrap(); + + let resumed = Sandbox::named(named_root).unwrap(); + + assert_eq!( + fs::read_to_string(resumed.world_dir().join("scene.md")).unwrap(), + "the tavern" + ); + } + + #[test] + fn a_named_sandbox_with_nowhere_to_live_says_so() { + let parent = TempDir::new().unwrap(); + fs::write(parent.path().join("not-a-directory"), "x").unwrap(); + let named_root = parent.path().join("not-a-directory").join("kept-world"); + + let error = Sandbox::named(named_root).err().unwrap(); + + assert!(error.contains("could not create")); + } } diff --git a/src/play/screen.rs b/src/play/screen.rs index 09c7739..e3c96f4 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -110,12 +110,34 @@ struct Screen { /// It holds the campaign clock as of the last turn that ended, so /// the whole prompt keeps one shape while the player types. prefix: String, + /// True while the turn now running is one that draws a horizontal + /// rule into the transcript when it ends with [`TurnEvent::Done`]. + /// Only a resumed session's recap turn sets this, and it resets to + /// false the moment that turn settles, however it ends, so no later + /// turn draws a second rule. + divider: bool, } /// Where the screen reads the campaign clock. `None` leaves the prompt /// with the bare marker. pub type Clock<'a> = &'a mut dyn FnMut() -> Option; +/// A line that submits itself before the first key, as if the player +/// typed it. A canned scenario opens a fresh session this way, and a +/// resumed session opens the same way with a recap instead. +pub struct Opening { + /// The line itself, submitted exactly as if the player had typed it. + pub text: String, + /// Whether the turn this line starts draws a horizontal rule into the + /// transcript once it ends with [`TurnEvent::Done`]. A canned + /// scenario's opening leaves this false, since there is no earlier + /// play to set apart from what follows. A resumed session's recap + /// sets it true, so the rule marks where the recap stops and live + /// play begins. A turn that ends with [`TurnEvent::Failed`] draws no + /// rule either way. + pub divider: bool, +} + /// What a session brings to the loop besides the terminal, the player's /// keys, and the worker: the prose it composes before the loop starts, /// the prompts it remembers, and where it reads the campaign clock. @@ -129,8 +151,9 @@ pub struct Session<'a> { /// 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, + /// typed it. A canned scenario or a resumed session's recap opens the + /// session through this. + pub opening: Option, } /// Runs the game until the player quits. @@ -181,13 +204,14 @@ pub fn play>( state: DmState::Thinking, rows: VIEWPORT_HEIGHT, prefix: prompt::prefix((session.clock)()), + divider: false, }; 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)?; + screen.input.set_text(opening.text); + screen.submit(terminal, worker, session, guard, viewport, opening.divider)?; } loop { screen.tick = screen.tick.wrapping_add(1); @@ -223,7 +247,7 @@ pub fn play>( Some(Key::Down) => down(&mut screen, terminal, session.history)?, Some(Key::Newline) => screen.input.insert('\n'), Some(Key::Paste(text)) => screen.input.insert_str(&text), - Some(Key::Enter) => screen.submit(terminal, worker, session, guard, viewport)?, + Some(Key::Enter) => screen.submit(terminal, worker, session, guard, viewport, false)?, Some(Key::Cancel | Key::Interrupt) if screen.busy => { worker.cancel.store(true, Ordering::Relaxed); } @@ -423,6 +447,12 @@ impl Screen { /// stays inside this same guard rather than waiting for the next pass: /// otherwise the screen would sit part-cleared, outside any synchronized /// update, until that next pass came around. + /// + /// `divider` marks the turn this line starts as the one turn that draws + /// a horizontal rule into the transcript when it ends with + /// [`TurnEvent::Done`]. Every key-driven submit passes `false`; only the + /// session's own opening line, when it is a resumed session's recap, + /// passes `true`. fn submit>( &mut self, terminal: &mut Terminal, @@ -430,6 +460,7 @@ impl Screen { session: &mut Session<'_>, guard: &mut G, viewport: &mut V, + divider: bool, ) -> Result<(), B::Error> { // One trim answers every question the rest of this asks: whether // the line says anything, whether it names a slash command, and @@ -467,6 +498,7 @@ impl Screen { guard.end(); self.busy = true; self.state = DmState::Thinking; + self.divider = divider; let _ = worker.inputs.send(input.clone()); session.history.record(&input); Ok(()) @@ -580,6 +612,10 @@ fn events( /// Reads what streamed in rather than the event's own payload: a round /// that narrates and then calls a secret tool emits no tool event, so the /// stream is the only place that has seen every delta of the turn. +/// +/// A turn `submit` marked with `divider` draws a horizontal rule into the +/// transcript here, after whatever the DM said, so a resumed session's +/// recap ends on a line that marks where it stops and live play begins. fn finish( screen: &mut Screen, terminal: &mut Terminal, @@ -591,10 +627,24 @@ fn finish( .transcript .insert(terminal, NOTHING_SAID, aside(), Kind::Aside)?; } + if screen.divider { + insert_divider(&mut screen.transcript, terminal)?; + } settle(screen, clock); Ok(()) } +/// Puts a horizontal rule in the transcript, shaped and styled like the +/// markdown renderer's own thematic break: [`RULE`] repeated across the +/// terminal's width, dim. +fn insert_divider( + transcript: &mut Transcript, + terminal: &Terminal, +) -> Result<(), B::Error> { + let width = terminal.size()?.width as usize; + transcript.insert(terminal, &RULE.repeat(width), aside(), Kind::Aside) +} + /// Ends the turn: the prompt works again, and the next turn starts with /// nothing said. The turn count advances here, so the first turn of a /// session shows the first thinking phrase. @@ -602,11 +652,16 @@ fn finish( /// The prefix takes the clock again here. A turn is the only thing that /// moves campaign time, so this is the one point in the session where the /// prompt can show a new day or hour. +/// +/// `divider` resets here too, however the turn ended, so it never carries +/// over to the next one: only `submit` sets it, and only for the one line +/// it was given for. fn settle(screen: &mut Screen, clock: Clock<'_>) { screen.busy = false; screen.transcript.next_turn(); screen.turns += 1; screen.prefix = prompt::prefix(clock()); + screen.divider = false; } /// Draws the tail area (the block still forming), a blank row, the status diff --git a/src/play/screen_sync_tests.rs b/src/play/screen_sync_tests.rs index 98c7306..ff77560 100644 --- a/src/play/screen_sync_tests.rs +++ b/src/play/screen_sync_tests.rs @@ -187,6 +187,7 @@ fn screen_with_a_two_row_input() -> Screen { state: DmState::Thinking, rows: VIEWPORT_HEIGHT + 1, prefix: prompt::prefix(None), + divider: false, } } @@ -240,6 +241,7 @@ fn submitting_repaints_the_shrunk_viewport_inside_its_own_guard() { &mut session, &mut guard, &mut viewport, + false, ) .unwrap(); diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index 124f843..a98982b 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -269,12 +269,17 @@ pub(in crate::play) fn play_clocked_on(height: u16, clock: Clock<'_>, steps: Vec } /// 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 { +/// that submits itself before the first key. `divider` sets whether the +/// turn that answers it draws a horizontal rule into the transcript once +/// it ends with [`TurnEvent::Done`]. +pub(in crate::play) fn play_opening(opening: &str, divider: bool, steps: Vec) -> Played { play_session_on( VIEWPORT_HEIGHT, &mut || None, - Some(opening.to_string()), + Some(Opening { + text: opening.to_string(), + divider, + }), steps, ) } @@ -284,7 +289,7 @@ pub(in crate::play) fn play_opening(opening: &str, steps: Vec) -> Played { /// first key. struct SessionSetup<'a> { clock: Clock<'a>, - opening: Option, + opening: Option, } /// The one place a non-pinned harness opens its terminal and runs the @@ -292,7 +297,7 @@ struct SessionSetup<'a> { fn play_session_on( height: u16, clock: Clock<'_>, - opening: Option, + opening: Option, steps: Vec, ) -> Played { let mut backend = TestBackend::new(40, height); @@ -517,7 +522,7 @@ pub(in crate::play) fn done() -> Step { #[test] fn an_opening_line_submits_before_the_first_key() { - let played = play_opening("You wake in a ditch", vec![]); + let played = play_opening("You wake in a ditch", false, vec![]); assert_eq!(played.submitted(), "You wake in a ditch"); assert!(played.transcript().contains("> You wake in a ditch")); @@ -528,7 +533,7 @@ 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); + let played = play_opening("You wake in a ditch", false, steps); assert_eq!(played.submitted(), "You wake in a ditch"); assert!(played.nothing_submitted()); @@ -538,7 +543,7 @@ fn the_opening_turn_holds_the_prompt_until_it_ends() { 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); + let played = play_opening("You wake in a ditch", false, steps); assert_eq!(played.prompt(), "> You wake in a ditch"); } diff --git a/src/play/screen_turn_tests.rs b/src/play/screen_turn_tests.rs index fef8a87..794c801 100644 --- a/src/play/screen_turn_tests.rs +++ b/src/play/screen_turn_tests.rs @@ -8,7 +8,7 @@ use std::sync::atomic::Ordering; use ratatui::text::Text; -use super::tests::{Step, done, play_script, press, typing}; +use super::tests::{Step, done, play_opening, play_script, press, typing}; use super::*; #[test] @@ -104,6 +104,43 @@ fn a_turn_of_only_a_tool_call_says_nothing_of_the_kind() { assert!(!played.transcript().contains("(the DM says nothing)")); } +#[test] +fn a_divider_flagged_opening_draws_a_rule_once_its_turn_completes() { + let played = play_opening("Where were we?", true, vec![done()]); + + assert!(played.transcript().contains(&RULE.repeat(40))); +} + +#[test] +fn a_scenario_opening_draws_no_divider() { + let played = play_opening("You wake in a ditch", false, vec![done()]); + + assert!(!played.transcript().contains(&RULE.repeat(40))); +} + +#[test] +fn a_divider_flagged_opening_whose_turn_fails_draws_no_divider() { + let played = play_opening( + "Where were we?", + true, + vec![Step::Turn(TurnEvent::Failed("no".to_string()))], + ); + + assert!(!played.transcript().contains(&RULE.repeat(40))); +} + +#[test] +fn a_later_turn_after_the_divider_draws_no_second_rule() { + let mut steps = vec![done()]; + steps.extend(typing("again")); + steps.push(press(Key::Enter)); + steps.push(done()); + + let played = play_opening("Where were we?", true, steps); + + assert_eq!(played.transcript().matches(&RULE.repeat(40)).count(), 1); +} + #[test] fn a_failed_turn_shows_the_error_in_the_transcript() { let steps = vec![Step::Turn(TurnEvent::Failed( diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 0a61e67..702a355 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -29,7 +29,7 @@ use crate::knowledge::Mount; use super::history::History; use super::keys::{self, Key, Keys}; use super::panics; -use super::screen::{self, VIEWPORT_HEIGHT}; +use super::screen::{self, Opening, VIEWPORT_HEIGHT}; use super::sync::SyncGuard; use super::viewport::{self, ViewportRows}; use super::worker::Worker; @@ -38,8 +38,14 @@ 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. When `opening` -/// holds a line, it submits as the player's first turn before any key. +/// first, starts the DM, and plays until the player quits. +/// +/// `opening` is a canned scenario's first line and, when given, always +/// submits as the player's first turn before any key, with no divider. +/// When `opening` is `None`, a session the DM resumed from a prior +/// transcript submits its recap trigger instead, with a divider marking +/// where the recap ends and live play begins. A fresh world with no +/// scenario opens with neither, and the player speaks first. /// /// 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 @@ -69,6 +75,21 @@ pub fn run( // The DM answers what `/context` shows: its own prompt and its own // tools, so the report cannot drift from the session. let slash_context = dm.context_report(); + // Read before the DM moves into the worker below. A scenario's opening + // wins outright; otherwise a resumed session's recap trigger opens the + // session with a divider, and a fresh world with nothing to recap opens + // with neither. + let opening = opening + .map(|text| Opening { + text, + divider: false, + }) + .or_else(|| { + dm.recap_trigger().map(|text| Opening { + text, + divider: true, + }) + }); pin_to_bottom(VIEWPORT_HEIGHT).map_err(|error| error.to_string())?; let options = TerminalOptions { viewport: Viewport::Inline(VIEWPORT_HEIGHT),