From c47f3dd22de0c4125396bec65ad93e10aaa73524 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 4 Aug 2026 07:51:18 -0400 Subject: [PATCH] Open a resumed session with a recap from the DM A session that resumes a world no longer waits for the player. When the transcript replay put messages in the history, the engine submits src/dm/recap.md as the player's first line: it asks the DM for a short cold-open recap, "Previously, on ...", titled by the DM itself, drawn only from the replayed turns and the session brief, ending on where things stand, without marking the clock. The trigger echoes as a player line and lands in the transcript, and the recap lands as narration, because both are part of the record. When the recap turn completes, the screen draws a dim rule across the terminal, the same shape as the markdown renderer's thematic break, to mark where live play begins. An explicit scenario still wins over the recap, and a fresh world with no scenario opens at the player's prompt. storied sandbox grows --world DIR to make a resumed session playable before phase 10 picks the real world root: the session keeps its world and player directories under DIR, creating them when missing and deleting nothing on exit, so a later run with the same DIR resumes that world. Plan 0011 records the design and checks off the transcript replay that landed in ae4642f. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- plans/0011-session-state.md | 25 ++++++- src/cli.rs | 30 +++++++-- src/dm/dm_session_start_tests.rs | 33 ++++++++- src/dm/mod.rs | 28 +++++++- src/dm/recap.md | 5 ++ src/play/sandbox.rs | 112 ++++++++++++++++++++++++++----- src/play/screen.rs | 65 ++++++++++++++++-- src/play/screen_sync_tests.rs | 2 + src/play/screen_tests.rs | 21 +++--- src/play/screen_turn_tests.rs | 39 ++++++++++- src/play/terminal.rs | 27 +++++++- 11 files changed, 345 insertions(+), 42 deletions(-) create mode 100644 src/dm/recap.md 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), -- 2.51.2