diff --git a/src/dm/base-system.md b/src/dm/base-system.md --- a/src/dm/base-system.md +++ b/src/dm/base-system.md @@ -47,6 +47,10 @@ Entities you refer to stay in the next turn's context. Stop naming something and it drops out of that context. Link what still matters and stop linking what has passed. Name the Rusty Anchor and Vera Blackwater to keep the tavern and the NPC in your head; stop when the party moves on. Every entity you link comes back as a note in your next prompt; that is the payoff for linking it now. See Timekeeping for the clock and the history tools, `mark` and `recall`. Wikilinks are for what is true right now; keep the two roles distinct. +## Out of character + +A player line that opens with `(out of character)` is table talk, not the character speaking or acting. Answer it out of character too, briefly and plainly. The story does not move, nobody in the world hears it, and the clock does not mark. Return to the story when the player does. + ## Timekeeping The campaign clock is entirely yours. It moves only when you call `mark`. Time never passes on its own. diff --git a/src/play/screen.rs b/src/play/screen.rs --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -22,6 +22,7 @@ use ratatui::Frame; use ratatui::Terminal; use ratatui::backend::Backend; use ratatui::layout::{Constraint, Layout, Size}; +use ratatui::style::Style; use ratatui::text::Text; use crate::campaign::GameTime; @@ -67,7 +68,20 @@ /// `busy` set forever and the prompt would never accept input again. 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", "/play", "/settings"]; +const SLASH_COMMANDS: &[&str] = &["/context", "/dm", "/play", "/settings"]; + +/// The screen's wire marker for table talk. `/dm` puts this ahead of the +/// player's words before the line reaches the worker, so the DM reads it +/// as talk aimed at the DM, not the character speaking or acting. The +/// marker travels as an ordinary player line, so the transcript and the +/// replay need nothing special: it shows and replays like any other line +/// the player sent, marker intact. +pub const OUT_OF_CHARACTER_MARKER: &str = "(out of character) "; + +/// What `/dm` shows when the player gives it no words to send. The words +/// after `/dm` are what reaches the DM, so a bare command has nothing to +/// send. +const DM_NEEDS_WORDS: &str = "put your words after /dm"; /// The input the player is typing, the reply that is still arriving, and /// how much of the screen the viewport holds while both go on. @@ -447,6 +461,9 @@ /// /// The line goes out under the prefix it was typed under, so it keeps /// the shape it had on the prompt. The clock the next turn ends on is /// the DM's answer to this line, not the moment the player said it. + /// `/dm` is the one line that sends the worker something other than + /// what it echoes and records: [`OUT_OF_CHARACTER_MARKER`] plus the + /// words after the command. /// /// The line queues before the viewport fits around the empty prompt, so /// the rows the prompt gives back are rows the line fills as it goes out, @@ -481,10 +498,24 @@ // Slash commands stay local. /context shows the DM's system prompt, // and /settings shows the world, the mount stack, and the model. if input == "/context" { - return self.echo_local(terminal, guard, viewport, "/context", session.slash_context); + return self.echo_local( + terminal, + guard, + viewport, + "/context", + session.slash_context, + aside(), + ); } if input == "/settings" { - return self.echo_local(terminal, guard, viewport, "/settings", session.settings); + return self.echo_local( + terminal, + guard, + viewport, + "/settings", + session.settings, + aside(), + ); } // /play stays local too. Alone, it lists the world's characters; @@ -500,6 +531,34 @@ let target = (!target.is_empty()).then_some(target); return self.slash_play(terminal, session, guard, viewport, target); } + // /dm is not local: it starts a turn like ordinary input, but + // wraps the words after it in the out-of-character marker, so the + // DM answers as itself rather than as the story. `strip_prefix` + // catches the command the way `/play`'s does; the words after it + // are what the DM hears, and a command with none fails locally + // instead of starting a turn on nothing. `spoken` is what reaches + // the worker; `input` still echoes and enters the prompt history + // as the player typed it, so Up recalls the raw `/dm` line rather + // than the wrapped text the DM reads. + let spoken = if let Some(rest) = input.strip_prefix("/dm") + && (rest.is_empty() || rest.starts_with(' ')) + { + let text = rest.trim(); + if text.is_empty() { + return self.echo_local( + terminal, + guard, + viewport, + "/dm", + DM_NEEDS_WORDS, + failure(), + ); + } + format!("{OUT_OF_CHARACTER_MARKER}{text}") + } else { + input.clone() + }; + let line = format!("{}{input}", self.prefix); guard.begin(); self.transcript @@ -508,7 +567,7 @@ fit(self, terminal, viewport)?; self.transcript.place(terminal)?; terminal.draw(|frame| render(self, frame))?; guard.end(); - self.send(worker, Speaker::Player, input.clone(), false); + self.send(worker, Speaker::Player, spoken, false); session.history.record(&input); Ok(()) } @@ -530,11 +589,12 @@ self.divider = divider; let _ = worker.inputs.send(TurnInput { speaker, text }); } - /// Echoes `command` as a player line, then answers it with `text` as - /// an aside, the way `/context` and `/settings` both work: neither + /// Echoes `command` as a player line, then answers it with `text` in + /// `style`, the way `/context` and `/settings` both work: neither /// reaches the worker, and neither touches `busy`, since /// [`Self::submit`] has already refused to run this while a turn is in - /// flight. + /// flight. `/dm` with nothing to send answers this way too, styled as + /// a failure rather than an aside. fn echo_local>( &mut self, terminal: &mut Terminal, @@ -542,13 +602,13 @@ guard: &mut G, viewport: &mut V, command: &str, text: &str, + style: Style, ) -> Result<(), B::Error> { let line = format!("{}{command}", self.prefix); guard.begin(); self.transcript .insert(terminal, &line, player(), Kind::Player)?; - self.transcript - .insert(terminal, text, aside(), Kind::Aside)?; + self.transcript.insert(terminal, text, style, Kind::Aside)?; fit(self, terminal, viewport)?; self.transcript.place(terminal)?; terminal.draw(|frame| render(self, frame))?; diff --git a/src/play/screen_slash_tests.rs b/src/play/screen_slash_tests.rs --- a/src/play/screen_slash_tests.rs +++ b/src/play/screen_slash_tests.rs @@ -1,11 +1,12 @@ //! 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`. +//! screen instead of going to the worker, `/dm` wrapping its words into a +//! turn, and Tab completing command names on the prompt. The harness +//! lives in `screen_tests.rs`. use std::cell::RefCell; use super::tests::{ - SLASH_CONTEXT, SLASH_SETTINGS, play_script, play_script_on, play_staged, press, typing, + SLASH_CONTEXT, SLASH_SETTINGS, done, play_script, play_script_on, play_staged, press, typing, }; use super::*; @@ -199,6 +200,76 @@ let played = play_script(steps); assert_eq!(played.prompt(), "> /settings"); +} + +#[test] +fn slash_dm_echoes_the_typed_line() { + let mut steps = typing("/dm keep it light"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert!(played.transcript().contains("> /dm keep it light")); +} + +#[test] +fn slash_dm_sends_the_wrapped_text_to_the_worker() { + let mut steps = typing("/dm keep it light"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert_eq!( + played.sent(), + ( + Speaker::Player, + format!("{OUT_OF_CHARACTER_MARKER}keep it light") + ) + ); +} + +#[test] +fn slash_dm_recalls_the_typed_line_with_up() { + let mut steps = typing("/dm keep it light"); + steps.push(press(Key::Enter)); + steps.push(done()); + steps.push(press(Key::Up)); + + let played = play_script(steps); + + assert_eq!(played.prompt(), "> /dm keep it light"); +} + +#[test] +fn bare_slash_dm_starts_no_turn_and_shows_the_failure_aside() { + let mut steps = typing("/dm"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert!(played.transcript().contains("> /dm")); + assert!(played.transcript().contains(DM_NEEDS_WORDS)); + assert!(played.nothing_submitted()); +} + +#[test] +fn tab_completes_dm() { + let mut steps = typing("/d"); + steps.push(press(Key::Tab)); + + let played = play_script(steps); + + assert_eq!(played.prompt(), "> /dm"); +} + +#[test] +fn slash_dmx_is_not_the_dm_command() { + let mut steps = typing("/dmx hello"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert_eq!(played.sent(), (Speaker::Player, "/dmx hello".to_string())); } #[test]