diff --git a/src/play/screen.rs b/src/play/screen.rs index e3c96f4..902a23d 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -70,7 +70,7 @@ const RULE: &str = "─"; const WORKER_GONE: &str = "the storyteller thread is gone; restart storied to continue"; /// Slash commands the player can type. Tab cycles through completions. -const SLASH_COMMANDS: &[&str] = &["/context"]; +const SLASH_COMMANDS: &[&str] = &["/context", "/play"]; /// What the DM is doing right now within a turn, for the status row's /// phrase. @@ -122,6 +122,24 @@ struct Screen { /// with the bare marker. pub type Clock<'a> = &'a mut dyn FnMut() -> Option; +/// Where the screen learns about the world's characters and puts one on +/// stage. `/play` reads and writes through this, and the session's +/// opening reads it once to decide whether the unbound notice belongs +/// after the banner. +pub trait Stage { + /// Every character in the world, by slug. + fn characters(&self) -> Vec; + + /// The character on stage now, or `None` when nobody is: the world + /// holds no characters yet, or it holds several and none has taken + /// the stage. + fn on_stage(&self) -> Option; + + /// Puts `slug` on stage. Fails when the campaign log cannot be + /// written. + fn take_stage(&self, slug: &str) -> Result<(), String>; +} + /// 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. @@ -150,6 +168,9 @@ pub struct Session<'a> { pub history: &'a mut History, /// Where the screen reads the campaign clock. pub clock: Clock<'a>, + /// Where the screen learns about the world's characters and puts one + /// on stage. + pub stage: &'a dyn Stage, /// A line that submits itself before the first key, as if the player /// typed it. A canned scenario or a resumed session's recap opens the /// session through this. @@ -187,6 +208,11 @@ pub struct Session<'a> { /// prompt's prefix shows. Only a turn moves that clock, so this reads it /// once before the loop and once more after each turn ends, never on the /// way through a keystroke. +/// +/// `session.stage` says who the world's characters are and who is on +/// stage. Right after the banner, this queries it once for the unbound +/// notice: a world with several characters and nobody on stage yet gets +/// a plain aside pointing at `/play`; any other world gets none. pub fn play>( terminal: &mut Terminal, keys: &mut K, @@ -209,6 +235,13 @@ pub fn play>( screen .transcript .insert(terminal, session.banner, aside(), Kind::Aside)?; + let characters = session.stage.characters(); + let on_stage = session.stage.on_stage(); + if let Some(notice) = unbound_notice(&characters, on_stage.as_deref()) { + screen + .transcript + .insert(terminal, ¬ice, aside(), Kind::Aside)?; + } if let Some(opening) = session.opening.take() { screen.input.set_text(opening.text); screen.submit(terminal, worker, session, guard, viewport, opening.divider)?; @@ -488,6 +521,19 @@ impl Screen { return Ok(()); } + // /play stays local too. Alone, it lists the world's characters; + // named, it puts one on stage. `strip_prefix` catches both: an + // empty remainder is the bare command, and a remainder starting + // with a space is one that named something, however much space + // sits between the word and its argument. + if let Some(rest) = input.strip_prefix("/play") + && (rest.is_empty() || rest.starts_with(' ')) + { + let target = rest.trim(); + let target = (!target.is_empty()).then_some(target); + return self.slash_play(terminal, session, guard, viewport, target); + } + let line = format!("{}{input}", self.prefix); guard.begin(); self.transcript @@ -503,6 +549,113 @@ impl Screen { session.history.record(&input); Ok(()) } + + /// Handles `/play`, screen-local like `/context`: never reaches the + /// worker, and never touches `busy`, since [`Self::submit`] has + /// already refused to run this while a turn is in flight. + /// + /// `target` `None` lists the world's characters, marking the one on + /// stage. `target` naming a character puts it on stage and confirms + /// the switch, unless it is already on stage or names nobody, in + /// which case this says so instead of writing a second stage event. + fn slash_play>( + &mut self, + terminal: &mut Terminal, + session: &Session<'_>, + guard: &mut G, + viewport: &mut V, + target: Option<&str>, + ) -> Result<(), B::Error> { + let echoed = match target { + Some(slug) => format!("{}/play {slug}", self.prefix), + None => format!("{}/play", self.prefix), + }; + let characters = session.stage.characters(); + let on_stage = session.stage.on_stage(); + let (text, style) = match target { + None => (character_roster(&characters, on_stage.as_deref()), aside()), + Some(slug) => match play_outcome(&characters, on_stage.as_deref(), slug) { + PlayOutcome::Unknown => (unknown_character(&characters, slug), failure()), + PlayOutcome::AlreadyOnStage => (format!("{slug} is already on stage"), aside()), + PlayOutcome::Switch => match session.stage.take_stage(slug) { + Ok(()) => (format!("{slug} takes the stage"), aside()), + Err(error) => (error, failure()), + }, + }, + }; + guard.begin(); + self.transcript + .insert(terminal, &echoed, player(), Kind::Player)?; + self.transcript + .insert(terminal, &text, style, Kind::Aside)?; + fit(self, terminal, viewport)?; + self.transcript.place(terminal)?; + terminal.draw(|frame| render(self, frame))?; + guard.end(); + Ok(()) + } +} + +/// What `/play` alone shows: every character in the world, one per line, +/// marking the one on stage, or a line saying the world has none yet. +fn character_roster(characters: &[String], on_stage: Option<&str>) -> String { + if characters.is_empty() { + return "the world has no characters yet".to_string(); + } + characters + .iter() + .map(|slug| match on_stage { + Some(active) if active == slug => format!("{slug} (on stage)"), + _ => slug.clone(), + }) + .collect::>() + .join("\n") +} + +/// What `/play ` names: nobody, the character already on stage, or +/// a character new to the stage that `slug` should take. +enum PlayOutcome { + /// `slug` names none of the world's characters. + Unknown, + /// `slug` names the character already on stage. + AlreadyOnStage, + /// `slug` names a character to put on stage. + Switch, +} + +/// Decides what `/play slug` should do, from the world's characters and +/// who is on stage now. +fn play_outcome(characters: &[String], on_stage: Option<&str>, slug: &str) -> PlayOutcome { + if !characters.iter().any(|character| character == slug) { + PlayOutcome::Unknown + } else if on_stage == Some(slug) { + PlayOutcome::AlreadyOnStage + } else { + PlayOutcome::Switch + } +} + +/// What `/play` says when `slug` names none of the world's characters: +/// that plainly, and what does exist to play instead. +fn unknown_character(characters: &[String], slug: &str) -> String { + if characters.is_empty() { + return format!("no character named '{slug}'; the world has no characters yet"); + } + format!( + "no character named '{slug}'; the world has: {}", + characters.join(", ") + ) +} + +/// The session's opening notice for an unbound world: several characters +/// and nobody on stage yet. `None` when the world has zero or one +/// character, since neither case leaves anything to choose. +fn unbound_notice(characters: &[String], on_stage: Option<&str>) -> Option { + (characters.len() > 1 && on_stage.is_none()).then(|| { + "the world has several characters and none is on stage yet; \ + /play chooses one" + .to_string() + }) } /// If the input is a single line starting with `/`, cycles through diff --git a/src/play/screen_slash_tests.rs b/src/play/screen_slash_tests.rs index 8d07ebe..6c81af2 100644 --- a/src/play/screen_slash_tests.rs +++ b/src/play/screen_slash_tests.rs @@ -1,10 +1,59 @@ -//! Tests for slash commands: `/context` staying local to the screen -//! instead of going to the worker, and Tab completing command names on -//! the prompt. The harness lives in `screen_tests.rs`. +//! Tests for slash commands: `/context` and `/play` staying local to the +//! screen instead of going to the worker, and Tab completing command +//! names on the prompt. The harness lives in `screen_tests.rs`. -use super::tests::{SLASH_CONTEXT, play_script, play_script_on, press, typing}; +use std::cell::RefCell; + +use super::tests::{SLASH_CONTEXT, play_script, play_script_on, play_staged, press, typing}; use super::*; +/// A stage with a fixed roster and a fixed answer for who is on it, that +/// records every slug `take_stage` is asked to put there. `fails`, when +/// set, is the error every `take_stage` call returns instead of +/// recording the slug and succeeding. +struct FakeStage { + characters: Vec, + on_stage: Option, + taken: RefCell>, + fails: Option, +} + +impl FakeStage { + fn new(characters: &[&str], on_stage: Option<&str>) -> Self { + Self { + characters: characters.iter().map(|slug| slug.to_string()).collect(), + on_stage: on_stage.map(str::to_string), + taken: RefCell::new(Vec::new()), + fails: None, + } + } + + fn failing(characters: &[&str], on_stage: Option<&str>, error: &str) -> Self { + Self { + fails: Some(error.to_string()), + ..Self::new(characters, on_stage) + } + } +} + +impl Stage for FakeStage { + fn characters(&self) -> Vec { + self.characters.clone() + } + + fn on_stage(&self) -> Option { + self.on_stage.clone() + } + + fn take_stage(&self, slug: &str) -> Result<(), String> { + if let Some(error) = &self.fails { + return Err(error.clone()); + } + self.taken.borrow_mut().push(slug.to_string()); + Ok(()) + } +} + #[test] fn slash_context_puts_the_command_and_the_prompt_in_the_transcript() { let mut steps = typing("/context"); @@ -98,3 +147,190 @@ fn tab_leaves_an_input_of_several_rows_alone() { assert_eq!(played.prompt(), " the rest of it"); } + +#[test] +fn tab_completes_play() { + let mut steps = typing("/pl"); + steps.push(press(Key::Tab)); + + let played = play_script(steps); + + assert_eq!(played.prompt(), "> /play"); +} + +#[test] +fn slash_play_lists_characters_and_marks_the_one_on_stage() { + let stage = FakeStage::new(&["maren", "tomas"], Some("maren")); + let mut steps = typing("/play"); + steps.push(press(Key::Enter)); + + let played = play_staged(&stage, steps); + + assert!(played.transcript().contains("maren (on stage)")); + assert!(played.transcript().contains("tomas")); + assert!(!played.transcript().contains("tomas (on stage)")); +} + +#[test] +fn slash_play_with_no_characters_says_so() { + let stage = FakeStage::new(&[], None); + let mut steps = typing("/play"); + steps.push(press(Key::Enter)); + + let played = play_staged(&stage, steps); + + assert!( + played + .transcript() + .contains("the world has no characters yet") + ); +} + +#[test] +fn slash_play_switches_and_the_stage_closure_records_the_call() { + let stage = FakeStage::new(&["maren", "tomas"], Some("tomas")); + let mut steps = typing("/play maren"); + steps.push(press(Key::Enter)); + + let played = play_staged(&stage, steps); + + assert_eq!(*stage.taken.borrow(), vec!["maren".to_string()]); + assert!(played.transcript().contains("maren takes the stage")); +} + +#[test] +fn slash_play_shows_the_error_when_taking_the_stage_fails() { + let stage = FakeStage::failing( + &["maren", "tomas"], + Some("tomas"), + "the campaign log could not be written", + ); + let mut steps = typing("/play maren"); + steps.push(press(Key::Enter)); + + let played = play_staged(&stage, steps); + + assert!( + played + .transcript() + .contains("the campaign log could not be written") + ); +} + +#[test] +fn slash_play_with_an_unknown_slug_lists_what_exists() { + let stage = FakeStage::new(&["maren"], None); + let mut steps = typing("/play bob"); + steps.push(press(Key::Enter)); + + let played = play_staged(&stage, steps); + + assert!(played.transcript().contains("no character named 'bob'")); + assert!(played.transcript().contains("maren")); + assert!(stage.taken.borrow().is_empty()); +} + +#[test] +fn slash_play_on_the_active_character_writes_no_event() { + let stage = FakeStage::new(&["maren"], Some("maren")); + let mut steps = typing("/play maren"); + steps.push(press(Key::Enter)); + + let played = play_staged(&stage, steps); + + assert!(played.transcript().contains("maren is already on stage")); + assert!(stage.taken.borrow().is_empty()); +} + +#[test] +fn the_unbound_notice_appears_in_the_transcript_when_composed() { + let stage = FakeStage::new(&["maren", "tomas"], None); + + let played = play_staged(&stage, vec![]); + + assert!(played.transcript().contains("none is on stage yet")); +} + +#[test] +fn character_roster_marks_the_one_on_stage() { + let characters = vec!["maren".to_string(), "tomas".to_string()]; + + let roster = character_roster(&characters, Some("maren")); + + assert_eq!(roster, "maren (on stage)\ntomas"); +} + +#[test] +fn character_roster_with_no_characters_says_so() { + let roster = character_roster(&[], None); + + assert_eq!(roster, "the world has no characters yet"); +} + +#[test] +fn play_outcome_names_nobody() { + let characters = vec!["maren".to_string()]; + + assert!(matches!( + play_outcome(&characters, None, "bob"), + PlayOutcome::Unknown + )); +} + +#[test] +fn play_outcome_names_who_is_already_on_stage() { + let characters = vec!["maren".to_string()]; + + assert!(matches!( + play_outcome(&characters, Some("maren"), "maren"), + PlayOutcome::AlreadyOnStage + )); +} + +#[test] +fn play_outcome_names_a_switch() { + let characters = vec!["maren".to_string(), "tomas".to_string()]; + + assert!(matches!( + play_outcome(&characters, Some("tomas"), "maren"), + PlayOutcome::Switch + )); +} + +#[test] +fn unknown_character_lists_what_exists() { + let characters = vec!["maren".to_string()]; + + let text = unknown_character(&characters, "bob"); + + assert_eq!(text, "no character named 'bob'; the world has: maren"); +} + +#[test] +fn unknown_character_with_no_characters_says_so() { + let text = unknown_character(&[], "bob"); + + assert_eq!( + text, + "no character named 'bob'; the world has no characters yet" + ); +} + +#[test] +fn unbound_notice_names_several_unbound_characters() { + let characters = vec!["maren".to_string(), "tomas".to_string()]; + + assert!(unbound_notice(&characters, None).is_some()); +} + +#[test] +fn unbound_notice_says_nothing_with_one_character() { + let characters = vec!["maren".to_string()]; + + assert_eq!(unbound_notice(&characters, None), None); +} + +#[test] +fn unbound_notice_says_nothing_with_no_characters() { + assert_eq!(unbound_notice(&[], None), None); +} diff --git a/src/play/screen_sync_tests.rs b/src/play/screen_sync_tests.rs index ff77560..41bb6a0 100644 --- a/src/play/screen_sync_tests.rs +++ b/src/play/screen_sync_tests.rs @@ -15,7 +15,7 @@ use ratatui::text::Text; use ratatui::{Terminal, TerminalOptions, Viewport}; use super::prompt_tests::RecordingViewport; -use super::tests::{Step, done, play_script, play_script_on, press, typing}; +use super::tests::{EmptyStage, Step, done, play_script, play_script_on, press, typing}; use super::*; /// One step in the timeline a `RecordingGuard`, the harness's `Script` @@ -227,11 +227,13 @@ fn submitting_repaints_the_shrunk_viewport_inside_its_own_guard() { let mut guard = RecordingGuard::default(); let mut viewport = RecordingViewport::default(); + let stage = EmptyStage; let mut session = Session { banner: "", slash_context: "", history: &mut history, clock: &mut || None, + stage: &stage, opening: None, }; screen diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index a98982b..b18fa7a 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -244,6 +244,24 @@ pub(in crate::play) fn buffer_text(buffer: &Buffer) -> String { rows.join("\n") } +/// A stage with no characters and nobody on it, for every harness entry +/// point that does not care about `/play`. +pub(in crate::play) struct EmptyStage; + +impl Stage for EmptyStage { + fn characters(&self) -> Vec { + Vec::new() + } + + fn on_stage(&self) -> Option { + None + } + + fn take_stage(&self, _slug: &str) -> Result<(), String> { + Ok(()) + } +} + /// Runs the loop over `steps` on a screen of the viewport's own height, /// where every inserted row lands in scrollback. pub(in crate::play) fn play_script(steps: Vec) -> Played { @@ -265,7 +283,7 @@ 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) + play_session_on(height, clock, &EmptyStage, None, steps) } /// Runs the loop the way [`play_script`] does, with `opening` as the line @@ -276,6 +294,7 @@ pub(in crate::play) fn play_opening(opening: &str, divider: bool, steps: Vec) -> Played { + play_session_on(VIEWPORT_HEIGHT, &mut || None, stage, None, 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. +/// the campaign clock, the world's characters, and the opening line that +/// submits before the first key. struct SessionSetup<'a> { clock: Clock<'a>, + stage: &'a dyn Stage, opening: Option, } @@ -297,6 +324,7 @@ struct SessionSetup<'a> { fn play_session_on( height: u16, clock: Clock<'_>, + stage: &dyn Stage, opening: Option, steps: Vec, ) -> Played { @@ -321,7 +349,11 @@ fn play_session_on( &mut guard, requested, Resized::default(), - SessionSetup { clock, opening }, + SessionSetup { + clock, + stage, + opening, + }, steps, ) } @@ -362,6 +394,7 @@ pub(in crate::play) fn play_pinned(height: u16, steps: Vec) -> Played { resized, SessionSetup { clock: &mut || None, + stage: &EmptyStage, opening: None, }, steps, @@ -406,6 +439,7 @@ fn run_script>( slash_context: SLASH_CONTEXT, history: &mut history, clock: setup.clock, + stage: setup.stage, opening: setup.opening, }, guard, diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 702a355..8a0cbb8 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, Opening, VIEWPORT_HEIGHT}; +use super::screen::{self, Opening, Stage, VIEWPORT_HEIGHT}; use super::sync::SyncGuard; use super::viewport::{self, ViewportRows}; use super::worker::Worker; @@ -71,6 +71,11 @@ pub fn run( // rather than ending the session over a prefix. let clock_campaign = campaign.clone(); let mut clock = move || clock_campaign.current_time().ok(); + // `/play` and the unbound notice read and write the world's characters + // through their own handle on the campaign too, for the same reason + // the clock does: the DM owns the one it advances, and that one lives + // on the worker thread. + let stage_campaign = campaign.clone(); let dm = Dm::new(config, mount, layers, Some(campaign))?; // The DM answers what `/context` shows: its own prompt and its own // tools, so the report cannot drift from the session. @@ -115,6 +120,7 @@ pub fn run( slash_context: &slash_context, history: &mut history, clock: &mut clock, + stage: &stage_campaign, opening, }, &mut CrosstermSyncGuard, @@ -201,6 +207,33 @@ fn pin_to_bottom(viewport_height: u16) -> io::Result<()> { execute!(stdout, MoveTo(0, target)) } +/// The screen's handle on the world's characters, backed by a cloned +/// campaign the same way the clock is. +/// +/// Every method here calls a `Campaign` method of the same name. +/// `Campaign`'s own methods take priority over this trait's during that +/// call, so each one reaches straight into the campaign rather than +/// looping back into itself; this impl exists only to expose the rule +/// the engine already uses for who is on stage, not to write it twice. +impl Stage for Campaign { + fn characters(&self) -> Vec { + self.characters() + } + + fn on_stage(&self) -> Option { + let mut characters = self.characters(); + match characters.len() { + 1 => characters.pop(), + _ => self.on_stage().ok().flatten(), + } + } + + fn take_stage(&self, slug: &str) -> Result<(), String> { + let time = self.current_time()?; + self.take_stage(slug, time) + } +} + /// The player's keyboard, as crossterm reports it. struct CrosstermKeys;