From 3dddcab34121d463afd29ad84b26f7bf682cf074 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 4 Aug 2026 17:24:27 -0400 Subject: [PATCH] Add /dm to speak out of character /dm sends the words after it to the DM wrapped in an out-of-character marker, so the table can talk without the character speaking. The line echoes and recalls exactly as typed, while the DM reads the wrapped form; the marker travels inside an ordinary player line, so the transcript records it honestly and replay carries the register back without special handling. base-system.md teaches the register: answer out of character, briefly, the story does not move, nobody in the world hears it, and the clock does not mark. A bare /dm sends nothing and says to put the words after the command. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- src/dm/base-system.md | 4 ++ src/play/screen.rs | 78 ++++++++++++++++++++++++++++++---- src/play/screen_slash_tests.rs | 77 +++++++++++++++++++++++++++++++-- 3 files changed, 147 insertions(+), 12 deletions(-) diff --git a/src/dm/base-system.md b/src/dm/base-system.md index d28cadb..58857c5 100644 --- 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 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 index 842d5c5..7bfa8bb 100644 --- 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 @@ 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", "/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 @@ impl Screen { /// 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 @@ impl Screen { // 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 @@ impl Screen { 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 @@ impl Screen { 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 @@ impl Screen { 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 @@ impl Screen { 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 index e43d473..a677060 100644 --- 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::*; @@ -201,6 +202,76 @@ fn tab_completes_settings() { 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] fn slash_play_lists_characters_and_marks_the_one_on_stage() { let stage = FakeStage::new(&["maren", "tomas"], Some("maren")); -- 2.51.2