diff --git a/src/campaign/character.rs b/src/campaign/character.rs new file mode 100644 index 0000000..e328b5a --- /dev/null +++ b/src/campaign/character.rs @@ -0,0 +1,83 @@ +//! The characters a world holds, and the stage-taking line that says +//! which one the session plays. +//! +//! A character is a directory under `characters/` with an `entry.md` +//! inside it, so `[[characters/sister-maren]]` addresses the sheet and a +//! `log/` beside it holds the character's own history. The stage-taking +//! line is a contract between the writers and the reader: `/play` and the +//! `sheet` tool write it into the campaign log, and the engine reads it +//! back to learn who is on stage. The format lives here and nowhere else. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::knowledge::ENTRY_FILE; + +/// The directory under a world that holds the characters. +const CHARACTERS_DIR: &str = "characters"; + +/// The directory beside a character's sheet that holds the character's +/// own day-partitioned history. +const HISTORY_DIR: &str = "log"; + +/// The stage-taking line ahead of the slug. +const STAGE_PREFIX: &str = "[[characters/"; + +/// The stage-taking line after the slug. +const STAGE_SUFFIX: &str = "]] takes the stage."; + +/// The campaign-log line that puts `slug` on stage. +pub(crate) fn stage_line(slug: &str) -> String { + format!("{STAGE_PREFIX}{slug}{STAGE_SUFFIX}") +} + +/// The slug a stage-taking line names, or `None` when `line` is any other +/// event. +/// +/// The match is exact: the line is the prefix, one slug, and the suffix, +/// with nothing else around them. A slug names one directory, so it is +/// never empty and never carries a `/` or a wikilink bracket; a line that +/// puts either between the fixed halves is prose that happens to look +/// like the event, not the event. +pub(crate) fn stage_slug(line: &str) -> Option<&str> { + let slug = line + .strip_prefix(STAGE_PREFIX)? + .strip_suffix(STAGE_SUFFIX)?; + (!slug.is_empty() && !slug.contains(['/', '[', ']'])).then_some(slug) +} + +/// The sheet of the character `slug` under the world at `world`, whether +/// or not the file exists yet. +pub(crate) fn sheet_path(world: &Path, slug: &str) -> PathBuf { + world.join(CHARACTERS_DIR).join(slug).join(ENTRY_FILE) +} + +/// The directory holding `slug`'s own log under the world at `world`. +pub(crate) fn log_dir(world: &Path, slug: &str) -> PathBuf { + world.join(CHARACTERS_DIR).join(slug).join(HISTORY_DIR) +} + +/// Every character in the world at `world`, by slug, in sorted order. +/// +/// A character is a directory under `characters/` holding an +/// [`ENTRY_FILE`]. A loose file under `characters/`, and a directory with +/// no sheet in it, are not characters, so a half-built world names nobody +/// it cannot play. An empty list when the world has no `characters/` +/// directory at all. +pub(crate) fn slugs(world: &Path) -> Vec { + let mut slugs = Vec::new(); + let Ok(read_dir) = fs::read_dir(world.join(CHARACTERS_DIR)) else { + return slugs; + }; + for entry in read_dir.flatten() { + if entry.path().join(ENTRY_FILE).is_file() { + slugs.push(entry.file_name().to_string_lossy().into_owned()); + } + } + slugs.sort(); + slugs +} + +#[cfg(test)] +#[path = "character_tests.rs"] +mod tests; diff --git a/src/campaign/character_tests.rs b/src/campaign/character_tests.rs new file mode 100644 index 0000000..5b0a0e9 --- /dev/null +++ b/src/campaign/character_tests.rs @@ -0,0 +1,292 @@ +//! Tests for the characters a world holds: the stage-taking line, who the +//! record puts on stage, and the character's own log. + +use super::*; +use crate::campaign::{Campaign, GameTime}; + +use std::fs; + +use tempfile::TempDir; + +/// A fresh campaign in a temp directory. +fn campaign() -> (Campaign, TempDir) { + let dir = TempDir::new().unwrap(); + let opened = Campaign::open(dir.path()).unwrap(); + (opened, dir) +} + +fn time(text: &str) -> GameTime { + GameTime::parse(text).unwrap() +} + +/// Writes a sheet for `slug` in the world at `world`, so the character +/// counts as one. +fn write_sheet(world: &Path, slug: &str, contents: &str) { + let sheet = sheet_path(world, slug); + fs::create_dir_all(sheet.parent().unwrap()).unwrap(); + fs::write(sheet, contents).unwrap(); +} + +// --- The stage-taking line ------------------------------------------------ + +#[test] +fn taking_the_stage_writes_the_event_line_to_the_campaign_log() { + let (campaign, dir) = campaign(); + + campaign + .take_stage("sister-maren", time("#d1-0000")) + .unwrap(); + + let text = fs::read_to_string(dir.path().join("campaign-log/0001.md")).unwrap(); + assert_eq!( + text, + "#d1-0000 - [[characters/sister-maren]] takes the stage.\n" + ); +} + +#[test] +fn taking_the_stage_reads_back_as_the_character_on_stage() { + let (campaign, _dir) = campaign(); + + campaign + .take_stage("sister-maren", time("#d1-0000")) + .unwrap(); + + assert_eq!( + campaign.on_stage().unwrap().as_deref(), + Some("sister-maren") + ); +} + +#[test] +fn taking_the_stage_leaves_the_transcript_alone() { + let (campaign, _dir) = campaign(); + + campaign + .take_stage("sister-maren", time("#d1-0000")) + .unwrap(); + + assert!(campaign.transcript_entries().unwrap().is_empty()); +} + +#[test] +fn taking_the_stage_twice_at_the_same_moment_is_allowed() { + let (campaign, _dir) = campaign(); + + campaign + .take_stage("sister-maren", time("#d1-0000")) + .unwrap(); + campaign + .take_stage("brother-kel", time("#d1-0000")) + .unwrap(); + + assert_eq!(campaign.on_stage().unwrap().as_deref(), Some("brother-kel")); +} + +#[test] +fn nobody_is_on_stage_when_the_log_holds_no_stage_event() { + let (campaign, _dir) = campaign(); + + campaign + .mark(time("#d1-0830"), "The party leaves the inn.", None) + .unwrap(); + + assert_eq!(campaign.on_stage().unwrap(), None); +} + +#[test] +fn the_last_stage_event_across_day_files_wins() { + let (campaign, _dir) = campaign(); + + campaign + .take_stage("sister-maren", time("#d1-0000")) + .unwrap(); + campaign + .mark(time("#d1-0830"), "The party leaves the inn.", None) + .unwrap(); + campaign + .take_stage("brother-kel", time("#d2-0900")) + .unwrap(); + campaign + .mark(time("#d3-1000"), "The road turns north.", None) + .unwrap(); + + assert_eq!(campaign.on_stage().unwrap().as_deref(), Some("brother-kel")); +} + +#[test] +fn a_stage_event_in_an_older_day_still_answers() { + let (campaign, _dir) = campaign(); + + campaign + .take_stage("sister-maren", time("#d1-0000")) + .unwrap(); + campaign + .mark(time("#d4-1000"), "The road turns north.", None) + .unwrap(); + + assert_eq!( + campaign.on_stage().unwrap().as_deref(), + Some("sister-maren") + ); +} + +#[test] +fn narration_that_only_looks_like_the_stage_event_names_nobody() { + let (campaign, _dir) = campaign(); + + campaign + .mark( + time("#d1-0830"), + "Everyone watches as [[characters/sister-maren]] takes the stage. The room quiets.", + None, + ) + .unwrap(); + + assert_eq!(campaign.on_stage().unwrap(), None); +} + +#[test] +fn a_line_naming_no_slug_names_nobody() { + let (campaign, _dir) = campaign(); + + campaign + .mark(time("#d1-0830"), "[[characters/]] takes the stage.", None) + .unwrap(); + + assert_eq!(campaign.on_stage().unwrap(), None); +} + +#[test] +fn a_line_whose_slug_holds_a_path_names_nobody() { + let (campaign, _dir) = campaign(); + + campaign + .mark( + time("#d1-0830"), + "[[characters/sister-maren/log]] takes the stage.", + None, + ) + .unwrap(); + + assert_eq!(campaign.on_stage().unwrap(), None); +} + +// --- The characters in a world ------------------------------------------- + +#[test] +fn a_world_with_no_characters_directory_holds_no_characters() { + let (campaign, _dir) = campaign(); + + assert!(campaign.characters().is_empty()); +} + +#[test] +fn every_directory_with_a_sheet_is_a_character_in_sorted_order() { + let (campaign, dir) = campaign(); + write_sheet( + dir.path(), + "sister-maren", + "---\nhp: 22\n---\n\nA cleric.\n", + ); + write_sheet( + dir.path(), + "brother-kel", + "---\nhp: 30\n---\n\nA fighter.\n", + ); + + assert_eq!(campaign.characters(), vec!["brother-kel", "sister-maren"]); +} + +#[test] +fn a_directory_with_no_sheet_is_not_a_character() { + let (campaign, dir) = campaign(); + write_sheet( + dir.path(), + "sister-maren", + "---\nhp: 22\n---\n\nA cleric.\n", + ); + fs::create_dir_all(dir.path().join("characters/half-built")).unwrap(); + fs::write(dir.path().join("characters/loose.md"), "not a character").unwrap(); + + assert_eq!(campaign.characters(), vec!["sister-maren"]); +} + +#[test] +fn a_characters_sheet_is_the_entry_file_in_its_directory() { + let (campaign, dir) = campaign(); + + assert_eq!( + campaign.character_sheet("sister-maren"), + dir.path().join("characters/sister-maren/entry.md") + ); +} + +// --- The character's own log --------------------------------------------- + +#[test] +fn a_character_event_writes_a_line_to_the_characters_log() { + let (campaign, dir) = campaign(); + + campaign + .log_character_event( + "sister-maren", + time("#d1-0830"), + "hp: 22 → 15, the ghoul's claws find her shoulder", + ) + .unwrap(); + + let text = fs::read_to_string(dir.path().join("characters/sister-maren/log/0001.md")).unwrap(); + assert_eq!( + text, + "#d1-0830 - hp: 22 → 15, the ghoul's claws find her shoulder\n" + ); +} + +#[test] +fn character_events_partition_by_day() { + let (campaign, dir) = campaign(); + + campaign + .log_character_event("sister-maren", time("#d1-0830"), "hp: 22 → 15") + .unwrap(); + campaign + .log_character_event("sister-maren", time("#d1-2200"), "hp: 15 → 22, a long rest") + .unwrap(); + campaign + .log_character_event("sister-maren", time("#d2-0900"), "level: 3 → 4") + .unwrap(); + + let log = dir.path().join("characters/sister-maren/log"); + assert_eq!( + fs::read_to_string(log.join("0001.md")).unwrap(), + "#d1-0830 - hp: 22 → 15\n#d1-2200 - hp: 15 → 22, a long rest\n" + ); + assert_eq!( + fs::read_to_string(log.join("0002.md")).unwrap(), + "#d2-0900 - level: 3 → 4\n" + ); +} + +#[test] +fn a_character_event_leaves_the_campaign_log_alone() { + let (campaign, _dir) = campaign(); + + campaign + .log_character_event("sister-maren", time("#d1-0830"), "hp: 22 → 15") + .unwrap(); + + assert!(campaign.log_entries().unwrap().is_empty()); +} + +#[test] +fn a_character_event_reports_a_log_it_cannot_write() { + let (campaign, dir) = campaign(); + fs::write(dir.path().join("characters"), "not a directory").unwrap(); + + let error = campaign + .log_character_event("sister-maren", time("#d1-0830"), "hp: 22 → 15") + .unwrap_err(); + + assert!(error.contains("cannot create")); +} diff --git a/src/campaign/log.rs b/src/campaign/log.rs index 87efd7e..596e545 100644 --- a/src/campaign/log.rs +++ b/src/campaign/log.rs @@ -49,6 +49,27 @@ impl CampaignLog { Ok(None) } + /// The newest entry `select` accepts, or `None` when no entry does. + /// + /// Walks day files newest first, and each file's lines newest first, + /// so the answer is the last one written. A long campaign's older day + /// files are never read once a newer day answers. + pub fn last_match(&self, select: impl Fn(&str) -> Option) -> Result, String> { + for day in days_in(&self.dir).into_iter().rev() { + let path = day_path(&self.dir, day); + let text = fs::read_to_string(&path) + .map_err(|error| format!("{}: cannot read: {error}", path.display()))?; + let found = parse_lines(&text) + .iter() + .rev() + .find_map(|entry| select(&entry.body)); + if found.is_some() { + return Ok(found); + } + } + Ok(None) + } + /// 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> { diff --git a/src/campaign/log_tests.rs b/src/campaign/log_tests.rs index 7df0080..805210a 100644 --- a/src/campaign/log_tests.rs +++ b/src/campaign/log_tests.rs @@ -106,6 +106,50 @@ fn last_anchor_falls_back_to_an_older_day_when_the_newest_day_file_has_no_parsea ); } +#[test] +fn last_match_takes_the_newest_accepted_entry_of_a_day() { + let (log, _dir) = log(); + + log.append(GameTime::parse("#d1-0830").unwrap(), "a spy watches") + .unwrap(); + log.append(GameTime::parse("#d1-1200").unwrap(), "a spy leaves") + .unwrap(); + log.append(GameTime::parse("#d1-1300").unwrap(), "the road turns") + .unwrap(); + + let found = log + .last_match(|body| body.strip_prefix("a spy ").map(str::to_string)) + .unwrap(); + + assert_eq!(found.as_deref(), Some("leaves")); +} + +#[test] +fn last_match_is_none_when_no_entry_matches() { + let (log, _dir) = log(); + + log.append(GameTime::parse("#d1-0830").unwrap(), "the road turns") + .unwrap(); + + let found = log + .last_match(|body| body.strip_prefix("a spy ").map(str::to_string)) + .unwrap(); + + assert_eq!(found, None); +} + +#[test] +fn last_match_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.last_match(|body| Some(body.to_string())).unwrap_err(); + + assert!(error.contains("cannot read")); +} + #[test] fn read_lines_returns_all_entries_in_order() { let (log, _dir) = log(); diff --git a/src/campaign/mod.rs b/src/campaign/mod.rs index aed0d33..4a53d84 100644 --- a/src/campaign/mod.rs +++ b/src/campaign/mod.rs @@ -6,7 +6,14 @@ //! separate state to drift. The transcript mirrors what the player has //! seen, so `lookup` answers what's true and `recall` answers what //! happened. +//! +//! The world's characters live here too, under `characters/`, and which +//! one is on stage is a fact about the record rather than a flag on a +//! file: the last stage-taking line in the campaign log names the +//! character the session plays. Every change to a sheet lands in that +//! character's own log, so the campaign log stays narrative. +mod character; mod clock; pub mod day_file; mod entry; @@ -103,6 +110,54 @@ impl Campaign { self.transcript.append(time, text) } + /// Every character in this world, by slug, in sorted order. + pub fn characters(&self) -> Vec { + character::slugs(&self.world) + } + + /// The sheet of the character `slug`, `characters//entry.md`, + /// whether or not it exists yet. The engine rereads this file every + /// turn, and the `sheet` tool writes it, so both find it through + /// here. + pub fn character_sheet(&self, slug: &str) -> PathBuf { + character::sheet_path(&self.world, slug) + } + + /// Records that `slug` takes the stage at `time`, in the campaign log + /// alone. Which character the player runs is bookkeeping, not + /// something that happened in the story, so the transcript says + /// nothing about it. + /// + /// The line goes straight to the log rather than through + /// [`Self::mark`], because taking the stage happens at the current + /// moment and moves no time. `mark` would reject it for that. + pub fn take_stage(&self, slug: &str, time: GameTime) -> Result<(), String> { + self.log.append(time, &character::stage_line(slug)) + } + + /// The character the record last put on stage, or `None` when the + /// campaign log holds no stage-taking line. + pub fn on_stage(&self) -> Result, String> { + self.log + .last_match(|body| character::stage_slug(body).map(str::to_string)) + } + + /// Appends `event` to `slug`'s own log at `time`, day file by day + /// file, the same shape the campaign log has. + /// + /// The character's log is the mechanical ledger: every wound, every + /// spent slot, every level, stamped with the campaign clock. It stays + /// out of the campaign log so fifty hit-point ticks cannot bury the + /// story beats `recall` exists to find. + pub fn log_character_event( + &self, + slug: &str, + time: GameTime, + event: &str, + ) -> Result<(), String> { + CampaignLog::new(character::log_dir(&self.world, slug)).append(time, event) + } + /// Every campaign-log entry, for the recall search. pub(crate) fn log_entries(&self) -> Result, String> { self.log.read_lines() diff --git a/src/dm/character-sheet.md b/src/dm/character-sheet.md new file mode 100644 index 0000000..e46d590 --- /dev/null +++ b/src/dm/character-sheet.md @@ -0,0 +1,3 @@ +## The character on stage + +This is the player's character. The engine reads the sheet from disk at the start of every turn, so the stat block below holds the current numbers, not remembered ones. When the conversation and the stat block disagree, the stat block wins: a tool or the player wrote those values. The prose says who the character is, and it can trail the conversation the way any note does. Name the character with the address in the heading below, so the reference resolves to this sheet. diff --git a/src/dm/dm_stage_tests.rs b/src/dm/dm_stage_tests.rs new file mode 100644 index 0000000..53977de --- /dev/null +++ b/src/dm/dm_stage_tests.rs @@ -0,0 +1,282 @@ +//! Tests for the character on stage: which character the session binds +//! to, and the sheet every turn's prompt carries for them. + +use super::tests::{dm_for, dm_with_campaign, fake_server, sent_messages, sse_response}; + +use std::fs; +use std::path::Path; + +use tempfile::TempDir; + +/// The preamble's opening words, distinctive enough to prove the whole +/// character sheet section is present or absent. +const PREAMBLE_MARKER: &str = "This is the player's character."; + +/// A sheet with mechanical state in the frontmatter and the person in the +/// prose. +const MAREN_SHEET: &str = "---\nname: Sister Maren\nclass: cleric\nhp: 22\nac: 16\n---\n\nShe keeps the ledger of the drowned.\n"; + +/// The system prompt of the next request the fake server captured. +fn next_system_prompt( + requests: &std::sync::mpsc::Receiver, +) -> String { + sent_messages(&requests.recv().unwrap())[0]["content"] + .as_str() + .unwrap() + .to_string() +} + +/// Writes `slug`'s sheet in the world at `world`, creating the character's +/// directory. +fn write_sheet(world: &Path, slug: &str, contents: &str) { + let directory = world.join("characters").join(slug); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("entry.md"), contents).unwrap(); +} + +/// Writes a stage-taking line for `slug` into the world's campaign log. +fn take_stage(world: &Path, slug: &str) { + let campaign = crate::campaign::Campaign::open(world).unwrap(); + campaign + .take_stage(slug, campaign.current_time().unwrap()) + .unwrap(); +} + +// --- Binding -------------------------------------------------------------- + +#[test] +fn the_only_character_in_the_world_is_on_stage() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + let (url, _requests, server) = fake_server(vec![]); + let dm = dm_with_campaign(url, &world); + + assert_eq!(dm.active_character().as_deref(), Some("sister-maren")); + server.join().unwrap(); +} + +#[test] +fn a_world_with_no_characters_leaves_nobody_on_stage() { + let world = TempDir::new().unwrap(); + let (url, _requests, server) = fake_server(vec![]); + let dm = dm_with_campaign(url, &world); + + assert_eq!(dm.active_character(), None); + server.join().unwrap(); +} + +#[test] +fn several_characters_with_no_stage_event_leave_nobody_on_stage() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + write_sheet( + world.path(), + "brother-kel", + "---\nhp: 30\n---\n\nA fighter.\n", + ); + let (url, _requests, server) = fake_server(vec![]); + let dm = dm_with_campaign(url, &world); + + assert_eq!(dm.active_character(), None); + server.join().unwrap(); +} + +#[test] +fn several_characters_bind_the_one_the_record_last_staged() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + write_sheet( + world.path(), + "brother-kel", + "---\nhp: 30\n---\n\nA fighter.\n", + ); + take_stage(world.path(), "brother-kel"); + let (url, _requests, server) = fake_server(vec![]); + let dm = dm_with_campaign(url, &world); + + assert_eq!(dm.active_character().as_deref(), Some("brother-kel")); + server.join().unwrap(); +} + +#[test] +fn a_character_created_mid_session_takes_the_stage_without_a_restart() { + let world = TempDir::new().unwrap(); + let (url, _requests, server) = fake_server(vec![]); + let dm = dm_with_campaign(url, &world); + assert_eq!(dm.active_character(), None); + + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + + assert_eq!(dm.active_character().as_deref(), Some("sister-maren")); + server.join().unwrap(); +} + +#[test] +fn a_dm_with_no_campaign_has_nobody_on_stage() { + let (url, _requests, server) = fake_server(vec![]); + let dm = dm_for(url); + + assert_eq!(dm.active_character(), None); + server.join().unwrap(); +} + +// --- Injection ------------------------------------------------------------ + +#[test] +fn a_turn_carries_the_sheet_of_the_character_on_stage() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains(PREAMBLE_MARKER)); + assert!(prompt.contains("### characters/sister-maren")); + assert!(prompt.contains("name: Sister Maren")); + assert!(prompt.contains("hp: 22")); + assert!(prompt.contains("She keeps the ledger of the drowned.")); + assert!(!prompt.contains("---\nname: Sister Maren")); +} + +#[test] +fn a_sheet_edited_on_disk_between_turns_reaches_the_next_prompt() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + write_sheet( + world.path(), + "sister-maren", + "---\nname: Sister Maren\nhp: 15\n---\n\nHer shoulder is torn open.\n", + ); + dm.turn("I press on.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains("hp: 15")); + assert!(prompt.contains("Her shoulder is torn open.")); + assert!(!prompt.contains("hp: 22")); +} + +#[test] +fn a_character_rolled_mid_session_reaches_the_next_prompt() { + let world = TempDir::new().unwrap(); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("Roll me a cleric.").unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + let first = next_system_prompt(&requests); + let second = next_system_prompt(&requests); + + assert!(!first.contains(PREAMBLE_MARKER)); + assert!(second.contains("### characters/sister-maren")); +} + +#[test] +fn a_world_with_nobody_on_stage_injects_no_sheet_section() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + write_sheet( + world.path(), + "brother-kel", + "---\nhp: 30\n---\n\nA fighter.\n", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(!prompt.contains(PREAMBLE_MARKER)); +} + +#[test] +fn a_dm_with_no_campaign_injects_no_sheet_section() { + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_for(url); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(!prompt.contains(PREAMBLE_MARKER)); +} + +#[test] +fn a_stage_event_naming_a_character_with_no_sheet_injects_nothing() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", MAREN_SHEET); + write_sheet( + world.path(), + "brother-kel", + "---\nhp: 30\n---\n\nA fighter.\n", + ); + take_stage(world.path(), "the-ghost"); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + + assert_eq!(dm.active_character().as_deref(), Some("the-ghost")); + assert!(!prompt.contains(PREAMBLE_MARKER)); +} + +#[test] +fn an_empty_sheet_injects_nothing() { + let world = TempDir::new().unwrap(); + write_sheet(world.path(), "sister-maren", ""); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + + assert_eq!(dm.active_character().as_deref(), Some("sister-maren")); + assert!(!prompt.contains(PREAMBLE_MARKER)); +} + +#[test] +fn a_sheet_with_no_frontmatter_fence_carries_its_prose() { + let world = TempDir::new().unwrap(); + write_sheet( + world.path(), + "sister-maren", + "She keeps the ledger of the drowned.\n", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains(PREAMBLE_MARKER)); + assert!(prompt.contains("She keeps the ledger of the drowned.")); +} diff --git a/src/dm/mod.rs b/src/dm/mod.rs index c44dfa7..8ea504b 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -28,6 +28,7 @@ use tools::recall::RecallTool; mod prompt; mod report; mod session_start; +mod stage; pub mod tools; /// Told to the model in place of its tools on a withheld round: it has @@ -283,6 +284,17 @@ impl Dm { self.resumed.then(|| RECAP.trim_end().to_string()) } + /// The character on stage, by slug, or `None` when nobody is: the + /// world holds no characters, or it holds several and the record + /// never said which one the session plays. + /// + /// Resolved from the world and the campaign log at the moment of the + /// call, the same way each turn resolves it, so a character rolled + /// mid-session is on stage as soon as the sheet lands on disk. + pub fn active_character(&self) -> Option { + self.campaign.as_ref().and_then(stage::active_character) + } + /// The `/context` report: the system prompt as the context stack /// composes it now, and every tool the model can call, rendered for /// a player who asks what the DM was told. @@ -508,3 +520,7 @@ mod session_start_tests; #[cfg(test)] #[path = "dm_clock_tests.rs"] mod clock_tests; + +#[cfg(test)] +#[path = "dm_stage_tests.rs"] +mod stage_tests; diff --git a/src/dm/prompt.rs b/src/dm/prompt.rs index 1c35438..47182bc 100644 --- a/src/dm/prompt.rs +++ b/src/dm/prompt.rs @@ -1,16 +1,18 @@ //! The turn's system prompt: what the context stack composes, plus the //! session state that rides on the end of it. //! -//! Three things follow the context stack's own prompt, in order. A clock +//! Four things follow the context stack's own prompt, in order. A clock //! line states the current game time, read fresh from the campaign every -//! turn. The session brief says where the story stood when the last -//! session ended. The entity notes say what the world knows about -//! whatever the DM named last turn. The brief and the notes are read -//! fresh from disk every turn too, so an edit between two turns reaches -//! the second one. +//! turn. The character sheet says who the player is playing and what +//! shape they are in. The session brief says where the story stood when +//! the last session ended. The entity notes say what the world knows +//! about whatever the DM named last turn. The sheet, the brief, and the +//! notes are read fresh from disk every turn too, so an edit between two +//! turns reaches the second one. use std::path::Path; +use super::stage; use crate::campaign::Campaign; use crate::entities::{ScanState, brief_body, note_body}; @@ -23,13 +25,18 @@ const SESSION_BRIEF_PREAMBLE: &str = include_str!("session-brief.md"); /// conversation, and the conversation wins. const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); +/// The preamble ahead of the character sheet: the file is the truth about +/// the numbers, and the engine reread it this turn. +const CHARACTER_SHEET_PREAMBLE: &str = include_str!("character-sheet.md"); + /// `prompt` with the session's own state appended: the clock first, then -/// the session brief, then the notes of `scan`'s completed entities. +/// the character on stage, then the session brief, then the notes of +/// `scan`'s completed entities. /// -/// The clock comes from `campaign`; the brief and the notes come from the -/// world at `root`. A `None` campaign, or one whose clock cannot be read, -/// contributes no clock line. A `None` root is a session with no world, -/// which has neither a brief nor notes to read. +/// The clock and the sheet come from `campaign`; the brief and the notes +/// come from the world at `root`. A `None` campaign, or one whose clock +/// cannot be read, contributes no clock line. A `None` root is a session +/// with no world, which has neither a brief nor notes to read. pub(super) fn compose( prompt: String, campaign: Option<&Campaign>, @@ -37,6 +44,7 @@ pub(super) fn compose( scan: &ScanState, ) -> String { let prompt = append_clock(prompt, campaign); + let prompt = append_character_sheet(prompt, campaign); let Some(root) = root else { return prompt; }; @@ -56,6 +64,28 @@ fn append_clock(prompt: String, campaign: Option<&Campaign>) -> String { format!("{prompt}\n\n---\n\nThe campaign clock reads {time}.") } +/// Appends the sheet of the character on stage to `prompt`, under the +/// character's own address, so every turn carries the person the player +/// is playing. A session with nobody on stage, and one whose sheet is +/// missing or empty, contribute nothing, not even +/// [`CHARACTER_SHEET_PREAMBLE`]. +fn append_character_sheet(prompt: String, campaign: Option<&Campaign>) -> String { + let Some(campaign) = campaign else { + return prompt; + }; + let Some(slug) = stage::active_character(campaign) else { + return prompt; + }; + let Some(sheet) = stage::sheet(campaign, &slug) else { + return prompt; + }; + section( + prompt, + CHARACTER_SHEET_PREAMBLE, + &format!("### characters/{slug}\n\n{sheet}"), + ) +} + /// Appends the world's `session.md` to `prompt`, so the DM knows where /// the story stands however far back the replayed history reaches. A /// world with no brief, or an empty one, contributes nothing, not even diff --git a/src/dm/stage.rs b/src/dm/stage.rs new file mode 100644 index 0000000..2b7ae51 --- /dev/null +++ b/src/dm/stage.rs @@ -0,0 +1,50 @@ +//! Who is on stage, and the sheet the turn's prompt carries for them. +//! +//! The binding resolves fresh on every turn rather than once at startup, +//! because the `sheet` tool can roll the session's first character in the +//! middle of a session. The sheet itself is read fresh too, so a hand +//! edit in vim reaches the next turn. + +use std::fs; + +use crate::campaign::Campaign; +use crate::knowledge::frontmatter::split_frontmatter; + +/// The slug of the character on stage, or `None` when nobody is. +/// +/// The rules, in order. One character in the world is on stage by being +/// the only one. With more than one, the record decides: the campaign +/// log's last stage-taking line names the character. A world with +/// several characters and no stage-taking line, and a world with no +/// characters at all, leave the session unbound. +/// +/// A campaign log that cannot be read names nobody. Binding is not worth +/// failing a turn over, and an unbound session says so plainly rather +/// than guessing at a character. +pub(super) fn active_character(campaign: &Campaign) -> Option { + let mut slugs = campaign.characters(); + match slugs.len() { + 0 => None, + 1 => slugs.pop(), + _ => campaign.on_stage().ok().flatten(), + } +} + +/// `slug`'s sheet, rendered for the prompt: the frontmatter as a plain +/// stat block, then the prose. `None` when the sheet is missing or holds +/// nothing. +/// +/// The frontmatter goes in as written, one `key: value` line at a time, +/// rather than through a parser and back out. The file is the truth about +/// the numbers, and what the player typed into it is what the DM reads. +/// A sheet with no frontmatter fence is prose alone; a sheet with no +/// prose is a stat block alone. +pub(super) fn sheet(campaign: &Campaign, slug: &str) -> Option { + let contents = fs::read_to_string(campaign.character_sheet(slug)).ok()?; + let rendered = match split_frontmatter(&contents) { + Ok((stat_block, prose)) => format!("{}\n\n{}", stat_block.trim(), prose.trim()), + Err(_) => contents, + }; + let rendered = rendered.trim(); + (!rendered.is_empty()).then(|| rendered.to_string()) +}