diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -22,10 +22,10 @@ sha2 = "0.11.0" tar = { version = "0.4.46", default-features = false } toml = "1.1.4" ureq = "3.3.0" +tempfile = "3.27.0" [dev-dependencies] assert_cmd = "2.2.2" -tempfile = "3.27.0" [lints.rust] # cargo-llvm-cov sets cfg(coverage). The play module uses it to leave the diff --git a/plans/0000-roadmap.md b/plans/0000-roadmap.md --- a/plans/0000-roadmap.md +++ b/plans/0000-roadmap.md @@ -89,24 +89,26 @@ markdown into styled rows as it streams, committing each block as soon as it settles and holding back only the one still forming. *You can now: read the DM's words styled, not asterisked.* ([0008](completed/0008-streaming-markdown.md)) -- [ ] **Phase 7: DM prompting.** The first real tuning pass against - live sessions, and the DM-versus-player knowledge line: secrets from - lore stay behind the screen until the player earns them, and the DM - stops narrating its own tool use. *You can now: meet Mother Uldra - without being told what she is.* -- [ ] **Phase 8: The context stack.** Rules, world, and player knowledge +- [ ] **Phase 7: Timekeeping and the campaign log.** Game time, a + transcript of every turn, and a campaign log of events — the backbone + that DM memory, player recall, and context stacking all sit on. The + clock is the last anchor in the log; there is no separate state. + `lookup` for what's true, `recall` for what happened. + *You can now: quit, come back, and the clock picks up where it left + off.* ([0009](0009-timekeeping.md)) +- [ ] **Phase 8: DM prompting.** The first real tuning pass against live + sessions, and the DM-versus-player knowledge line: secrets from lore + stay behind the screen until the player earns them. *You can now: meet + Mother Uldra without being told what she is.* +- [ ] **Phase 9: The context stack.** Rules, world, and player knowledge layered into each turn. The push/pull split gets decided here: what the - DM always sees versus what it looks up. *You can now: watch the DM - remember the world from turn to turn.* -- [ ] **Phase 9: Persistence.** Worlds and players as files on disk. - Character sheet, campaign log, session state. *You can now: quit, come - back tomorrow, and pick up where you left off.* -- [ ] **Phase 10 and beyond: ideas that have to earn it.** Character + DM always sees versus what it looks up, now fueled by the campaign log + and transcript. *You can now: watch the DM remember the world from + turn to turn.* +- [ ] **Phase 10: Persistence.** Character sheet, world entities, session + state. *You can now: your character persists across sessions.* +- [ ] **Phase 11 and beyond: ideas that have to earn it.** Character creation, combat and initiative, background world motion, advancement, name generation, colors and theming (the styles all live in `markdown::style` waiting for it). Each one starts as a conversation, not a commitment. - -Phases 8 and 9 might swap or blur together; building context on in-memory -state first seemed simpler, but if it feels backwards when we get there, -we'll flip them. We'll reorder any of this when we have a reason to. diff --git a/plans/0009-timekeeping.md b/plans/0009-timekeeping.md new file mode 100644 --- /dev/null +++ b/plans/0009-timekeeping.md @@ -0,0 +1,92 @@ +# 0009: Timekeeping and the campaign log + +## What we're building + +Game time, a transcript, and a campaign log — the backbone that everything +else (DM memory, player recall, context stacking, persistence) sits on. + +The campaign log is the clock. Current game time is always the last time +anchor in the log — no separate state to drift. The transcript is the +public record of every word narrated. Together they give the DM two kinds +of memory: `lookup` for what's true, `recall` for what happened. + +## Storage + +``` +worlds/{world}/ + campaign-log/ + 0001.md → one file per game-day, DM-only, forward-only + 0002.md + transcript/ + 0001.md → one file per game-day, shared (player-readable) + 0002.md +``` + +Both use the same per-day partitioning with zero-padded filenames matching +the day number. The campaign log holds `mark` entries that advance the +clock. The transcript holds auto-appended narration, stamped with whatever +the current game time is at that moment. Both start at day 1 and grow +forward. + +## Time notation + +`#dX-HHMM` — day number (signed), hour, minute. Day 1 starts when the +campaign starts. No months, no years, no calendar. + +- Zero is not a day. The campaign starts at `#d1-0000`. +- Negative days (`#d-3-1234`) are valid in lore text and narration — + for events that happened before the campaign — but never appear as + anchors in the campaign log or transcript. +- The campaign log is forward-only. No backdating. Cross-references to + past days go in event text, not as new log entries. + +Clock derivation: find the highest day directory, read the file, find the +last `#dX-HHMM` anchor. Derived on every read, never stored separately. + +## Narration auto-logging + +Every turn's narration is appended to the transcript automatically. The +engine writes it — the DM doesn't call a tool. It's stamped with the +current game time at the start of the turn. The transcript is append-only +and never edited. + +## Tools + +**`mark`** — advance game time and log an event + +- `time` (required): the new `#dX-HHMM` anchor. Must be later than the + current clock. +- `event` (required): one-liner describing what happened. +- `visibility` (required): `public` | `screened` | `secret`. +- `screened` (optional): the text the player sees. Only valid when + visibility is `screened`. +- Writes to campaign log (always). Writes to transcript when `public` + (the `event`) or `screened` (the `screened` text). Nothing to transcript + when `secret`. +- Sets the clock. + +**`recall`** — search memory + +- For the DM: searches both campaign log and transcript across day files. +- For the player (future): searches transcript only. +- Returns matching entries with their time anchors and source. + +**`lookup`** — unchanged scope: world entities, lore, rules. Does not +search history — that's `recall`'s job. + +## Uniform visibility + +Every tool that produces player-visible output uses the same three levels: +`public`, `screened`, `secret`. The engine routes output to the right file +based on visibility. The DM controls the screened text per call. + +## Playability goal + +You can quit mid-session, come back, and `recall` remembers everything that +happened. The clock picks up where it left off. This is the foundation for: + +- Phase 8: DM prompting — personality, style, secrets behind the screen +- Phase 9: the context stack — what the DM sees each turn, layered from + rules/world/player +- Phase 10: full persistence — character sheet, world entities, session + state diff --git a/plans/README.md b/plans/README.md --- a/plans/README.md +++ b/plans/README.md @@ -22,6 +22,8 @@ ## How we work together - **Design first.** We talk an idea through before any code exists. The plan gets written, Chris signs off, then we build. + After the plan is written, we add it to the roadmap in `0000-roadmap.md` + and renumber the phases that follow it. - **Small pieces.** We build in phases, and every phase ends with something you can actually run. We do not spend months on foundations with nothing to show for it. diff --git a/src/campaign/campaign_tests.rs b/src/campaign/campaign_tests.rs new file mode 100644 --- /dev/null +++ b/src/campaign/campaign_tests.rs @@ -0,0 +1,290 @@ +//! Tests for the `Campaign` type: the clock, forward-only marking, the +//! transcript sections, and the line-based campaign log. + +use super::*; +use std::fs; + +/// A fresh campaign in a temp directory. +fn campaign() -> (Campaign, tempfile::TempDir) { + let dir = tempfile::TempDir::new().unwrap(); + let opened = Campaign::open(dir.path()).unwrap(); + (opened, dir) +} + +fn time(text: &str) -> GameTime { + GameTime::parse(text).unwrap() +} + +#[test] +fn open_creates_the_log_and_transcript_directories() { + let dir = tempfile::TempDir::new().unwrap(); + + Campaign::open(dir.path()).unwrap(); + + assert!(dir.path().join("campaign-log").is_dir()); + assert!(dir.path().join("transcript").is_dir()); +} + +#[test] +fn the_clock_starts_at_day_one_midnight() { + let (campaign, _dir) = campaign(); + + assert_eq!(campaign.current_time().unwrap(), START); +} + +// --- Campaign log: line format ------------------------------------------- + +#[test] +fn a_mark_writes_a_line_to_the_log() { + let (campaign, _dir) = campaign(); + + campaign + .mark( + time("#d1-0830"), + "The party leaves the inn.", + Some("The party leaves the inn."), + ) + .unwrap(); + + let log_entries = campaign.log_entries().unwrap(); + assert_eq!(log_entries.len(), 1); + assert_eq!(log_entries[0].time, time("#d1-0830")); + assert_eq!(log_entries[0].body, "The party leaves the inn."); + // Line format, no headings. + let text = + fs::read_to_string(format!("{}/campaign-log/0001.md", _dir.path().display())).unwrap(); + assert_eq!(text, "#d1-0830 - The party leaves the inn.\n"); +} + +#[test] +fn consecutive_marks_at_the_same_day_grow_the_log() { + let (campaign, _dir) = campaign(); + + campaign + .mark(time("#d1-0830"), "first", Some("first")) + .unwrap(); + campaign + .mark(time("#d1-1200"), "second", Some("second")) + .unwrap(); + + let text = + fs::read_to_string(format!("{}/campaign-log/0001.md", _dir.path().display())).unwrap(); + assert_eq!(text, "#d1-0830 - first\n#d1-1200 - second\n"); +} + +#[test] +fn a_mark_to_a_new_day_builds_a_second_file() { + let (campaign, _dir) = campaign(); + + campaign + .mark(time("#d1-0830"), "first", Some("first")) + .unwrap(); + campaign + .mark(time("#d2-0100"), "second", Some("second")) + .unwrap(); + + assert!(campaign.current_time().unwrap() == time("#d2-0100")); + assert!(_dir.path().join("campaign-log/0001.md").exists()); + assert!(_dir.path().join("campaign-log/0002.md").exists()); +} + +#[test] +fn a_secret_mark_reaches_only_the_log() { + let (campaign, _dir) = campaign(); + + campaign + .mark(time("#d1-0830"), "A spy watches the party.", None) + .unwrap(); + + assert_eq!(campaign.log_entries().unwrap().len(), 1); + assert_eq!(campaign.transcript_entries().unwrap().len(), 0); +} + +#[test] +fn the_log_moves_forward_only() { + let (campaign, _dir) = campaign(); + + campaign + .mark(time("#d1-1200"), "Noon.", Some("Noon.")) + .unwrap(); + + let error = campaign + .mark(time("#d1-0830"), "Backdated.", Some("Backdated.")) + .unwrap_err(); + + assert!(error.contains("not after the current clock")); +} + +#[test] +fn the_log_refuses_a_time_before_the_campaign_starts() { + let (campaign, _dir) = campaign(); + + let error = campaign + .mark(time("#d-3-1200"), "Ancient.", Some("Ancient.")) + .unwrap_err(); + + assert!(error.contains("before the campaign")); +} + +// --- Transcript ----------------------------------------------------------- + +#[test] +fn a_public_mark_writes_to_the_transcript() { + let (campaign, _dir) = campaign(); + + campaign + .mark( + time("#d1-0830"), + "The party leaves the inn.", + Some("The party leaves the inn."), + ) + .unwrap(); + + let sections = campaign.transcript_entries().unwrap(); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].time, time("#d1-0830")); + assert_eq!(sections[0].body, "The party leaves the inn."); +} + +#[test] +fn a_screened_mark_shares_the_screened_text_in_the_transcript() { + let (campaign, _dir) = campaign(); + + campaign + .mark( + time("#d1-0830"), + "A spy rolls a d20 for stealth.", + Some("Something stirs in the shadows."), + ) + .unwrap(); + + let sections = campaign.transcript_entries().unwrap(); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].body, "Something stirs in the shadows."); +} + +#[test] +fn player_input_appears_in_the_transcript_with_a_prefix() { + let (campaign, _dir) = campaign(); + + campaign + .append_player(time("#d1-0830"), "I jump across the stream.") + .unwrap(); + + let sections = campaign.transcript_entries().unwrap(); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].body, "player> I jump across the stream."); +} + +#[test] +fn narrations_at_the_same_time_share_a_section() { + let (campaign, _dir) = campaign(); + + campaign + .append_player(time("#d1-0830"), "I check the room.") + .unwrap(); + campaign + .append_narration( + time("#d1-0830"), + "You find dusty cobwebs and a rusted lever.", + ) + .unwrap(); + + let sections = campaign.transcript_entries().unwrap(); + assert_eq!(sections.len(), 1); + assert!(sections[0].body.starts_with("player> I check the room.")); + assert!(sections[0].body.contains("You find dusty cobwebs")); +} + +#[test] +fn a_time_change_opens_a_new_section() { + let (campaign, _dir) = campaign(); + + campaign + .append_player(time("#d1-0830"), "I wake up.") + .unwrap(); + campaign + .append_narration(time("#d1-1200"), "Noon arrives.") + .unwrap(); + + let sections = campaign.transcript_entries().unwrap(); + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].time, time("#d1-0830")); + assert_eq!(sections[1].time, time("#d1-1200")); +} + +#[test] +fn the_transcript_file_has_one_heading_per_section() { + let (campaign, _dir) = campaign(); + + campaign + .append_player(time("#d1-0830"), "I wake up.") + .unwrap(); + campaign + .append_narration(time("#d1-1200"), "Noon arrives.") + .unwrap(); + + let text = fs::read_to_string(format!("{}/transcript/0001.md", _dir.path().display())).unwrap(); + assert!(text.contains("## #d1-0830\nplayer> I wake up.\n")); + assert!(text.contains("## #d1-1200\nNoon arrives.\n")); +} + +#[test] +fn narration_appends_to_the_transcript_alone() { + let (campaign, _dir) = campaign(); + + campaign + .append_narration(time("#d1-0830"), "You wake in a cold cell.") + .unwrap(); + + assert_eq!(campaign.transcript_entries().unwrap().len(), 1); + assert_eq!(campaign.log_entries().unwrap().len(), 0); +} + +// --- Recall --------------------------------------------------------------- + +#[test] +fn recall_finds_matching_entries_newest_first() { + let (campaign, _dir) = campaign(); + + campaign + .mark( + time("#d1-0830"), + "The party meets a goblin.", + Some("A goblin blocks the road."), + ) + .unwrap(); + campaign + .append_narration( + time("#d1-0900"), + "The goblin snarls and draws a rusty blade.", + ) + .unwrap(); + + let hits = search(&campaign, &["goblin".to_string()], None).unwrap(); + + // The log, the mark's shared transcript entry (#d1-0830), and the + // narration (#d1-0900) all mention the goblin. + assert_eq!(hits.len(), 3); + assert_eq!(hits[0].time, time("#d1-0900")); + assert_eq!(hits[0].source, Source::Transcript); + // Among the two at #d1-0830, log came before transcript in the + // search (insertion order). + assert_eq!(hits[1].source, Source::Log); + assert_eq!(hits[2].source, Source::Transcript); +} + +#[test] +fn recall_can_filter_to_the_log() { + let (campaign, _dir) = campaign(); + + campaign.mark(time("#d1-0830"), "secret spy", None).unwrap(); + campaign + .append_player(time("#d1-0900"), "I talk about spies.") + .unwrap(); + + let hits = search(&campaign, &["spy".to_string()], Some(Source::Log)).unwrap(); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].source, Source::Log); +} diff --git a/src/campaign/clock.rs b/src/campaign/clock.rs new file mode 100644 --- /dev/null +++ b/src/campaign/clock.rs @@ -0,0 +1,170 @@ +//! Game time: the `#dX-HHMM` notation, and nothing else. +//! +//! Day number and a time of day, no months, years, or calendar. Day 1 is +//! the day the campaign starts. + +use std::cmp::Ordering; +use std::fmt; +use std::str::FromStr; + +use regex::Regex; + +/// The moment the campaign starts: day 1 at midnight. +pub const START: GameTime = GameTime { + day: 1, + hour: 0, + minute: 0, +}; + +/// One moment in game time: a day and a time of day, written `#dX-HHMM`. +/// +/// The day is signed, so events before the campaign can be named in lore +/// text with a negative day. Zero is not a day and never parses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GameTime { + pub day: i32, + pub hour: u8, + pub minute: u8, +} + +/// Matches `#dX-HHMM`, where `X` is a signed day and the four trailing +/// digits are the hour and minute. +const TIME_PATTERN: &str = r"^#d(-?\d+)-(\d{4})$"; + +impl GameTime { + /// Parses `text` as `#dX-HHMM`, like `#d1-0830` or `#d-3-1234`. + /// + /// Returns `None` for a time out of range: an hour past 23, a minute + /// past 59, or a zero day. + pub fn parse(text: &str) -> Option { + let captures = Regex::new(TIME_PATTERN).ok()?.captures(text)?; + let day: i32 = captures.get(1)?.as_str().parse().ok()?; + if day == 0 { + return None; + } + let clock = captures.get(2)?.as_str(); + let hour = clock[..2].parse().ok()?; + let minute = clock[2..].parse().ok()?; + if hour > 23 || minute > 59 { + return None; + } + Some(GameTime { day, hour, minute }) + } +} + +impl FromStr for GameTime { + type Err = String; + + fn from_str(text: &str) -> Result { + Self::parse(text).ok_or_else(|| { + format!("`{text}` is not valid game time; write it `#dX-HHMM`, like `#d1-0830`") + }) + } +} + +impl fmt::Display for GameTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "#d{}-{:02}{:02}", self.day, self.hour, self.minute) + } +} + +impl Ord for GameTime { + fn cmp(&self, other: &Self) -> Ordering { + (self.day, self.hour, self.minute).cmp(&(other.day, other.hour, other.minute)) + } +} + +impl PartialOrd for GameTime { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_positive_day() { + assert_eq!( + GameTime::parse("#d1-0830"), + Some(GameTime { + day: 1, + hour: 8, + minute: 30 + }) + ); + } + + #[test] + fn parses_a_negative_day() { + assert_eq!( + GameTime::parse("#d-3-1234"), + Some(GameTime { + day: -3, + hour: 12, + minute: 34 + }) + ); + } + + #[test] + fn parses_midnight() { + assert_eq!( + GameTime::parse("#d1-0000"), + Some(GameTime { + day: 1, + hour: 0, + minute: 0 + }) + ); + } + + #[test] + fn rejects_a_zero_day() { + assert_eq!(GameTime::parse("#d0-0000"), None); + } + + #[test] + fn rejects_an_invalid_hour() { + assert_eq!(GameTime::parse("#d1-2430"), None); + } + + #[test] + fn rejects_an_invalid_minute() { + assert_eq!(GameTime::parse("#d1-1260"), None); + } + + #[test] + fn rejects_garbage() { + assert_eq!(GameTime::parse("banana"), None); + } + + #[test] + fn from_str_reports_a_bad_time() { + let error = "banana".parse::().unwrap_err(); + + assert!(error.contains("#dX-HHMM")); + } + + #[test] + fn displays_as_day_hour_minute() { + let time = GameTime { + day: -3, + hour: 12, + minute: 34, + }; + + assert_eq!(time.to_string(), "#d-3-1234"); + } + + #[test] + fn orders_by_day_then_hour_then_minute() { + let early = GameTime::parse("#d1-0830").unwrap(); + let later_same_day = GameTime::parse("#d1-1200").unwrap(); + let next_day = GameTime::parse("#d2-0000").unwrap(); + + assert!(early < later_same_day); + assert!(later_same_day < next_day); + } +} diff --git a/src/campaign/day_file.rs b/src/campaign/day_file.rs new file mode 100644 --- /dev/null +++ b/src/campaign/day_file.rs @@ -0,0 +1,35 @@ +//! Shared plumbing for day-partitioned files: the path of a day's file, +//! the day numbers that exist, and the one place that knows zero-padding. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// The zero-padded file name for `day`, like `0001.md`. +pub(crate) fn day_path(dir: &Path, day: i32) -> PathBuf { + dir.join(format!("{day:04}.md")) +} + +/// Creates `dir` (and its parents) if it does not exist yet. +pub(crate) fn ensure_dir(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|error| format!("{}: cannot create: {error}", dir.display())) +} + +/// Every day with a file under `dir`, sorted ascending; an empty list when +/// the directory does not exist yet. +pub(crate) fn days_in(dir: &Path) -> Vec { + let mut days = Vec::new(); + let Ok(read_dir) = fs::read_dir(dir) else { + return days; + }; + for entry in read_dir.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(day) = name + .strip_suffix(".md") + .and_then(|stem| stem.parse::().ok()) + { + days.push(day); + } + } + days.sort_unstable(); + days +} diff --git a/src/campaign/entry.rs b/src/campaign/entry.rs new file mode 100644 --- /dev/null +++ b/src/campaign/entry.rs @@ -0,0 +1,11 @@ +//! One entry in a day-partitioned log: a game-time anchor and its body. + +use super::clock::GameTime; + +/// One `## #dX-HHMM` entry: the moment it is stamped with, and the text +/// that follows it up to the next entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogEntry { + pub time: GameTime, + pub body: String, +} diff --git a/src/campaign/log.rs b/src/campaign/log.rs new file mode 100644 --- /dev/null +++ b/src/campaign/log.rs @@ -0,0 +1,100 @@ +//! The DM's private campaign log: one line per event, `#dX-HHMM - event`, +//! oldest to newest across one zero-padded file per game-day. +//! +//! Each file is plain lines, with nothing but the entries; there are no +//! headings. The clock is always the time of the last line of the highest +//! day file. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use super::clock::GameTime; +use super::day_file::{day_path, days_in, ensure_dir}; +use super::entry::LogEntry; + +/// The DM-only campaign log at `dir`. +#[derive(Debug, Clone)] +pub struct CampaignLog { + dir: PathBuf, +} + +impl CampaignLog { + /// A log rooted at `dir`. + pub fn new(dir: PathBuf) -> Self { + Self { dir } + } + + /// The directory the log lives in. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// The newest `#dX-HHMM` anchor on disk, or `None` when no entry + /// exists yet. + pub fn last_anchor(&self) -> Result, String> { + let days = days_in(&self.dir); + let Some(&max_day) = days.last() else { + return Ok(None); + }; + let path = day_path(&self.dir, max_day); + let text = fs::read_to_string(&path) + .map_err(|error| format!("{}: cannot read: {error}", path.display()))?; + Ok(parse_lines(&text).last().map(|entry| entry.time)) + } + + /// Appends an entry `{time} - {event}` to that day's file, creating it + /// when the day is new. + pub fn append(&self, time: GameTime, event: &str) -> Result<(), String> { + ensure_dir(&self.dir)?; + let path = day_path(&self.dir, time.day); + if path.exists() { + let mut file = OpenOptions::new() + .append(true) + .open(&path) + .map_err(|error| format!("{}: cannot open: {error}", path.display()))?; + writeln!(file, "{time} - {event}") + .map_err(|error| format!("{}: cannot append: {error}", path.display()))?; + } else { + fs::write(&path, format!("{time} - {event}\n")) + .map_err(|error| format!("{}: cannot create: {error}", path.display()))?; + } + Ok(()) + } + + /// Every entry across all day files, ordered by day then by position + /// in its file. + pub fn read_lines(&self) -> Result, String> { + let mut entries = Vec::new(); + for day in days_in(&self.dir) { + let path = day_path(&self.dir, day); + let text = fs::read_to_string(&path) + .map_err(|error| format!("{}: cannot read: {error}", path.display()))?; + entries.extend(parse_lines(&text)); + } + Ok(entries) + } +} + +/// Parses the lines of a campaign-log day file into entries. +/// +/// A line must be `#dX-HHMM - text`; everything else is ignored. The +/// event text is everything after the first ` - `, trimmed. +pub(crate) fn parse_lines(text: &str) -> Vec { + let mut entries = Vec::new(); + for line in text.lines() { + if let Some((prefix, body)) = line.split_once(" - ") + && let Some(time) = GameTime::parse(prefix) + { + entries.push(LogEntry { + time, + body: body.trim().to_string(), + }); + } + } + entries +} + +#[cfg(test)] +#[path = "log_tests.rs"] +mod tests; diff --git a/src/campaign/log_tests.rs b/src/campaign/log_tests.rs new file mode 100644 --- /dev/null +++ b/src/campaign/log_tests.rs @@ -0,0 +1,197 @@ +//! Tests for the line-based campaign log. + +use super::*; +use crate::campaign::day_file; +use std::fs; + +fn log() -> (CampaignLog, tempfile::TempDir) { + let dir = tempfile::TempDir::new().unwrap(); + let log = CampaignLog::new(dir.path().join("log")); + (log, dir) +} + +#[test] +fn append_writes_a_line() { + let (log, _dir) = log(); + + log.append( + GameTime::parse("#d1-0830").unwrap(), + "The party leaves the inn.", + ) + .unwrap(); + + let text = fs::read_to_string(day_path(log.dir(), 1)).unwrap(); + assert_eq!(text, "#d1-0830 - The party leaves the inn.\n"); +} + +#[test] +fn consecutive_appends_grow_the_file() { + let (log, _dir) = log(); + + log.append(GameTime::parse("#d1-0830").unwrap(), "first") + .unwrap(); + log.append(GameTime::parse("#d1-1200").unwrap(), "second") + .unwrap(); + + let text = fs::read_to_string(day_path(log.dir(), 1)).unwrap(); + assert_eq!(text, "#d1-0830 - first\n#d1-1200 - second\n"); +} + +#[test] +fn a_mark_to_a_new_day_creates_a_second_file() { + let (log, _dir) = log(); + + log.append(GameTime::parse("#d1-0830").unwrap(), "first") + .unwrap(); + log.append(GameTime::parse("#d2-0100").unwrap(), "second") + .unwrap(); + + assert!(day_path(log.dir(), 1).exists()); + assert!(day_path(log.dir(), 2).exists()); +} + +#[test] +fn last_anchor_is_none_for_an_empty_log() { + let (log, _dir) = log(); + + assert_eq!(log.last_anchor().unwrap(), None); +} + +#[test] +fn last_anchor_is_the_newest_entry_of_the_highest_day() { + let (log, _dir) = log(); + + log.append(GameTime::parse("#d1-0830").unwrap(), "first") + .unwrap(); + log.append(GameTime::parse("#d2-0100").unwrap(), "second") + .unwrap(); + + assert_eq!( + log.last_anchor().unwrap(), + Some(GameTime::parse("#d2-0100").unwrap()) + ); +} + +#[test] +fn read_lines_returns_all_entries_in_order() { + let (log, _dir) = log(); + + log.append(GameTime::parse("#d1-0830").unwrap(), "morning") + .unwrap(); + log.append(GameTime::parse("#d1-1200").unwrap(), "noon") + .unwrap(); + log.append(GameTime::parse("#d2-0100").unwrap(), "next day") + .unwrap(); + + let entries = log.read_lines().unwrap(); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].time.to_string(), "#d1-0830"); + assert_eq!(entries[1].time.to_string(), "#d1-1200"); + assert_eq!(entries[2].time.to_string(), "#d2-0100"); +} + +#[test] +fn parse_lines_ignores_non_matching_text() { + let text = "not a line\n#d1-0830 - first\n# comment\n#d1-1200 - second\n"; + + let entries = parse_lines(text); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].body, "first"); + assert_eq!(entries[1].body, "second"); +} + +#[test] +fn read_lines_on_a_non_dir_returns_empty() { + let dir = tempfile::TempDir::new().unwrap(); + let file_path = dir.path().join("log"); + std::fs::write(&file_path, "not a dir").unwrap(); + let log = CampaignLog::new(file_path); + + // days_in silently treats a non-directory as having no day files. + assert!(log.read_lines().unwrap().is_empty()); +} + +#[test] +fn append_fails_when_the_log_cannot_be_created() { + let dir = tempfile::TempDir::new().unwrap(); + let parent = dir.path().join("blob"); + std::fs::write(&parent, "x").unwrap(); + let log = CampaignLog::new(parent.join("log")); + + let error = log + .append(GameTime::parse("#d1-0000").unwrap(), "x") + .unwrap_err(); + + assert!(error.contains("cannot create")); +} + +#[test] +fn append_fails_when_the_day_file_is_a_directory() { + let dir = tempfile::TempDir::new().unwrap(); + let log = CampaignLog::new(dir.path().join("log")); + day_file::ensure_dir(log.dir()).unwrap(); + std::fs::create_dir(day_path(log.dir(), 1)).unwrap(); + + let error = log + .append(GameTime::parse("#d1-0000").unwrap(), "x") + .unwrap_err(); + + assert!(error.contains("cannot open")); +} + +#[test] +fn append_fails_when_the_write_errors() { + let dir = tempfile::TempDir::new().unwrap(); + let log = CampaignLog::new(dir.path().join("log")); + day_file::ensure_dir(log.dir()).unwrap(); + std::os::unix::fs::symlink("/dev/full", day_path(log.dir(), 1)).unwrap(); + + let error = log + .append(GameTime::parse("#d1-0000").unwrap(), "x") + .unwrap_err(); + + assert!(error.contains("cannot append")); +} + +#[test] +fn append_fails_when_the_day_file_cannot_be_created() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::TempDir::new().unwrap(); + let log_dir = dir.path().join("log"); + let log = CampaignLog::new(log_dir.clone()); + day_file::ensure_dir(&log_dir).unwrap(); + std::fs::set_permissions(&log_dir, PermissionsExt::from_mode(0o500)).unwrap(); + drop(log); + + let log = CampaignLog::new(log_dir); + let error = log + .append(GameTime::parse("#d1-0000").unwrap(), "x") + .unwrap_err(); + + assert!(error.contains("cannot create")); +} + +#[test] +fn last_anchor_fails_when_the_day_file_is_a_directory() { + let dir = tempfile::TempDir::new().unwrap(); + let log_dir = dir.path().join("log"); + let log = CampaignLog::new(log_dir.clone()); + day_file::ensure_dir(&log_dir).unwrap(); + std::fs::create_dir(day_path(&log_dir, 3)).unwrap(); + + let error = log.last_anchor().unwrap_err(); + + assert!(error.contains("cannot read")); +} + +#[test] +fn read_lines_fails_when_a_day_file_is_a_directory() { + let dir = tempfile::TempDir::new().unwrap(); + let log = CampaignLog::new(dir.path().join("log")); + day_file::ensure_dir(log.dir()).unwrap(); + std::fs::create_dir(day_path(log.dir(), 1)).unwrap(); + + let error = log.read_lines().unwrap_err(); + + assert!(error.contains("cannot read")); +} diff --git a/src/campaign/mod.rs b/src/campaign/mod.rs new file mode 100644 --- /dev/null +++ b/src/campaign/mod.rs @@ -0,0 +1,112 @@ +//! The campaign: game time, the DM-only campaign log, and the shared +//! transcript, all stored as day-partitioned markdown under a world +//! directory. +//! +//! The clock is always the last anchor in the campaign log; there is no +//! separate state to drift. The transcript mirrors what the player has +//! seen, so `lookup` answers what's true and `recall` answers what +//! happened. + +mod clock; +pub mod day_file; +mod entry; +mod log; +mod recall; +mod transcript; + +pub use clock::{GameTime, START}; +pub use log::CampaignLog; +pub use recall::{RecallHit, Source, search}; +pub use transcript::Transcript; + +use std::path::Path; + +/// A world's record of time and events: the campaign log under +/// `campaign-log/`, DM-only and forward-only, and the transcript under +/// `transcript/`, shared with the player. +#[derive(Debug, Clone)] +pub struct Campaign { + log: CampaignLog, + transcript: Transcript, +} + +impl Campaign { + /// Opens (or creates) the campaign under world root `world`. + pub fn open(world: &Path) -> Result { + let campaign = Self { + log: CampaignLog::new(world.join("campaign-log")), + transcript: Transcript::new(world.join("transcript")), + }; + day_file::ensure_dir(campaign.log.dir())?; + day_file::ensure_dir(campaign.transcript.dir())?; + Ok(campaign) + } + + /// The current game clock: the last `#dX-HHMM` anchor in the campaign + /// log, or the campaign's starting moment, `#d1-0000`, when no event + /// has been marked yet. + pub fn current_time(&self) -> Result { + Ok(self.log.last_anchor()?.unwrap_or(START)) + } + + /// Logs one campaign event at `time` and advances the clock. + /// + /// The event is always written to the campaign log. `shared_text`, the + /// player-facing version of the event, is written to the transcript + /// when `Some`; `None` keeps the event behind the screen entirely. + /// + /// Time moves forward only: `time` must be later than the current + /// clock, and never before day 1. + pub fn mark( + &self, + time: GameTime, + event: &str, + shared_text: Option<&str>, + ) -> Result<(), String> { + match self.log.last_anchor()? { + Some(current) if time <= current => Err(format!( + "`time` {time} is not after the current clock {current}; the campaign \ + log moves forward only, so mark a later moment" + )), + Some(_) => Ok(()), + None if time.day < 1 => Err(format!( + "the campaign starts at {START}; `time` {time} is before the campaign" + )), + None => Ok(()), + }?; + self.log.append(time, event)?; + if let Some(shared) = shared_text { + self.transcript.append(time, shared)?; + } + Ok(()) + } + + /// Records what the player typed at the start of the turn, stamped + /// with the current game time. + pub fn append_player(&self, time: GameTime, input: &str) -> Result<(), String> { + self.transcript.append(time, &format!("player> {input}")) + } + + /// Records the DM's narration at the given game time. + /// + /// The clock at the end of the turn is used so the narration appears + /// after any `mark` that advanced time mid-turn, keeping the + /// transcript oldest-to-newest. + pub fn append_narration(&self, time: GameTime, text: &str) -> Result<(), String> { + self.transcript.append(time, text) + } + + /// Every campaign-log entry, for the recall search. + pub(crate) fn log_entries(&self) -> Result, String> { + self.log.read_lines() + } + + /// Every transcript section, for the recall search. + pub(crate) fn transcript_entries(&self) -> Result, String> { + self.transcript.read_sections() + } +} + +#[cfg(test)] +#[path = "campaign_tests.rs"] +mod tests; diff --git a/src/campaign/recall.rs b/src/campaign/recall.rs new file mode 100644 --- /dev/null +++ b/src/campaign/recall.rs @@ -0,0 +1,95 @@ +//! The `recall` search: keyword matching over the campaign log and the +//! transcript, returning what happened and when. + +use super::Campaign; +use super::clock::GameTime; + +/// Where a trace of game history lives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// The DM-only campaign log. + Log, + /// The transcript, shared with the player. + Transcript, +} + +impl Source { + /// `Source`'s two names, for errors that list them. + pub const VALUES: [&'static str; 2] = ["campaign-log", "transcript"]; + + /// Reads `text` as one of the two sources, naming the alternatives on + /// a miss. + pub fn parse(text: &str) -> Result { + match text { + "campaign-log" => Ok(Source::Log), + "transcript" => Ok(Source::Transcript), + _ => Err(format!( + "`source` was `{text}`, but it must be one of: {}", + Source::VALUES.join(", ") + )), + } + } + + /// The source's name as it appears in a recall result. + pub fn name(self) -> &'static str { + match self { + Source::Log => "campaign-log", + Source::Transcript => "transcript", + } + } +} + +/// One matching entry from history: when it happened, where it lives, and +/// what it holds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecallHit { + pub time: GameTime, + pub source: Source, + pub text: String, +} + +/// Searches `campaign`'s log and transcript (or just `filter`'s source) +/// for entries that match every `keyword`, case-insensitively, in their +/// time or body. Results come back newest first. +pub fn search( + campaign: &Campaign, + keywords: &[String], + filter: Option, +) -> Result, String> { + let mut hits = Vec::new(); + + if filter.is_none_or(|source| source == Source::Log) { + for entry in campaign.log_entries()? { + if matches(keywords, &entry.time, &entry.body) { + hits.push(RecallHit { + time: entry.time, + source: Source::Log, + text: entry.body, + }); + } + } + } + if filter.is_none_or(|source| source == Source::Transcript) { + for entry in campaign.transcript_entries()? { + if matches(keywords, &entry.time, &entry.body) { + hits.push(RecallHit { + time: entry.time, + source: Source::Transcript, + text: entry.body, + }); + } + } + } + + hits.sort_by_key(|hit| std::cmp::Reverse(hit.time)); + Ok(hits) +} + +/// True when every `keyword` appears, case-insensitively, in the entry's +/// time plus body. +fn matches(keywords: &[String], time: &GameTime, body: &str) -> bool { + let haystack = format!("{} {body}", time.to_string().to_lowercase()).to_lowercase(); + keywords + .iter() + .all(|keyword| haystack.contains(&keyword.to_lowercase())) +} diff --git a/src/campaign/transcript.rs b/src/campaign/transcript.rs new file mode 100644 --- /dev/null +++ b/src/campaign/transcript.rs @@ -0,0 +1,114 @@ +//! The transcript: the shared record of everything the player has seen. +//! +//! One zero-padded file per game-day, with a `# Day N` title, then +//! `## #dX-HHMM` headings wherever game time changes, and the content +//! beneath: `player>` lines for what the player typed, narration +//! paragraphs, and event lines from public or screened marks. Oldest +//! entry first. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::clock::GameTime; +use super::day_file::{day_path, days_in, ensure_dir}; +use super::entry::LogEntry; + +/// The shared transcript at `dir`. +#[derive(Debug, Clone)] +pub struct Transcript { + dir: PathBuf, +} + +impl Transcript { + /// A transcript rooted at `dir`. + pub fn new(dir: PathBuf) -> Self { + Self { dir } + } + + /// The directory the transcript lives in. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Appends `block` under `time`'s section, opening a new `## #dX-HHMM` + /// heading when the file's latest heading is not already `time`. + /// + /// Blocks at the same time are separated by a blank line; a time + /// change inserts a blank line then the heading. The caller must + /// format the block (e.g. `player> …` for the player's lines, or the + /// narration as-is). + pub fn append(&self, time: GameTime, block: &str) -> Result<(), String> { + ensure_dir(&self.dir)?; + let path = day_path(&self.dir, time.day); + let (prefix, header) = if path.exists() { + let existing = fs::read_to_string(&path) + .map_err(|error| format!("{}: cannot read: {error}", path.display()))?; + if last_section_time(&existing) == Some(time) { + // Same section; no new heading, no extra separator — + // the file already ends with \n\n. + (existing, String::new()) + } else { + (existing, format!("\n## {time}\n")) + } + } else { + (format!("# Day {}\n\n", time.day), format!("## {time}\n")) + }; + fs::write(&path, format!("{prefix}{header}{block}\n\n")) + .map_err(|error| format!("{}: cannot create: {error}", path.display()))?; + Ok(()) + } + + /// Every section, `(time, content)`, oldest section first. + pub fn read_sections(&self) -> Result, String> { + let mut entries = Vec::new(); + for day in days_in(&self.dir) { + let path = day_path(&self.dir, day); + let text = fs::read_to_string(&path) + .map_err(|error| format!("{}: cannot read: {error}", path.display()))?; + entries.extend(parse_sections(&text)); + } + Ok(entries) + } +} + +/// The game-time of the last `## #dX-HHMM` heading in `text`, or `None` +/// when the file has no sections yet. +fn last_section_time(text: &str) -> Option { + parse_sections(text).last().map(|entry| entry.time) +} + +/// Parses a transcript day-file into its sections: `(time, content)` +/// for each `## #dX-HHMM` heading, in order. The `# Day N` title and +/// blank lines are ignored. +fn parse_sections(text: &str) -> Vec { + let mut entries = Vec::new(); + let mut current: Option = None; + let mut body = String::new(); + + for line in text.lines() { + if line.starts_with("## ") { + if let Some(time) = current.take() { + entries.push(LogEntry { + time, + body: body.trim().to_string(), + }); + body.clear(); + } + current = GameTime::parse(line.trim_start_matches("## ").trim()); + } else if current.is_some() { + body.push_str(line); + body.push('\n'); + } + } + if let Some(time) = current { + entries.push(LogEntry { + time, + body: body.trim().to_string(), + }); + } + entries +} + +#[cfg(test)] +#[path = "transcript_tests.rs"] +mod tests; diff --git a/src/campaign/transcript_tests.rs b/src/campaign/transcript_tests.rs new file mode 100644 --- /dev/null +++ b/src/campaign/transcript_tests.rs @@ -0,0 +1,156 @@ +//! Tests for the heading-based transcript. + +use super::*; +use crate::campaign::day_file; +use std::fs; + +fn t() -> (Transcript, tempfile::TempDir) { + let dir = tempfile::TempDir::new().unwrap(); + let tr = Transcript::new(dir.path().join("x")); + (tr, dir) +} + +#[test] +fn append_writes_the_day_title_and_a_section_for_a_new_file() { + let (tr, _dir) = t(); + + tr.append(GameTime::parse("#d1-0830").unwrap(), "player> I wake up.") + .unwrap(); + + let text = fs::read_to_string(day_path(tr.dir(), 1)).unwrap(); + assert_eq!(text, "# Day 1\n\n## #d1-0830\nplayer> I wake up.\n\n"); +} + +#[test] +fn append_to_the_same_section_does_not_repeat_the_heading() { + let (tr, _dir) = t(); + + tr.append( + GameTime::parse("#d1-0830").unwrap(), + "player> I look around.", + ) + .unwrap(); + tr.append( + GameTime::parse("#d1-0830").unwrap(), + "You find dusty cobwebs.", + ) + .unwrap(); + + let text = fs::read_to_string(day_path(tr.dir(), 1)).unwrap(); + assert!( + text.matches("## #d1-0830").count() == 1, + "expected one heading, got: {text}" + ); + assert!(text.contains("player> I look around.")); + assert!(text.contains("You find dusty cobwebs.")); +} + +#[test] +fn a_time_change_opens_a_new_section() { + let (tr, _dir) = t(); + + tr.append(GameTime::parse("#d1-0830").unwrap(), "player> I wake up.") + .unwrap(); + tr.append(GameTime::parse("#d1-1200").unwrap(), "Noon arrives.") + .unwrap(); + + let text = fs::read_to_string(day_path(tr.dir(), 1)).unwrap(); + assert!(text.matches("## ").count() == 2); + assert!(text.contains("## #d1-0830")); + assert!(text.contains("## #d1-1200")); +} + +#[test] +fn read_sections_returns_every_section_in_order() { + let (tr, _dir) = t(); + + tr.append(GameTime::parse("#d1-0830").unwrap(), "player> I wake up.") + .unwrap(); + tr.append(GameTime::parse("#d1-1200").unwrap(), "Noon arrives.") + .unwrap(); + + let sections = tr.read_sections().unwrap(); + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].time.to_string(), "#d1-0830"); + assert!(sections[0].body.contains("player> I wake up.")); + assert_eq!(sections[1].time.to_string(), "#d1-1200"); + assert!(sections[1].body.contains("Noon arrives.")); +} + +#[test] +fn append_to_a_new_day_starts_a_new_day_file() { + let (tr, _dir) = t(); + + tr.append(GameTime::parse("#d1-0830").unwrap(), "player> first day.") + .unwrap(); + tr.append(GameTime::parse("#d2-0100").unwrap(), "next day.") + .unwrap(); + + assert!(day_path(tr.dir(), 2).exists()); + let text = fs::read_to_string(day_path(tr.dir(), 2)).unwrap(); + assert!(text.contains("# Day 2")); + assert!(text.contains("## #d2-0100")); + assert!(text.contains("next day.")); +} + +#[test] +fn a_dir_that_cannot_be_created_fails_append() { + let dir = tempfile::TempDir::new().unwrap(); + let parent = dir.path().join("blob"); + std::fs::write(&parent, "x").unwrap(); + let tr = Transcript::new(parent.join("x")); + + let error = tr + .append(GameTime::parse("#d1-0000").unwrap(), "x") + .unwrap_err(); + + assert!(error.contains("cannot create")); +} + +#[test] +fn read_sections_fails_when_a_day_file_is_a_directory() { + let dir = tempfile::TempDir::new().unwrap(); + let tr = Transcript::new(dir.path().join("x")); + day_file::ensure_dir(tr.dir()).unwrap(); + std::fs::create_dir(day_path(tr.dir(), 1)).unwrap(); + + let error = tr.read_sections().unwrap_err(); + + assert!(error.contains("cannot read")); +} + +#[test] +fn append_fails_when_write_fails() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::TempDir::new().unwrap(); + let tr = Transcript::new(dir.path().join("x")); + // Write a valid file first so the read branch is taken. + tr.append(GameTime::parse("#d1-0830").unwrap(), "first block.") + .unwrap(); + let path = day_path(tr.dir(), 1); + assert!(path.exists()); + // Chmod the file read-only; the next append will read (succeeds) then + // write (fails). + std::fs::set_permissions(&path, PermissionsExt::from_mode(0o400)).unwrap(); + + let error = tr + .append(GameTime::parse("#d1-0830").unwrap(), "second block") + .unwrap_err(); + + assert!(error.contains("cannot create")); // fs::write error +} + +#[test] +fn append_fails_when_the_existing_file_cannot_be_read() { + let dir = tempfile::TempDir::new().unwrap(); + let tr = Transcript::new(dir.path().join("x")); + day_file::ensure_dir(tr.dir()).unwrap(); + let path = day_path(tr.dir(), 1); + std::fs::create_dir(&path).unwrap(); + // path.exists() is true (it's a directory), so append takes the read + // branch and read_to_string fails. + let error = tr + .append(GameTime::parse("#d1-0830").unwrap(), "block") + .unwrap_err(); + assert!(error.contains("cannot read")); +} diff --git a/src/cli.rs b/src/cli.rs --- a/src/cli.rs +++ b/src/cli.rs @@ -103,11 +103,36 @@ command: SrdCommand::Verify, }) => run_srd_verify(layer_root), Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] - Some(Command::Sandbox { api_base, model }) => crate::play::run( + Some(Command::Sandbox { api_base, model }) => run_sandbox( &crate::config::Overrides { api_base, model }, session_layers, ), } +} + +/// Boots a fresh, temporary world for a sandbox session, prints where it +/// lives, and plays until the player quits. +#[cfg(not(coverage))] +fn run_sandbox( + overrides: &crate::config::Overrides, + session_layers: &[PathBuf], +) -> Result<(), String> { + use crate::play::sandbox::Sandbox; + + let sandbox = 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:"); + println!(" world: {}", sandbox.world_dir().display()); + println!(" player: {}", sandbox.player_dir().display()); + println!("mount stack, lowest first:"); + for layer in &layers { + println!(" {}", layer.display()); + } + + crate::play::run(overrides, &layers, &sandbox.world_dir()) } fn run_srd_fetch(sources: &SrdSources) -> Result<(), String> { diff --git a/src/dm/dm_tests.rs b/src/dm/dm_tests.rs --- a/src/dm/dm_tests.rs +++ b/src/dm/dm_tests.rs @@ -5,13 +5,16 @@ //! shares this file's fake-server harness rather than keeping its own //! copy. use super::*; +use crate::campaign::Campaign; use crate::knowledge::fixtures; use std::collections::HashMap; +use std::fs; use std::io::{self, BufRead, Read, Write}; use std::net::TcpListener; use std::sync::Arc; use std::sync::mpsc::{self, Receiver}; use std::thread::JoinHandle; +use tempfile::TempDir; /// One HTTP request as the fake server saw it. pub(super) struct CapturedRequest { @@ -101,6 +104,21 @@ api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), }, Arc::new(fixtures::mount(&[])), + None, + ) +} + +/// A `Dm` whose toolbox is the full default: dice, knowledge, and the +/// campaign tools, backed by a fresh campaign in `world`. +fn dm_with_campaign(api_base: String, world: &TempDir) -> Dm { + Dm::new( + Config { + api_base, + api_key: "sk-test".to_string(), + model: "gpt-4o-mini".to_string(), + }, + Arc::new(fixtures::mount(&[])), + Some(Campaign::open(world.path()).unwrap()), ) } @@ -154,6 +172,30 @@ assert_eq!(messages[0]["content"], SYSTEM_PROMPT.trim_end()); let last = messages.last().unwrap(); assert_eq!(last["role"], "user"); assert_eq!(last["content"], "I open the door."); +} + +#[test] +fn a_turn_with_a_campaign_logs_its_narration_to_the_transcript() { + let body = "data: {\"choices\":[{\"delta\":{\"content\":\"You wake.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, _requests, server) = fake_server(vec![sse_response(body)]); + let world = TempDir::new().unwrap(); + let mut dm = dm_with_campaign(url, &world); + + let turn = dm.turn("I sleep.", &mut ignore_event).unwrap(); + + assert_eq!(turn, Turn::Reply("You wake.".to_string())); + server.join().unwrap(); + // No mark has set the clock, so the narration carries the campaign's + // starting moment, day 1 at midnight, in the shared transcript. The + // player's line lands first, then the narration, under one `##` + // heading. + let transcript = fs::read_to_string(world.path().join("transcript/0001.md")).unwrap(); + assert_eq!( + transcript, + "# Day 1\n\n## #d1-0000\nplayer> I sleep.\n\nYou wake.\n\n" + ); + assert!(!world.path().join("campaign-log/0001.md").exists()); } #[test] diff --git a/src/dm/dm_tool_round_tests.rs b/src/dm/dm_tool_round_tests.rs --- a/src/dm/dm_tool_round_tests.rs +++ b/src/dm/dm_tool_round_tests.rs @@ -22,6 +22,7 @@ api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), }, toolbox, + None, ) } diff --git a/src/dm/mod.rs b/src/dm/mod.rs --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -8,13 +8,17 @@ use rand::rngs::StdRng; use ratatui::text::Text; use serde_json::Value; +use crate::campaign::Campaign; use crate::chat::{ChatError, Client, Message, Role, StreamItem}; use crate::config::Config; use crate::knowledge::Mount; +use tools::Tool; use tools::Toolbox; use tools::dice::DiceTool; use tools::lookup::LookupTool; +use tools::mark::MarkTool; use tools::read::ReadTool; +use tools::recall::RecallTool; pub mod tools; @@ -68,18 +72,20 @@ Tool(Text<'static>), } /// A dungeon master session: a chat client, the tools it can call -/// mid-turn, and the history of the conversation so far. +/// mid-turn, the campaign it records to, and the history of the +/// conversation so far. pub struct Dm { client: Client, toolbox: Toolbox, + campaign: Option, history: Vec, } impl Dm { - /// Builds a `Dm` from `config` and `mount`, seeding the history with - /// the system prompt and the toolbox with the dice tool and the two - /// knowledge tools, `lookup` and `read`, which both search and fetch - /// from `mount`. + /// Builds a `Dm` from `config`, `mount`, and `campaign`, seeding the + /// history with the system prompt and the toolbox with the dice tool, + /// the two knowledge tools (`lookup` and `read`), and, when a + /// `campaign` is given, the two history tools (`mark` and `recall`). /// /// The dice tool rolls with a `StdRng` seeded from `rand::make_rng` /// rather than the thread-local `rand::rng()` directly: a `Dm` moves @@ -89,20 +95,26 @@ /// /// Opening `mount` is fallible, since it reads files from disk; that /// belongs to the caller, which is better placed to turn a failed /// open into a clean error instead of a panic. - pub fn new(config: Config, mount: Arc) -> Self { + pub fn new(config: Config, mount: Arc, campaign: Option) -> Self { let rng: StdRng = rand::make_rng(); - let toolbox = Toolbox::new(vec![ + let mut tools: Vec> = vec![ Box::new(DiceTool::new(rng)), Box::new(LookupTool::new(Arc::clone(&mount))), Box::new(ReadTool::new(mount)), - ]); - Self::with_toolbox(config, toolbox) + ]; + if let Some(campaign) = campaign.clone() { + tools.push(Box::new(MarkTool::new(campaign.clone()))); + tools.push(Box::new(RecallTool::new(campaign))); + } + let toolbox = Toolbox::new(tools); + Self::with_toolbox(config, toolbox, campaign) } /// Builds a `Dm` from `config` and `toolbox`, for callers that need a /// toolbox other than the default, such as a test with a seeded dice - /// tool or a fake one. - pub fn with_toolbox(config: Config, toolbox: Toolbox) -> Self { + /// tool or a fake one. `campaign` is where narration is recorded; a + /// `None` records nothing. + pub fn with_toolbox(config: Config, toolbox: Toolbox, campaign: Option) -> Self { let client = Client { api_base: config.api_base, api_key: config.api_key, @@ -112,6 +124,7 @@ let history = vec![system_message(SYSTEM_PROMPT.trim_end())]; Self { client, toolbox, + campaign, history, } } @@ -163,6 +176,22 @@ let mut round_messages: Vec = Vec::new(); let mut tool_only_rounds: usize = 0; let mut rounds: usize = 0; + // The narration of the whole turn, so the transcript holds every + // round's words, not just the last one's. + let mut turn_narration = String::new(); + // The clock at the start of the turn, before any mark mid-turn + // advances it, stamps the player's line in the transcript. + let start_time = self + .campaign + .as_ref() + .and_then(|campaign| campaign.current_time().ok()); + if let (Some(campaign), Some(time)) = (&self.campaign, start_time) { + // The player's line goes in at the start-of-turn clock so it + // appears in chronological order before any mark that + // advances time mid-turn. + let _ = campaign.append_player(time, input); + } + loop { let withheld = tool_only_rounds >= MAX_TOOL_ONLY_ROUNDS || rounds >= MAX_ROUNDS; @@ -187,6 +216,7 @@ for item in &mut stream { match item? { StreamItem::Text(text) => { narration.push_str(&text); + turn_narration.push_str(&text); if on_delta(TurnDelta::Text(text)).is_break() { return Ok(Turn::Cancelled(narration)); } @@ -225,6 +255,15 @@ if withheld || calls.is_empty() { self.history.push(user_message); self.history.extend(round_messages); self.history.push(assistant_message(narration.clone())); + if let Some(campaign) = &self.campaign { + // Stamp the narration with the clock at the end of + // the turn, after any marks that advanced it, so the + // transcript stays oldest-to-newest. + let now = campaign.current_time().ok().or(start_time); + if let (Some(time), true) = (now, !turn_narration.trim().is_empty()) { + let _ = campaign.append_narration(time, &turn_narration); + } + } return Ok(Turn::Reply(narration)); } diff --git a/src/dm/system-prompt.md b/src/dm/system-prompt.md --- a/src/dm/system-prompt.md +++ b/src/dm/system-prompt.md @@ -6,4 +6,6 @@ Every tool call takes a `visibility`. Use `public` when a player at a real table would see the dice. Use `screened` or `secret` to keep a roll from the player until its outcome should come out: `screened` shows them that something happened behind the screen, `secret` shows them nothing. You also have a knowledge tree: game rules under `rules/` and world facts under `lore/`. Use `lookup` to search it by keyword, and `read` to fetch an entry by its address. A `[[address]]` reference inside an entry's text is itself an address; resolve it with `read`. When a rule matters, look it up instead of guessing. +The campaign has a clock. When game time passes, use `mark` to advance it and log the event. To remember what has already happened, use `recall`. + Narrate between rolls. Don't run a long silent stretch of tool calls with nothing said in between. diff --git a/src/dm/tools/dice.rs b/src/dm/tools/dice.rs --- a/src/dm/tools/dice.rs +++ b/src/dm/tools/dice.rs @@ -5,19 +5,25 @@ //! optional reason. `render` turns that outcome into the lines the //! transcript shows. `call` runs the two in sequence. use rand::Rng; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use serde_json::{Value, json}; use crate::dice::{self, RollResult}; -use super::{Tool, ToolReply}; +use super::{Tool, ToolReply, Visibility}; const DICE_MD: &str = include_str!("dice.md"); /// The die shown at the front of a public roll's line. const DIE_MARKER: &str = "🎲"; +/// The style of a public roll's total: bold and full white, so the one +/// number the player cares about pops out of the otherwise dim line. +fn total_style() -> Style { + Style::new().fg(Color::White).add_modifier(Modifier::BOLD) +} + /// What the transcript shows for a screened roll: no numbers, just that a /// roll happened behind the screen. const SCREENED_LINE: &str = "⚁ the DM rolls behind the screen"; @@ -104,7 +110,7 @@ spans.push(Span::raw("]")); if result.modifier != 0 { spans.push(Span::raw(format!(" {:+}", result.modifier))); } - spans.push(Span::raw(format!(" = {}", result.total))); + spans.push(Span::styled(format!(" = {}", result.total), total_style())); Text::from(Line::from(spans)) } @@ -185,7 +191,7 @@ }, }) } - fn call(&mut self, args: &Value) -> Result { + fn call(&mut self, args: &Value, _visibility: Visibility) -> Result { let outcome = execute(args, &mut self.rng)?; let (public, screened) = render(&outcome); Ok(ToolReply { diff --git a/src/dm/tools/dice_tests.rs b/src/dm/tools/dice_tests.rs --- a/src/dm/tools/dice_tests.rs +++ b/src/dm/tools/dice_tests.rs @@ -39,7 +39,7 @@ Span::raw("🎲 Perception check: 1d20+3 → ["), Span::raw("17"), Span::raw("]"), Span::raw(" +3"), - Span::raw(" = 20"), + Span::styled(" = 20", total_style()), ])) ); } @@ -57,7 +57,7 @@ Span::raw("🎲 1d20+3 → ["), Span::raw("17"), Span::raw("]"), Span::raw(" +3"), - Span::raw(" = 20"), + Span::styled(" = 20", total_style()), ])) ); } @@ -76,7 +76,7 @@ Span::raw("17"), Span::raw(", "), Span::styled("15", dim_style()), Span::raw("]"), - Span::raw(" = 17"), + Span::styled(" = 17", total_style()), ])) ); } @@ -95,7 +95,7 @@ Span::styled("1", dim_style()), Span::raw(", "), Span::raw("11"), Span::raw("]"), - Span::raw(" = 11"), + Span::styled(" = 11", total_style()), ])) ); } @@ -112,7 +112,7 @@ Text::from(Line::from(vec![ Span::raw("🎲 1d3 → ["), Span::raw("3"), Span::raw("]"), - Span::raw(" = 3"), + Span::styled(" = 3", total_style()), ])) ); } @@ -130,7 +130,7 @@ Span::raw("🎲 1d20-3 → ["), Span::raw("17"), Span::raw("]"), Span::raw(" -3"), - Span::raw(" = 14"), + Span::styled(" = 14", total_style()), ])) ); } @@ -274,7 +274,9 @@ #[test] fn call_composes_execute_and_render_into_a_reply() { let mut tool = DiceTool::new(StdRng::seed_from_u64(0)); - let reply = tool.call(&json!({ "notation": "1d20+3" })).unwrap(); + let reply = tool + .call(&json!({ "notation": "1d20+3" }), Visibility::Public) + .unwrap(); assert!(reply.for_model.contains("total: 20")); assert_eq!( @@ -284,7 +286,7 @@ Span::raw("🎲 1d20+3 → ["), Span::raw("17"), Span::raw("]"), Span::raw(" +3"), - Span::raw(" = 20"), + Span::styled(" = 20", total_style()), ])) ); } @@ -293,7 +295,7 @@ #[test] fn call_propagates_an_execute_error() { let mut tool = DiceTool::new(StdRng::seed_from_u64(0)); - let error = tool.call(&json!({})).unwrap_err(); + let error = tool.call(&json!({}), Visibility::Public).unwrap_err(); assert_eq!( error, diff --git a/src/dm/tools/lookup.rs b/src/dm/tools/lookup.rs --- a/src/dm/tools/lookup.rs +++ b/src/dm/tools/lookup.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use crate::knowledge::{Mount, SearchResult, search}; -use super::{Tool, ToolReply}; +use super::{Tool, ToolReply, Visibility}; const LOOKUP_MD: &str = include_str!("lookup.md"); @@ -217,7 +217,7 @@ }, }) } - fn call(&mut self, args: &Value) -> Result { + fn call(&mut self, args: &Value, _visibility: Visibility) -> Result { let outcome = execute(args, &self.mount)?; let (public, screened) = render(&outcome); Ok(ToolReply { diff --git a/src/dm/tools/lookup_tests.rs b/src/dm/tools/lookup_tests.rs --- a/src/dm/tools/lookup_tests.rs +++ b/src/dm/tools/lookup_tests.rs @@ -379,7 +379,9 @@ #[test] fn call_composes_execute_and_render_into_a_reply() { let mut tool = tool(goblin_mount()); - let reply = tool.call(&json!({ "keywords": ["goblin"] })).unwrap(); + let reply = tool + .call(&json!({ "keywords": ["goblin"] }), Visibility::Public) + .unwrap(); assert!(reply.for_model.contains("A small, cunning creature.")); assert_eq!( @@ -394,7 +396,7 @@ #[test] fn call_propagates_an_execute_error() { let mut tool = tool(goblin_mount()); - let error = tool.call(&json!({})).unwrap_err(); + let error = tool.call(&json!({}), Visibility::Public).unwrap_err(); assert_eq!( error, diff --git a/src/dm/tools/mark.md b/src/dm/tools/mark.md new file mode 100644 --- /dev/null +++ b/src/dm/tools/mark.md @@ -0,0 +1,13 @@ +Advance game time by marking a campaign event. + +Give `time` in #dX-HHMM notation: the day number, then the hour and minute, like `#d1-0830`. Time moves forward only — `time` must come after the current clock, and never before day 1. + +Give `event`, a one-line summary of what happened. The event is always written to the campaign log, the DM's private record. Use inline markdown and wikilinks only; the event must be a single line. + +What the player sees depends on `visibility`: + +- `public`: the player sees `event`. +- `screened`: the player sees the `screened` text instead, which you supply — the event with its sensitive details left out. +- `secret`: the player sees nothing; the event stays behind the screen entirely. + +A `screened` event requires the `screened` text, and `screened` text is only valid on a `screened` event. Writing to the transcript (what the player has seen) is done for you according to `visibility`. diff --git a/src/dm/tools/mark.rs b/src/dm/tools/mark.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/mark.rs @@ -0,0 +1,204 @@ +//! The `mark` tool: advance game time and log a campaign event. +//! +//! `execute` turns tool arguments into an `Outcome` — the new time, the +//! event, and the screened text. `call` validates the visibility rules, +//! writes to the campaign, and composes the lines the transcript shows. + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use serde_json::{Value, json}; + +use crate::campaign::{Campaign, GameTime}; + +use super::{Tool, ToolReply, Visibility}; + +const MARK_MD: &str = include_str!("mark.md"); + +/// The clock shown at the front of a mark's line. +const CLOCK_MARKER: &str = "⏰"; + +/// The `mark` tool: advances the campaign clock and logs an event. +pub struct MarkTool { + campaign: Campaign, +} + +impl MarkTool { + /// Builds a mark tool that writes to `campaign`. + pub fn new(campaign: Campaign) -> Self { + Self { campaign } + } +} + +/// A mark this tool logged: the new time, the event, and the screened +/// text the player sees on a screened mark. +struct Outcome { + time: GameTime, + event: String, + screened: Option, +} + +/// Turns tool arguments into a `mark` `Outcome`. +/// +/// `time` and `event` are required strings; `screened` is optional but +/// must be a string when present. Any other key in `args` is ignored. A +/// `time` that does not parse as `#dX-HHMM` reports what it needs. +fn execute(args: &Value) -> Result { + let time = parse_time(args)?; + let event = parse_event(args)?; + let screened = parse_screened(args)?; + Ok(Outcome { + time, + event, + screened, + }) +} + +/// Reads `time` from `args` and parses it as game time. +fn parse_time(args: &Value) -> Result { + let Some(text) = args.get("time").and_then(Value::as_str) else { + return Err("`time` is required; give the new game time like `#d1-0830`".to_string()); + }; + GameTime::parse(text).ok_or_else(|| { + format!("`time` was `{text}`, but it must be game time in `#dX-HHMM` form, like `#d1-0830`") + }) +} + +/// Reads `event` from `args`: a required string. +fn parse_event(args: &Value) -> Result { + match args.get("event") { + None => Err("`event` is required; give a one-line summary of what happened".to_string()), + Some(Value::String(event)) if event.lines().count() <= 1 && !event.trim().is_empty() => { + Ok(event.clone()) + } + Some(Value::String(event)) if event.lines().count() > 1 => Err( + "`event` must be a single line; use inline markdown, not headings or blocks" + .to_string(), + ), + Some(Value::String(_)) => Err("`event` must not be empty".to_string()), + Some(other) => Err(format!( + "`event` was `{other}`, but it must be a string like `the party leaves the inn`" + )), + } +} + +/// Reads `screened` from `args`: an optional string. +fn parse_screened(args: &Value) -> Result, String> { + match args.get("screened") { + None => Ok(None), + Some(Value::String(screened)) if !screened.trim().is_empty() => Ok(Some(screened.clone())), + Some(Value::String(_)) => Err("`screened` must not be empty".to_string()), + Some(other) => Err(format!( + "`screened` was `{other}`, but it must be a string like `something stirs in the shadows`" + )), + } +} + +/// The public line: the clock Marker, the new time, and the event. +fn public_line(outcome: &Outcome) -> Text<'static> { + Text::from(Line::from(Span::raw(format!( + "{CLOCK_MARKER} {} — {}", + outcome.time, outcome.event + )))) +} + +/// The screened line: the same mark with the event's sensitive details +/// left out, as the DM supplied it. +fn screened_line(outcome: &Outcome) -> Text<'static> { + let shown = outcome.screened.as_deref().unwrap_or("time passes"); + Text::from(Line::from(Span::styled( + format!("{CLOCK_MARKER} {} — {shown}", outcome.time), + dim_style(), + ))) +} + +fn dim_style() -> Style { + Style::new().add_modifier(Modifier::DIM) +} + +/// The tool result text telling the DM where the mark was written. +fn for_model(outcome: &Outcome, visibility: Visibility) -> String { + match visibility { + Visibility::Public => format!( + "Marked {}: {}. Written to the campaign log and the transcript.", + outcome.time, outcome.event + ), + Visibility::Screened => format!( + "Marked {}: {}. Written to the campaign log; the player sees: {}.", + outcome.time, + outcome.event, + outcome.screened.as_deref().unwrap_or_default() + ), + Visibility::Secret => format!( + "Marked {}: {}. Written to the campaign log only; the player sees nothing.", + outcome.time, outcome.event + ), + } +} + +impl Tool for MarkTool { + fn name(&self) -> &'static str { + "mark" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "mark", + "description": MARK_MD.trim_end(), + "parameters": { + "type": "object", + "properties": { + "time": { + "type": "string", + "description": "The new game time in #dX-HHMM notation, like `#d1-0830`. Must be later than the current clock.", + }, + "event": { + "type": "string", + "description": "A one-line summary of what happened, always written to the campaign log.", + }, + "screened": { + "type": "string", + "description": "What the player sees instead of the event on a screened mark.", + }, + }, + "required": ["time", "event"], + }, + }, + }) + } + + fn call(&mut self, args: &Value, visibility: Visibility) -> Result { + let outcome = execute(args)?; + match (&outcome.screened, visibility) { + (Some(_), Visibility::Screened) | (None, Visibility::Public | Visibility::Secret) => {} + (Some(_), Visibility::Public | Visibility::Secret) => { + return Err("`screened` is only valid when `visibility` is `screened`".to_string()); + } + (None, Visibility::Screened) => { + return Err( + "`screened` is required when `visibility` is `screened`; give the text the player sees" + .to_string(), + ); + } + } + + let shared_text: Option<&str> = match visibility { + Visibility::Public => Some(&outcome.event), + Visibility::Screened => outcome.screened.as_deref(), + Visibility::Secret => None, + }; + self.campaign + .mark(outcome.time, &outcome.event, shared_text)?; + + Ok(ToolReply { + for_model: for_model(&outcome, visibility), + public: public_line(&outcome), + screened: screened_line(&outcome), + }) + } +} + +#[cfg(test)] +#[path = "mark_tests.rs"] +mod tests; diff --git a/src/dm/tools/mark_tests.rs b/src/dm/tools/mark_tests.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/mark_tests.rs @@ -0,0 +1,263 @@ +//! Tests for `mark.rs`, split out to keep the production file under the +//! project's file-length guideline. + +use super::*; +use crate::campaign::Campaign; +use serde_json::json; +use tempfile::TempDir; + +/// A mark tool backed by a fresh campaign in a temp directory. +fn campaign_tool() -> (MarkTool, TempDir) { + let dir = TempDir::new().unwrap(); + let campaign = Campaign::open(dir.path()).unwrap(); + (MarkTool::new(campaign), dir) +} + +#[test] +fn a_public_mark_writes_the_event_to_the_log_and_transcript() { + let (mut tool, dir) = campaign_tool(); + + let reply = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "The party leaves the inn.", + "visibility": "public", + }), + Visibility::Public, + ) + .unwrap(); + + assert!(reply.for_model.contains("campaign log and the transcript")); + assert_eq!( + reply.public, + Text::raw("⏰ #d1-0830 — The party leaves the inn.") + ); + let world = dir.path(); + assert!(world.join("campaign-log/0001.md").exists()); + assert!(world.join("transcript/0001.md").exists()); +} + +#[test] +fn a_secret_mark_writes_only_to_the_log() { + let (mut tool, dir) = campaign_tool(); + + tool.call( + &json!({ + "time": "#d1-0830", + "event": "A spy watches.", + "visibility": "secret", + }), + Visibility::Secret, + ) + .unwrap(); + + let world = dir.path(); + assert!(world.join("campaign-log/0001.md").exists()); + assert!(!world.join("transcript/0001.md").exists()); +} + +#[test] +fn a_screened_mark_shares_only_the_screened_text() { + let (mut tool, dir) = campaign_tool(); + + let reply = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "A spy rolls a d20 for stealth.", + "screened": "Something stirs in the shadows.", + }), + Visibility::Screened, + ) + .unwrap(); + + assert_eq!( + reply.screened, + Text::from(Line::from(Span::styled( + "⏰ #d1-0830 — Something stirs in the shadows.", + dim_style() + ))) + ); + let transcript = std::fs::read_to_string(dir.path().join("transcript/0001.md")).unwrap(); + assert!(transcript.contains("Something stirs in the shadows.")); + assert!(!transcript.contains("d20")); +} + +#[test] +fn a_mark_out_of_order_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + tool.call( + &json!({ + "time": "#d1-1200", + "event": "Noon.", + }), + Visibility::Public, + ) + .unwrap(); + + let error = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "Backdated.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("not after the current clock")); +} + +#[test] +fn a_bad_time_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ + "time": "banana", + "event": "Nonsense.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`time`")); + assert!(error.contains("#dX-HHMM")); +} + +#[test] +fn screened_requires_the_screened_text() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "A spy moves.", + }), + Visibility::Screened, + ) + .unwrap_err(); + + assert!(error.contains("`screened` is required")); +} + +#[test] +fn screened_text_is_invalid_on_a_public_mark() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ + "time": "#d1-0830", + "event": "A spy moves.", + "screened": "Something stirs.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("only valid when `visibility` is `screened`")); +} + +#[test] +fn a_missing_event_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call(&json!({ "time": "#d1-0830" }), Visibility::Public) + .unwrap_err(); + + assert!(error.contains("`event` is required")); +} + +#[test] +fn a_missing_time_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call(&json!({ "event": "The party camps." }), Visibility::Public) + .unwrap_err(); + + assert!(error.contains("`time` is required")); +} + +#[test] +fn an_empty_event_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ "time": "#d1-0830", "event": " " }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`event` must not be empty")); +} + +#[test] +fn a_multi_line_event_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ "time": "#d1-0830", "event": "line one\nline two" }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("single line")); +} + +#[test] +fn a_non_string_event_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ "time": "#d1-0830", "event": 5 }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`event` was `5`")); +} + +#[test] +fn an_empty_screened_text_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ "time": "#d1-0830", "event": "A spy", "screened": " " }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`screened` must not be empty")); +} + +#[test] +fn a_non_string_screened_text_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ "time": "#d1-0830", "event": "A spy", "screened": 5 }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`screened` was `5`")); +} + +#[test] +fn the_definition_names_the_tool() { + let (tool, _dir) = campaign_tool(); + + assert_eq!(tool.definition()["function"]["name"], "mark"); +} diff --git a/src/dm/tools/mod.rs b/src/dm/tools/mod.rs --- a/src/dm/tools/mod.rs +++ b/src/dm/tools/mod.rs @@ -13,7 +13,9 @@ use serde_json::{Value, json}; pub mod dice; pub mod lookup; +pub mod mark; pub mod read; +pub mod recall; const VISIBILITY_DESCRIPTION: &str = include_str!("visibility.md"); @@ -31,11 +33,15 @@ /// without the `visibility` parameter. `Toolbox::definitions` adds /// it before the declaration reaches the model. fn definition(&self) -> Value; - /// Runs the tool with `args`, already stripped of `visibility`. + /// Runs the tool with `args`, already stripped of `visibility`, and + /// the parsed `visibility` of this call. + /// + /// A tool that writes player-facing output — the transcript, for + /// example — uses `visibility` to decide what the player sees. /// /// `Err` becomes the tool result sent back to the model, so its text /// must name the problem and the fix. The player never sees it. - fn call(&mut self, args: &Value) -> Result; + fn call(&mut self, args: &Value, visibility: Visibility) -> Result; } /// What a tool call produced: the text the model sees, and the two ways @@ -163,7 +169,7 @@ let mut stripped = object.clone(); stripped.remove("visibility"); - match tool.call(&Value::Object(stripped)) { + match tool.call(&Value::Object(stripped), visibility) { Ok(reply) => outcome(reply, visibility), Err(message) => error(message), } diff --git a/src/dm/tools/read.rs b/src/dm/tools/read.rs --- a/src/dm/tools/read.rs +++ b/src/dm/tools/read.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use crate::knowledge::{Entry, Mount, normalize_address}; -use super::{Tool, ToolReply}; +use super::{Tool, ToolReply, Visibility}; const READ_MD: &str = include_str!("read.md"); @@ -111,7 +111,7 @@ }, }) } - fn call(&mut self, args: &Value) -> Result { + fn call(&mut self, args: &Value, _visibility: Visibility) -> Result { let entry = execute(args, &self.mount)?; let (public, screened) = render(&entry); Ok(ToolReply { diff --git a/src/dm/tools/read_tests.rs b/src/dm/tools/read_tests.rs --- a/src/dm/tools/read_tests.rs +++ b/src/dm/tools/read_tests.rs @@ -152,7 +152,10 @@ fn call_composes_execute_and_render_into_a_reply() { let mut tool = tool(goblin_mount()); let reply = tool - .call(&json!({ "address": "rules/monsters/goblin" })) + .call( + &json!({ "address": "rules/monsters/goblin" }), + Visibility::Public, + ) .unwrap(); assert!(reply.for_model.contains("A small, cunning creature.")); @@ -167,7 +170,10 @@ fn call_propagates_an_execute_error() { let mut tool = tool(goblin_mount()); let error = tool - .call(&json!({ "address": "rules/monsters/orc" })) + .call( + &json!({ "address": "rules/monsters/orc" }), + Visibility::Public, + ) .unwrap_err(); assert_eq!( diff --git a/src/dm/tools/recall.md b/src/dm/tools/recall.md new file mode 100644 --- /dev/null +++ b/src/dm/tools/recall.md @@ -0,0 +1,7 @@ +Search what has happened in the campaign by keyword. + +Give one or more `keywords`. An entry matches when every keyword appears, case-insensitively, in its time or its text. + +`recall` searches the campaign log, the DM's private record of events, and the transcript, everything the player has seen. Use `source` to limit the search to one of them: `campaign-log` or `transcript`. + +Results come back newest first, each with its time anchor and its source. This is the record of what happened; `lookup` is for what is true — world entities, lore, and rules. diff --git a/src/dm/tools/recall.rs b/src/dm/tools/recall.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/recall.rs @@ -0,0 +1,185 @@ +//! The `recall` tool: search game history — the campaign log and the +//! transcript — by keyword. +//! +//! `execute` turns tool arguments into a searched `Outcome`. `render` +//! turns that outcome into the lines the transcript shows. `call` runs +//! the two in sequence and composes the tool result text. + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use serde_json::{Value, json}; + +use crate::campaign::{Campaign, RecallHit, Source, search}; + +use super::{Tool, ToolReply, Visibility}; + +const RECALL_MD: &str = include_str!("recall.md"); + +/// The magnifier shown at the front of a recall's line. +const RECALL_MARKER: &str = "🔍"; + +/// What the transcript shows for a screened recall: no results named, +/// just that the DM consulted the record. +const SCREENED_LINE: &str = "🔍 the DM consults the record"; + +/// The `recall` tool: searches a campaign's history by keyword. +pub struct RecallTool { + campaign: Campaign, +} + +impl RecallTool { + /// Builds a recall tool that searches `campaign`. + pub fn new(campaign: Campaign) -> Self { + Self { campaign } + } +} + +/// A recall this tool ran: the keywords given, and what matched. +#[derive(Debug)] +struct Outcome { + keywords: Vec, + hits: Vec, +} + +/// Turns tool arguments into a searched `Outcome`. +/// +/// `keywords` is required and must be a nonempty array of strings; +/// `source` is optional but must be `campaign-log` or `transcript` when +/// present. Any other key in `args` is ignored. +fn execute(args: &Value, campaign: &Campaign) -> Result { + let keywords = parse_keywords(args)?; + let source = parse_source(args)?; + let hits = search(campaign, &keywords, source)?; + Ok(Outcome { keywords, hits }) +} + +/// Reads `keywords` from `args`: a required, nonempty array of strings. +fn parse_keywords(args: &Value) -> Result, String> { + let keywords = match args.get("keywords") { + None => { + return Err( + "`keywords` is required; give one or more keywords like `[\"goblin\"]`".to_string(), + ); + } + Some(Value::Array(keywords)) => keywords, + Some(other) => { + return Err(format!( + "`keywords` was `{other}`, but it must be an array of strings like `[\"goblin\"]`" + )); + } + }; + if keywords.is_empty() { + return Err("`keywords` must not be empty; give at least one keyword".to_string()); + } + keywords + .iter() + .map(|keyword| match keyword { + Value::String(keyword) => Ok(keyword.clone()), + other => Err(format!( + "`keywords` contains `{other}`, but every keyword must be a string" + )), + }) + .collect() +} + +/// Reads `source` from `args`: an optional source name. +fn parse_source(args: &Value) -> Result, String> { + match args.get("source") { + None => Ok(None), + Some(Value::String(source)) => Source::parse(source).map(Some), + Some(other) => Err(format!( + "`source` was `{other}`, but it must be a string like `campaign-log`" + )), + } +} + +/// Renders an `Outcome` as the public and screened transcript lines. +fn render(outcome: &Outcome) -> (Text<'static>, Text<'static>) { + (public_line(outcome), screened_line()) +} + +/// The public line: the keywords searched, and how much matched. +fn public_line(outcome: &Outcome) -> Text<'static> { + let query = outcome.keywords.join(" "); + let found = match outcome.hits.len() { + 0 => "no matches".to_string(), + 1 => "1 match".to_string(), + n => format!("{n} matches"), + }; + Text::from(Line::from(Span::raw(format!( + "{RECALL_MARKER} recall \"{query}\" → {found}" + )))) +} + +/// The screened line: the DM consulted the record, with nothing else said. +fn screened_line() -> Text<'static> { + Text::from(Line::from(Span::styled(SCREENED_LINE, dim_style()))) +} + +fn dim_style() -> Style { + Style::new().add_modifier(Modifier::DIM) +} + +/// The tool result text: every hit's time and source with its text, and, +/// on zero hits, what to try next. +fn for_model(outcome: &Outcome) -> String { + if outcome.hits.is_empty() { + return format!( + "No entries matched \"{}\". Try a shorter keyword or a different word; a \ + match must appear, case-insensitively, in an entry's time or text.", + outcome.keywords.join(" ") + ); + } + outcome + .hits + .iter() + .map(|hit| format!("- {} [{}]: {}", hit.time, hit.source.name(), hit.text)) + .collect::>() + .join("\n") +} + +impl Tool for RecallTool { + fn name(&self) -> &'static str { + "recall" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "recall", + "description": RECALL_MD.trim_end(), + "parameters": { + "type": "object", + "properties": { + "keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords to search for; every keyword must hit for an entry to match, like [\"goblin\"] or [\"goblin\", \"ambush\"].", + }, + "source": { + "type": "string", + "enum": Source::VALUES, + "description": "Restrict the search to the campaign log or the transcript. Leave out to search both.", + }, + }, + "required": ["keywords"], + }, + }, + }) + } + + fn call(&mut self, args: &Value, _visibility: Visibility) -> Result { + let outcome = execute(args, &self.campaign)?; + let (public, screened) = render(&outcome); + Ok(ToolReply { + for_model: for_model(&outcome), + public, + screened, + }) + } +} + +#[cfg(test)] +#[path = "recall_tests.rs"] +mod tests; diff --git a/src/dm/tools/recall_tests.rs b/src/dm/tools/recall_tests.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/recall_tests.rs @@ -0,0 +1,190 @@ +//! Tests for `recall.rs`, split out to keep the production file under the +//! project's file-length guideline. + +use super::*; +use crate::campaign::{Campaign, GameTime}; +use serde_json::json; +use tempfile::TempDir; + +/// A recall tool backed by a campaign holding two marks and a narration. +fn seeded_tool() -> (RecallTool, TempDir) { + let dir = TempDir::new().unwrap(); + let campaign = Campaign::open(dir.path()).unwrap(); + campaign + .mark( + GameTime::parse("#d1-0830").unwrap(), + "The party meets a goblin.", + Some("A goblin blocks the road."), + ) + .unwrap(); + campaign + .append_narration( + GameTime::parse("#d1-0900").unwrap(), + "The goblin snarls and draws a rusty blade.", + ) + .unwrap(); + (RecallTool::new(campaign), dir) +} + +fn call(tool: &mut RecallTool, args: Value) -> ToolReply { + tool.call(&args, Visibility::Public).unwrap() +} + +#[test] +fn keyword_call_finds_matching_entries_with_their_time_and_source() { + let (mut tool, _dir) = seeded_tool(); + + let reply = call(&mut tool, json!({ "keywords": ["goblin"] })); + + assert_eq!( + reply.for_model, + "- #d1-0900 [transcript]: The goblin snarls and draws a rusty blade.\n- #d1-0830 [campaign-log]: The party meets a goblin.\n- #d1-0830 [transcript]: A goblin blocks the road." + ); +} + +#[test] +fn matching_is_case_insensitive() { + let (mut tool, _dir) = seeded_tool(); + + let reply = call(&mut tool, json!({ "keywords": ["GOBLIN"] })); + + assert_eq!( + reply.for_model, + "- #d1-0900 [transcript]: The goblin snarls and draws a rusty blade.\n- #d1-0830 [campaign-log]: The party meets a goblin.\n- #d1-0830 [transcript]: A goblin blocks the road." + ); +} + +#[test] +fn the_public_line_counts_the_matches() { + let (mut tool, _dir) = seeded_tool(); + + let reply = call(&mut tool, json!({ "keywords": ["goblin"] })); + + assert_eq!(reply.public, Text::raw("🔍 recall \"goblin\" → 3 matches")); +} + +#[test] +fn a_screened_recall_shows_the_screened_line() { + let (mut tool, _dir) = seeded_tool(); + + let reply = tool + .call(&json!({ "keywords": ["goblin"] }), Visibility::Screened) + .unwrap(); + + assert_eq!( + reply.screened, + Text::from(Line::from(Span::styled(SCREENED_LINE, dim_style()))) + ); +} + +#[test] +fn a_source_filter_restricts_the_search() { + let (mut tool, _dir) = seeded_tool(); + + let reply = call( + &mut tool, + json!({ "keywords": ["goblin"], "source": "transcript" }), + ); + + assert_eq!( + reply.for_model, + "- #d1-0900 [transcript]: The goblin snarls and draws a rusty blade.\n- #d1-0830 [transcript]: A goblin blocks the road." + ); +} + +#[test] +fn an_invalid_source_is_rejected() { + let (mut tool, _dir) = seeded_tool(); + + let error = tool + .call( + &json!({ "keywords": ["goblin"], "source": "lore" }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`source`")); + assert!(error.contains("campaign-log")); +} + +#[test] +fn no_match_names_what_to_try_next() { + let (mut tool, _dir) = seeded_tool(); + + let reply = call(&mut tool, json!({ "keywords": ["dragon"] })); + + assert!(reply.for_model.contains("No entries matched")); + assert_eq!(reply.public, Text::raw("🔍 recall \"dragon\" → no matches")); +} + +#[test] +fn missing_keywords_are_rejected() { + let (mut tool, _dir) = seeded_tool(); + + let error = tool.call(&json!({}), Visibility::Public).unwrap_err(); + + assert!(error.contains("`keywords` is required")); +} + +#[test] +fn non_array_keywords_are_rejected() { + let (mut tool, _dir) = seeded_tool(); + + let error = tool + .call(&json!({ "keywords": "goblin" }), Visibility::Public) + .unwrap_err(); + + assert!(error.contains("must be an array of strings")); +} + +#[test] +fn empty_keywords_are_rejected() { + let (mut tool, _dir) = seeded_tool(); + + let error = tool + .call(&json!({ "keywords": [] }), Visibility::Public) + .unwrap_err(); + + assert!(error.contains("must not be empty")); +} + +#[test] +fn a_non_string_keyword_is_rejected() { + let (mut tool, _dir) = seeded_tool(); + + let error = tool + .call(&json!({ "keywords": [5] }), Visibility::Public) + .unwrap_err(); + + assert!(error.contains("every keyword must be a string")); +} + +#[test] +fn a_non_string_source_is_rejected() { + let (mut tool, _dir) = seeded_tool(); + + let error = tool + .call( + &json!({ "keywords": ["goblin"], "source": 5 }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`source` was `5`")); +} + +#[test] +fn a_single_match_is_counted_as_one() { + let (mut tool, _dir) = seeded_tool(); + + let reply = call(&mut tool, json!({ "keywords": ["rusty"] })); + + assert_eq!(reply.public, Text::raw("🔍 recall \"rusty\" → 1 match")); +} + +#[test] +fn the_definition_names_the_tool() { + let (tool, _dir) = seeded_tool(); + + assert_eq!(tool.definition()["function"]["name"], "recall"); +} diff --git a/src/dm/tools/tools_tests.rs b/src/dm/tools/tools_tests.rs --- a/src/dm/tools/tools_tests.rs +++ b/src/dm/tools/tools_tests.rs @@ -29,7 +29,7 @@ }, }) } - fn call(&mut self, args: &Value) -> Result { + fn call(&mut self, args: &Value, _visibility: Visibility) -> Result { match self { FakeTool::Echo => Ok(ToolReply { for_model: args.to_string(), @@ -61,7 +61,7 @@ }, }) } - fn call(&mut self, _args: &Value) -> Result { + fn call(&mut self, _args: &Value, _visibility: Visibility) -> Result { unreachable!("OtherTool is never called in these tests") } } @@ -88,7 +88,7 @@ }, }) } - fn call(&mut self, _args: &Value) -> Result { + fn call(&mut self, _args: &Value, _visibility: Visibility) -> Result { unreachable!("BareTool is never called in these tests") } } @@ -118,7 +118,7 @@ }, }) } - fn call(&mut self, _args: &Value) -> Result { + fn call(&mut self, _args: &Value, _visibility: Visibility) -> Result { unreachable!("MisnamedTool is never called in these tests") } } diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod campaign; pub mod chat; pub mod cli; pub mod config; diff --git a/src/play/mod.rs b/src/play/mod.rs --- a/src/play/mod.rs +++ b/src/play/mod.rs @@ -17,6 +17,8 @@ mod editor; mod history; pub mod keys; mod prompt; +#[cfg(not(coverage))] +pub mod sandbox; pub mod screen; pub mod sync; #[cfg(not(coverage))] diff --git a/src/play/sandbox.rs b/src/play/sandbox.rs new file mode 100644 --- /dev/null +++ b/src/play/sandbox.rs @@ -0,0 +1,65 @@ +//! The transient 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. + +use std::fs; +use std::path::PathBuf; + +/// A throwaway 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. +pub struct Sandbox { + root: tempfile::TempDir, +} + +impl Sandbox { + /// Creates a fresh sandbox with empty `world` and `player` + /// directories. + pub fn new() -> Result { + let root = tempfile::Builder::new() + .prefix("storied-sandbox-") + .tempdir() + .map_err(|error| format!("could not create a sandbox directory: {error}"))?; + for name in ["world", "player"] { + fs::create_dir_all(root.path().join(name)).map_err(|error| { + format!( + "could not create the sandbox {} directory: {error}", + root.path().join(name).display() + ) + })?; + } + Ok(Self { root }) + } + + /// The directory the session's campaign lives in. + pub fn world_dir(&self) -> PathBuf { + self.root.path().join("world") + } + + /// The directory the player's own knowledge lives in. + pub fn player_dir(&self) -> PathBuf { + self.root.path().join("player") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_sandbox_has_empty_world_and_player_directories() { + let sandbox = Sandbox::new().unwrap(); + + assert!(sandbox.world_dir().is_dir()); + assert!(sandbox.player_dir().is_dir()); + assert_eq!(std::fs::read_dir(sandbox.world_dir()).unwrap().count(), 0); + assert_eq!(std::fs::read_dir(sandbox.player_dir()).unwrap().count(), 0); + assert_ne!(sandbox.world_dir(), sandbox.player_dir()); + } +} 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::Modifier; use ratatui::text::Text; use super::editor::Editor; @@ -40,9 +41,9 @@ /// one prompt row. pub const VIEWPORT_HEIGHT: u16 = FIXED_ROWS + 1; /// The viewport rows that are neither the tail area nor the prompt: the -/// blank row that sets the rule and the prompt apart from the tail, and -/// the rule itself. -const SEPARATOR_ROWS: u16 = 2; +/// blank row that always clears the tail from the status row, the status +/// row itself, and the rule. +const SEPARATOR_ROWS: u16 = 3; /// The viewport rows above the prompt when the tail area holds one row: /// that row, the blank row, and the rule. @@ -65,6 +66,18 @@ /// Shown when the worker thread is gone. The channel disconnects only if /// the thread panicked; without this, a turn in progress would leave /// `busy` set forever and the prompt would never accept input again. const WORKER_GONE: &str = "the storyteller thread is gone; restart storied to continue"; + +/// What the DM is doing right now within a turn, for the status row's +/// phrase. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DmState { + /// Just submitted; nothing has arrived yet. + Thinking, + /// Narration is streaming in. + Storytelling, + /// A tool call is being resolved behind the screen. + Working, +} /// 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. @@ -76,9 +89,13 @@ /// at a time keeps the DM's history in order. busy: bool, /// Counts render passes, the clock the thinking indicator animates on. tick: usize, - /// Counts submits, so the thinking indicator's phrase changes turn to - /// turn. + /// Counts submits, so the thinking indicator's flavor phrase changes + /// turn to turn. turns: usize, + /// What the DM is doing right now, which the status row names. Resets + /// to [`DmState::Thinking`] on submit and follows the turn as it + /// evolves. + state: DmState, /// How many rows the viewport holds now. Every pass works out how /// many the prompt needs and resizes only when the two differ, /// because a resize costs a cursor-position query the terminal can @@ -124,6 +141,7 @@ transcript: Transcript::default(), busy: false, tick: 0, turns: 0, + state: DmState::Thinking, rows: VIEWPORT_HEIGHT, }; screen @@ -329,6 +347,7 @@ screen.transcript.place(terminal)?; terminal.draw(|frame| render(screen, frame))?; guard.end(); screen.busy = true; + screen.state = DmState::Thinking; let _ = worker.inputs.send(input.clone()); history.record(&input); Ok(()) @@ -381,8 +400,12 @@ return Ok(()); } }; match event { - TurnEvent::Delta(text) => screen.transcript.delta(&text), + TurnEvent::Delta(text) => { + screen.state = DmState::Storytelling; + screen.transcript.delta(&text); + } TurnEvent::Tool(text) => { + screen.state = DmState::Working; screen.transcript.flush(terminal)?; screen.transcript.insert_text(terminal, text)?; } @@ -430,19 +453,25 @@ screen.transcript.next_turn(); screen.turns += 1; } -/// Draws the tail area, the blank row, the rule, and the prompt's rows, -/// and puts the cursor where the next character will go. +/// Draws the tail area (the block still forming), a blank row, the status +/// row, the rule, and the prompt's rows, and puts the cursor where the +/// next character will go. +/// +/// The blank row between the tail and the status row is never populated, +/// so the thinking indicator always has one clear line above it. fn render(screen: &Screen, frame: &mut Frame) { let area = frame.area(); let rows = prompt::prompt_rows(&screen.input, area, FIXED_ROWS); - let [tail, _blank, rule, prompt_area] = Layout::vertical([ + let [tail, _blank, status, rule, prompt_area] = Layout::vertical([ Constraint::Min(0), + Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(rows), ]) .areas(area); frame.render_widget(tail_widget(screen, tail.width), tail); + frame.render_widget(status_widget(screen), status); frame.render_widget( Text::styled(RULE.repeat(rule.width as usize), aside()), rule, @@ -453,14 +482,31 @@ frame.set_cursor_position(prompt::on_screen(cursor, area)); } /// What the tail area shows: the rows of the block the DM is still -/// writing, rendered at `width`, or the thinking indicator while a turn -/// runs with nothing narrated yet. +/// writing, rendered at `width`. fn tail_widget(screen: &Screen, width: u16) -> Text<'static> { - if screen.busy && !screen.transcript.narrating() { - Text::styled(thinking::line(screen.tick, screen.turns), aside()) - } else { - Text::from(screen.transcript.forming_rows(width)) + Text::from(screen.transcript.forming_rows(width)) +} + +/// What the status row shows: the thinking indicator for the whole time a +/// turn runs, and nothing (a blank separator row) the rest of the time. +/// +/// The phrase beside the sparkle names what the DM is doing right now — +/// narrating, resolving a tool call, or still quiet at the top of the +/// turn — so it reads as "the DM is thinking or responding" rather than +/// as a static label. +fn status_widget(screen: &Screen) -> Text<'static> { + if !screen.busy { + return Text::raw(""); } + let phrase = match screen.state { + DmState::Thinking => thinking::thinking_phrase(screen.turns), + DmState::Storytelling => thinking::STORYTELLING, + DmState::Working => thinking::BEHIND_THE_SCREEN, + }; + Text::styled( + thinking::line(screen.tick, phrase), + aside().add_modifier(Modifier::ITALIC), + ) } #[cfg(test)] diff --git a/src/play/screen_editing_tests.rs b/src/play/screen_editing_tests.rs --- a/src/play/screen_editing_tests.rs +++ b/src/play/screen_editing_tests.rs @@ -163,7 +163,7 @@ steps.push(press(Key::Left)); let mut played = play_script(steps); - played.terminal.backend_mut().assert_cursor_position((3, 3)); + played.terminal.backend_mut().assert_cursor_position((3, 4)); } #[test] diff --git a/src/play/screen_prompt_tests.rs b/src/play/screen_prompt_tests.rs --- a/src/play/screen_prompt_tests.rs +++ b/src/play/screen_prompt_tests.rs @@ -52,10 +52,10 @@ assert!(!dim); } #[test] -fn a_dim_rule_sits_between_the_blank_row_and_the_prompt() { +fn a_dim_rule_sits_beneath_the_blank_and_status_rows() { let played = play_script(typing("hi")); - let (text, dim) = played.row(2); + let (text, dim) = played.row(3); assert_eq!(text, "─".repeat(40)); assert!(dim); assert_eq!(played.prompt(), "> hi"); @@ -72,9 +72,9 @@ #[test] fn an_input_that_wraps_to_two_rows_asks_for_one_taller_viewport() { // 40 characters with no space in them wrap at the prompt's 38 // columns, and every keystroke after the wrap stays on two rows. - let played = play_script_on(10, typing(&"ab".repeat(20))); + let played = play_script_on(12, typing(&"ab".repeat(20))); - assert_eq!(played.requested(), vec![5]); + assert_eq!(played.requested(), vec![6]); } #[test] @@ -83,13 +83,13 @@ let mut steps = typing(&"ab".repeat(20)); steps.push(press(Key::Backspace)); steps.push(press(Key::Backspace)); - let played = play_script_on(10, steps); + let played = play_script_on(12, steps); // The row the prompt no longer needs goes back below the viewport, // where a row of the transcript has to land to push the prompt down // to the bottom of the screen again. Nothing is waiting to go out // here, so the viewport holds the row, blank, instead. - assert_eq!(played.requested(), vec![5]); + assert_eq!(played.requested(), vec![6]); } #[test] @@ -99,9 +99,9 @@ steps.push(press(Key::Backspace)); steps.push(press(Key::Backspace)); steps.push(press(Key::Enter)); - let played = play_script_on(10, steps); + let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![5, 4]); + assert_eq!(played.requested(), vec![6, 5]); } #[test] @@ -109,9 +109,9 @@ fn submitting_asks_for_the_base_viewport_again() { let mut steps = typing(&"ab".repeat(20)); steps.push(press(Key::Enter)); - let played = play_script_on(10, steps); + let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![5, 4]); + assert_eq!(played.requested(), vec![6, 5]); assert_eq!(played.prompt(), ">"); } @@ -120,45 +120,46 @@ fn a_forming_block_of_two_rows_asks_for_one_taller_viewport() { let reply = "You wake in a cell that smells of wet stone and old smoke."; let steps = vec![Step::Turn(TurnEvent::Delta(reply.to_string()))]; - let played = play_script_on(10, steps); + let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![5]); + assert_eq!(played.requested(), vec![6]); } #[test] fn a_block_that_spilled_asks_for_no_more_than_the_cap() { let steps = vec![Step::Turn(TurnEvent::Delta(TALL_BLOCK.to_string()))]; - let played = play_script_on(10, steps); + let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![5]); + assert_eq!(played.requested(), vec![7]); } #[test] fn a_finished_reply_gives_the_tail_rows_back() { let steps = vec![Step::Turn(TurnEvent::Delta(TALL_BLOCK.to_string())), done()]; - let played = play_script_on(10, steps); + let played = play_script_on(12, steps); - assert_eq!(played.requested(), vec![5, 4]); + assert_eq!(played.requested(), vec![7, 5]); } #[test] fn an_absurdly_tall_input_stops_at_the_cap() { - // The screen is 10 rows, so the viewport takes 5 and the transcript - // keeps the other 5, however many rows the input has. - let played = play_script_on(10, vec![paste_rows(20)]); + // The screen is 12 rows, so the viewport takes at most 7 and the + // transcript keeps the rest, however many rows the input has. + let played = play_script_on(12, vec![paste_rows(20)]); - assert_eq!(played.requested(), vec![5]); + assert_eq!(played.requested(), vec![7]); } #[test] fn a_prompt_taller_than_its_room_shows_the_rows_that_end_at_the_cursor() { let played = play_script_on(10, vec![paste_rows(10)]); - // Seven rows of room, ten rows of input, cursor on the last: the - // prompt starts at row 3 and the marker is off the top with it. - assert_eq!(played.row(3).0, " row 3"); + // Six rows of room, ten rows of input, cursor on the last: the + // prompt shows the six rows that end at the cursor, and the marker + // and earlier rows are off the top with them. + assert_eq!(played.row(4).0, " row 4"); assert_eq!(played.prompt(), " row 9"); } diff --git a/src/play/screen_sync_tests.rs b/src/play/screen_sync_tests.rs --- a/src/play/screen_sync_tests.rs +++ b/src/play/screen_sync_tests.rs @@ -184,6 +184,7 @@ transcript: Transcript::default(), busy: false, tick: 0, turns: 0, + state: DmState::Thinking, rows: VIEWPORT_HEIGHT + 1, } } diff --git a/src/play/screen_tail_tests.rs b/src/play/screen_tail_tests.rs --- a/src/play/screen_tail_tests.rs +++ b/src/play/screen_tail_tests.rs @@ -86,11 +86,11 @@ let played = play_script(steps); assert!(played.viewport().chars().any(is_sparkle)); - assert!(played.viewport().contains(thinking::phrase(0))); + assert!(played.viewport().contains(thinking::thinking_phrase(0))); } #[test] -fn the_first_delta_replaces_the_thinking_indicator() { +fn the_tail_shows_the_narration_while_the_indicator_stays() { let mut steps = typing("hi"); steps.push(press(Key::Enter)); steps.push(Step::Turn(TurnEvent::Delta("You wake.".to_string()))); @@ -98,10 +98,24 @@ let played = play_script(steps); assert_eq!(played.tail(), "You wake."); + assert!(played.viewport().chars().any(is_sparkle)); } #[test] -fn the_thinking_indicator_comes_back_after_a_tool_line() { +fn the_indicator_holds_for_the_whole_turn() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Delta("You wake. ".to_string()))); + steps.push(Step::Turn(TurnEvent::Delta("You look around.".to_string()))); + + let played = play_script(steps); + + assert_eq!(played.tail(), "You wake. You look around."); + assert!(played.viewport().chars().any(is_sparkle)); +} + +#[test] +fn the_indicator_stays_through_a_tool_line() { let mut steps = typing("hi"); steps.push(press(Key::Enter)); steps.push(Step::Turn(TurnEvent::Delta("Rolling.".to_string()))); @@ -114,6 +128,49 @@ assert!(played.transcript().contains("Rolling.")); } #[test] +fn the_indicator_names_storytelling_while_narrating() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Delta("You wake.".to_string()))); + + let played = play_script(steps); + + assert!(played.viewport().contains(thinking::STORYTELLING)); +} + +#[test] +fn the_indicator_names_looking_behind_the_screen_while_calling_a_tool() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Tool(Text::raw("a tool line")))); + + let played = play_script(steps); + + assert!(played.viewport().contains(thinking::BEHIND_THE_SCREEN)); +} + +#[test] +fn the_thinking_indicator_is_italic() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + let buffer = played.terminal.backend().buffer(); + + // The status row is the third viewport row (tail, blank, status, + // rule, prompt) on the base harness. + let status: String = (0..buffer.area.width) + .map(|c| buffer[(c, 2)].symbol()) + .collect(); + let italic = (0..buffer.area.width) + .map(|c| buffer[(c, 2)].modifier) + .filter(|modifier| modifier.contains(Modifier::ITALIC)) + .count(); + assert!(status.contains(thinking::thinking_phrase(0))); + assert!(italic > 0); +} + +#[test] fn a_second_turn_shows_the_next_phrase() { let mut steps = typing("hi"); steps.push(press(Key::Enter)); @@ -123,7 +180,7 @@ steps.push(press(Key::Enter)); let played = play_script(steps); - assert!(played.viewport().contains(thinking::phrase(1))); + assert!(played.viewport().contains(thinking::thinking_phrase(1))); } #[test] @@ -133,5 +190,5 @@ steps.push(press(Key::Enter)); let played = play_script(steps); - assert!(!played.transcript().contains(thinking::phrase(0))); + assert!(!played.transcript().contains(thinking::thinking_phrase(0))); } diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -38,11 +38,11 @@ Step::Press(key) } /// A screen tall enough to hold a six-row block in the tail area: the -/// transcript keeps five rows of it, which leaves the tail twelve. +/// transcript keeps five rows of it, which leaves the tail eleven. pub(in crate::play) const ROOMY: u16 = 20; /// A screen whose tail area holds two rows. -pub(in crate::play) const CRAMPED: u16 = 10; +pub(in crate::play) const CRAMPED: u16 = 11; /// A paragraph that wraps to the four rows of [`TALL_ROWS`] at the /// harness's forty columns. @@ -351,7 +351,7 @@ #[test] fn the_cursor_sits_after_the_typed_text() { let mut played = play_script(typing("hi")); - played.terminal.backend_mut().assert_cursor_position((4, 3)); + played.terminal.backend_mut().assert_cursor_position((4, 4)); } #[test] diff --git a/src/play/terminal.rs b/src/play/terminal.rs --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -5,9 +5,8 @@ //! mode, an inline viewport anchored to the cursor, and crossterm's event //! stream. Coverage builds leave the whole module out, so it holds as //! little as it can and every decision lives in a module that tests reach. -use std::env; use std::io::{self, Stdout, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -22,6 +21,7 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::Rect; use ratatui::{Terminal, TerminalOptions, Viewport}; +use crate::campaign::Campaign; use crate::config::{self, Overrides}; use crate::dm::Dm; use crate::knowledge::Mount; @@ -47,9 +47,10 @@ /// ends the game before the terminal changes anything. Pinning the /// viewport to the bottom happens after both succeed and before the /// terminal enters raw mode, so a failure there ends the game the same /// way. -pub fn run(overrides: &Overrides, layers: &[PathBuf]) -> Result<(), String> { +pub fn run(overrides: &Overrides, layers: &[PathBuf], world_root: &Path) -> Result<(), String> { let config = config::load(overrides).map_err(|error| error.to_string())?; let mount = Arc::new(Mount::open(layers)?); + let campaign = Campaign::open(world_root)?; let banner = super::banner(&config); pin_to_bottom(VIEWPORT_HEIGHT).map_err(|error| error.to_string())?; let options = TerminalOptions { @@ -63,13 +64,8 @@ // A failed enable leaves paste as plain keystrokes instead of one // event with the pasted text kept whole; not worth failing the game // over. let _ = execute!(std::io::stdout(), EnableBracketedPaste); - let worker = Worker::spawn(Dm::new(config, mount)); - let xdg_config_home = env::var("XDG_CONFIG_HOME").ok(); - let home = env::var("HOME").unwrap_or_default(); - let history_path = config::storied_dir(xdg_config_home.as_deref(), &home) - .join("worlds") - .join("default") - .join("terminal_history"); + let worker = Worker::spawn(Dm::new(config, mount, Some(campaign))); + let history_path = world_root.join("terminal_history"); let mut history = History::load(history_path); let played = screen::play( &mut terminal, diff --git a/src/play/thinking.rs b/src/play/thinking.rs --- a/src/play/thinking.rs +++ b/src/play/thinking.rs @@ -1,11 +1,21 @@ -//! The thinking indicator: what the tail row shows between the player's -//! submit and the first streamed delta. +//! The thinking indicator: what the status row shows while a turn runs. +//! +//! The sparkle pulses, and the phrase beside it follows what the DM is +//! doing right now within the turn: a flavor line at the top of a turn +//! before anything has arrived, then `storytelling` while narration +//! streams, then `looking behind the screen` while tool calls resolve. /// The sparkle pulse, one glyph per animation frame, dim to bright and /// back. Dice glyphs are reserved for real rolls when the dice tool /// arrives. const SPARKS: [char; 8] = ['·', '✧', '✦', '✶', '✷', '✶', '✦', '✧']; +/// The phrase shown while the DM is narrating. +pub const STORYTELLING: &str = "storytelling"; + +/// The phrase shown while a tool call is being resolved. +pub const BEHIND_THE_SCREEN: &str = "looking behind the screen"; + /// The flavor phrases, one per line of `thinking.md`. const PHRASES: &str = include_str!("thinking.md"); @@ -21,15 +31,17 @@ SPARKS[(tick / 3) % SPARKS.len()] } /// The flavor phrase for the `turn`-th turn of the session, cycling -/// through `thinking.md` in file order. -pub fn phrase(turn: usize) -> &'static str { +/// through `thinking.md` in file order. It shows while the DM is quiet at +/// the top of a turn, before any word or tool call has arrived. +pub fn thinking_phrase(turn: usize) -> &'static str { let phrases = parse(PHRASES); phrases[turn % phrases.len()] } -/// The composed indicator: a die face, a phrase, and three literal dots. -pub fn line(tick: usize, turn: usize) -> String { - format!("{} {}...", frame(tick), phrase(turn)) +/// The composed indicator: a sparkle, a phrase naming what the DM is +/// doing, and three literal dots. +pub fn line(tick: usize, phrase: &str) -> String { + format!("{} {phrase}...", frame(tick)) } #[cfg(test)] @@ -52,33 +64,33 @@ assert_eq!(frame(0), frame(24)); } #[test] - fn the_phrase_starts_with_the_first_line_of_the_file() { - assert_eq!(phrase(0), "the DM peers behind the screen"); + fn the_thinking_phrase_starts_with_the_first_line_of_the_file() { + assert_eq!(thinking_phrase(0), "the DM peers behind the screen"); } #[test] - fn the_phrase_advances_with_the_turn() { - assert_eq!(phrase(1), "dice clatter behind the screen"); + fn the_thinking_phrase_advances_with_the_turn() { + assert_eq!(thinking_phrase(1), "dice clatter behind the screen"); } #[test] - fn the_phrase_wraps_after_eight_turns() { - assert_eq!(phrase(0), phrase(8)); + fn the_thinking_phrase_wraps_after_eight_turns() { + assert_eq!(thinking_phrase(0), thinking_phrase(8)); } #[test] fn the_line_contains_the_sparkle() { - assert!(line(0, 0).contains('·')); + assert!(line(0, "x").contains('·')); } #[test] fn the_line_contains_the_phrase() { - assert!(line(0, 0).contains("the DM peers behind the screen")); + assert!(line(0, STORYTELLING).contains(STORYTELLING)); } #[test] fn the_line_ends_with_three_dots() { - assert!(line(0, 0).ends_with("...")); + assert!(line(0, "x").ends_with("x...")); } #[test] diff --git a/src/play/transcript.rs b/src/play/transcript.rs --- a/src/play/transcript.rs +++ b/src/play/transcript.rs @@ -22,7 +22,7 @@ use ratatui::Terminal; use ratatui::backend::Backend; use ratatui::buffer::Buffer; use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Text}; +use ratatui::text::{Line, Span, Text}; use crate::markdown::MarkdownStream; use crate::wrap::{wrap, wrap_spans}; @@ -42,6 +42,16 @@ /// What went wrong. pub fn failure() -> Style { Style::new().fg(Color::Red) +} + +/// A dispatched tool call's line: dim (and italic) by default, so the +/// DM's bookkeeping reads as quieter than the narration around it. A tool +/// that styles one of its own spans — like a public roll's total, made +/// bold and full white — overrides the default on that span. +pub fn tool() -> Style { + Style::new() + .add_modifier(Modifier::DIM) + .add_modifier(Modifier::ITALIC) } /// What a transcript row holds. One blank row goes between two rows of @@ -124,12 +134,6 @@ /// its own edge until the round ends. `width` is what a round that /// has not started yet would render at, and it renders nothing. pub fn forming_rows(&self, width: u16) -> Vec> { self.narration.forming(self.width.unwrap_or(width)) - } - - /// True once the round in progress has taken a delta. The tail area - /// shows the thinking indicator until then. - pub fn narrating(&self) -> bool { - !self.narration.is_empty() } /// True once this turn has put a narration row or a tool line in the @@ -297,16 +301,30 @@ Ok(()) } /// Wraps `text` to the terminal width, preserving each span's style, - /// and puts every row of it in the transcript. A tool composes its - /// own styling; this adds none of its own. + /// and puts every row of it in the transcript. A span with no style + /// of its own picks up the dim tool default; a span a tool styled + /// itself — a public roll's bold-white total, say — keeps that style + /// instead. pub fn insert_text( &mut self, terminal: &Terminal, text: Text<'static>, ) -> Result<(), B::Error> { let width = terminal.size()?.width as usize; - for row in wrap_spans(text, width) { - self.emit(row, Kind::Tool); + let default = tool(); + for line in wrap_spans(text, width) { + let spans: Vec> = line + .spans + .into_iter() + .map(|span| { + if span.style == Style::default() { + Span::styled(span.content, span.style.patch(default)) + } else { + span + } + }) + .collect(); + self.emit(Line::from(spans), Kind::Tool); } Ok(()) } diff --git a/src/play/transcript_tests.rs b/src/play/transcript_tests.rs --- a/src/play/transcript_tests.rs +++ b/src/play/transcript_tests.rs @@ -91,6 +91,44 @@ assert_eq!(tool - narration, 2); } #[test] +fn a_tool_line_reaches_the_transcript_dim_and_italic() { + let text = "the DM checks a rule"; + let steps = vec![ + Step::Turn(TurnEvent::Tool(Text::raw(text.to_string()))), + done(), + ]; + + let played = play_script(steps); + + assert_eq!( + played.transcript_modifiers(text), + vec![Modifier::DIM | Modifier::ITALIC; text.chars().count()] + ); +} + +#[test] +fn a_tool_span_with_its_own_style_is_not_dimmed() { + let text = Text::from(Line::from(vec![ + Span::raw("the total is "), + Span::styled( + "20", + Style::new().fg(Color::White).add_modifier(Modifier::BOLD), + ), + ])); + let steps = vec![Step::Turn(TurnEvent::Tool(text)), done()]; + + let played = play_script(steps); + + // The bold-white total keeps its own style; only the unstyled + // "the total is " picks up the dim tool default. + assert_eq!(played.transcript_modifiers("20"), vec![Modifier::BOLD; 2]); + assert_eq!( + played.transcript_modifiers("the total is "), + vec![Modifier::DIM | Modifier::ITALIC; 13] + ); +} + +#[test] fn a_finished_reply_ending_in_blank_lines_has_no_extra_blank_rows() { let steps = vec![ Step::Turn(TurnEvent::Delta("You wake.\n\n".to_string())), diff --git a/src/play/worker.rs b/src/play/worker.rs --- a/src/play/worker.rs +++ b/src/play/worker.rs @@ -211,6 +211,7 @@ api_key: "sk-test".to_string(), model: "a-model".to_string(), }, Arc::new(fixtures::mount(&[])), + None, ) }