diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 8ea504b..b87af8e 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -24,6 +24,7 @@ use tools::lookup::LookupTool; use tools::mark::MarkTool; use tools::read::ReadTool; use tools::recall::RecallTool; +use tools::sheet::SheetTool; mod prompt; mod report; @@ -157,7 +158,7 @@ impl Dm { /// and `campaign`, seeding the history with the system prompt and the /// toolbox with the dice tool, the two knowledge tools (`lookup` and /// `read`), the `context` tool, and, when a `campaign` is given, the - /// two history tools (`mark` and `recall`). + /// three record tools (`mark`, `sheet`, and `recall`). /// /// The system prompt comes from `layers`' `system.md` fragments and /// the mount's `context: true` entries. `turn` composes it again @@ -187,6 +188,7 @@ impl Dm { ]; if let Some(campaign) = campaign.clone() { tools.push(Box::new(MarkTool::new(campaign.clone()))); + tools.push(Box::new(SheetTool::new(campaign.clone()))); tools.push(Box::new(RecallTool::new(campaign))); } let context = Arc::new(ContextStack::new( diff --git a/src/dm/report_tests.rs b/src/dm/report_tests.rs index 4a6763f..064934f 100644 --- a/src/dm/report_tests.rs +++ b/src/dm/report_tests.rs @@ -126,14 +126,16 @@ fn the_report_ends_with_its_own_size_in_characters_and_estimated_tokens() { } #[test] -fn the_report_lists_exactly_the_six_tools_the_dm_can_call() { +fn the_report_lists_exactly_the_seven_tools_the_dm_can_call() { let world = TempDir::new().unwrap(); let report = dm(&[], &world).context_report(); assert_eq!( tool_names(&report), - ["roll", "lookup", "read", "mark", "recall", "context"] + [ + "roll", "lookup", "read", "mark", "sheet", "recall", "context" + ] ); } diff --git a/src/dm/tools/mod.rs b/src/dm/tools/mod.rs index 091aed3..752cf94 100644 --- a/src/dm/tools/mod.rs +++ b/src/dm/tools/mod.rs @@ -25,6 +25,7 @@ pub mod lookup; pub mod mark; pub mod read; pub mod recall; +pub mod sheet; const VISIBILITY_DESCRIPTION: &str = include_str!("visibility.md"); diff --git a/src/dm/tools/sheet.md b/src/dm/tools/sheet.md new file mode 100644 index 0000000..da943ab --- /dev/null +++ b/src/dm/tools/sheet.md @@ -0,0 +1,13 @@ +Write a character's mechanical state: create the character, or patch the sheet. + +Give `character`, the slug that names the character's directory under `characters/`, like `sister-maren`. + +Give `sheet`, the frontmatter keys to write. Every value is absolute, never a change: after a seven-point wound, write `hp: 15`, never `-7`. Absolute values keep a misremembered subtraction from compounding. On a character who already exists, the keys you name are the keys that change; every other key keeps its value and the prose stays as it is. You decide what keys a character needs. The only key the engine reads is `name`, the name the transcript shows. + +Give `event`, one line saying what happened, like `the ghoul's claws find her shoulder`. Every change needs an honest event: the change and the event land together in the character's log, so the ledger reads back as history and not a column of numbers. Use inline markdown and wikilinks only, and keep it to one line. + +Give `prose` to create a character. A character is a person, not a stat block, so a new character needs opening prose that says who she is, what she wants, and what she owes. Create the character the moment the dice settle, so the sheet is real from her first scene. The first character you create takes the stage. `prose` is for creation alone; after that the prose belongs to the player and the Archivist, and the tool rejects it. + +This tool moves no time. Use `mark` for that. + +A sheet change is always public. The player sees every change to their own character, so call `sheet` with `visibility` `public`. diff --git a/src/dm/tools/sheet.rs b/src/dm/tools/sheet.rs new file mode 100644 index 0000000..9aa79ce --- /dev/null +++ b/src/dm/tools/sheet.rs @@ -0,0 +1,434 @@ +//! The `sheet` tool: create a character, or patch a character's +//! mechanical state. +//! +//! `execute` turns tool arguments into a `Request`, the character and +//! what to write about her. `call` finds the sheet on disk, and a sheet +//! that is not there yet means the character is born in this call: the +//! whole frontmatter, the prose, and the stage-taking line. A sheet that +//! is there is patched instead, key by key, and the prose is left alone. +//! Either way the change and its event land in the character's own log, +//! and the transcript shows a 📋 line. + +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use ratatui::text::{Line, Span, Text}; +use serde_json::{Map, Value, json}; +use serde_yaml_ng::{Mapping, Value as Yaml}; + +use crate::campaign::Campaign; +use crate::markdown; + +use super::{Tool, ToolReply, Visibility}; + +const SHEET_MD: &str = include_str!("sheet.md"); + +/// The marker at the front of a sheet change's line. +const SHEET_MARKER: &str = "📋"; + +/// The `sheet` tool: writes a character's frontmatter and logs the change. +pub struct SheetTool { + campaign: Campaign, +} + +impl SheetTool { + /// Builds a sheet tool that writes to `campaign`. + pub fn new(campaign: Campaign) -> Self { + Self { campaign } + } +} + +/// What the DM asked the tool to write: the character, the frontmatter +/// keys to set, the event that explains them, and the opening prose a new +/// character needs. +struct Request { + slug: String, + patch: Map, + event: String, + prose: Option, +} + +/// One key the patch set: its name, what the sheet held before, and what +/// it holds now. `old` is `None` when the sheet had no such key. +struct Change { + key: String, + old: Option, + new: String, +} + +impl Change { + /// `key: old → new`, or `key: value` when the key is new. + fn render(&self) -> String { + match &self.old { + Some(old) => format!("{}: {old} → {}", self.key, self.new), + None => format!("{}: {}", self.key, self.new), + } + } +} + +/// The sheet this call produced: the file to write, the changes it +/// records, the name the transcript shows, and whether the character was +/// born here. +struct Written { + contents: String, + changes: Vec, + name: String, + created: bool, +} + +/// Turns tool arguments into a `Request`. +/// +/// `character`, `sheet`, and `event` are required; `prose` is optional +/// here, and the character's own existence decides whether it is allowed. +/// Any other key in `args` is ignored. +fn execute(args: &Value) -> Result { + Ok(Request { + slug: parse_character(args)?, + patch: parse_sheet(args)?, + event: parse_event(args)?, + prose: parse_prose(args)?, + }) +} + +/// Reads `character` from `args`: a required slug. +fn parse_character(args: &Value) -> Result { + match args.get("character") { + None => Err( + "`character` is required; give the character's slug, as in `characters/sister-maren`" + .to_string(), + ), + Some(Value::String(slug)) if is_slug(slug) => Ok(slug.clone()), + Some(Value::String(slug)) => Err(format!( + "`character` was `{slug}`, but it must be one slug naming a directory under \ + `characters/`, like `sister-maren`" + )), + Some(other) => Err(format!( + "`character` was `{other}`, but it must be a string like `sister-maren`" + )), + } +} + +/// Whether `text` names one directory under `characters/`. A slug is +/// never empty, never a path of its own, and never a wikilink's brackets, +/// so the tool writes inside `characters/` and nowhere else. +fn is_slug(text: &str) -> bool { + !text.trim().is_empty() && !text.starts_with('.') && !text.contains(['/', '\\', '[', ']']) +} + +/// Reads `sheet` from `args`: a required object of frontmatter keys. +fn parse_sheet(args: &Value) -> Result, String> { + match args.get("sheet") { + None => Err( + "`sheet` is required; give the frontmatter keys to set, with absolute values \ + like `{\"hp\": 15}`" + .to_string(), + ), + Some(Value::Object(patch)) if !patch.is_empty() => Ok(patch.clone()), + Some(Value::Object(_)) => Err("`sheet` must name at least one key to set".to_string()), + Some(other) => Err(format!( + "`sheet` was `{other}`, but it must be an object like `{{\"hp\": 15}}`" + )), + } +} + +/// 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 ghoul's claws find her \ + shoulder`" + )), + } +} + +/// Reads `prose` from `args`: an optional string. +fn parse_prose(args: &Value) -> Result, String> { + match args.get("prose") { + None => Ok(None), + Some(Value::String(prose)) if !prose.trim().is_empty() => Ok(Some(prose.clone())), + Some(Value::String(_)) => Err("`prose` must not be empty".to_string()), + Some(other) => Err(format!( + "`prose` was `{other}`, but it must be markdown prose like `A cleric of the \ + drowned coast.`" + )), + } +} + +/// The sheet's address under the world, the form the DM writes in a +/// wikilink. Every message about the file names it this way. +fn address(path: &Path, world: &Path) -> String { + path.strip_prefix(world) + .unwrap_or(path) + .display() + .to_string() +} + +/// The sheet at `path`, or `None` when the character does not exist yet. +fn read_sheet(path: &Path, address: &str) -> Result, String> { + match fs::read_to_string(path) { + Ok(contents) => Ok(Some(contents)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("{address}: cannot read the sheet: {error}")), + } +} + +/// Writes `contents` to `path`, creating the character's directory when +/// the character is new. +fn write_sheet(path: &Path, contents: &str, address: &str) -> Result<(), String> { + let parent = path.parent().expect("a sheet path names a directory"); + fs::create_dir_all(parent) + .and_then(|()| fs::write(path, contents)) + .map_err(|error| format!("{address}: cannot write the sheet: {error}")) +} + +/// The file a new character is born with: the whole `sheet` argument as +/// frontmatter, then the prose that says who she is. +fn born(request: &Request) -> Result { + let Some(prose) = &request.prose else { + return Err( + "`prose` is required when the character does not exist yet; a character is a \ + person, not a stat block, so give the opening prose" + .to_string(), + ); + }; + let mut mapping = Mapping::new(); + let changes = apply(&mut mapping, &request.patch); + Ok(Written { + contents: format!("---\n{}---\n\n{}\n", to_yaml(&mapping), prose.trim()), + name: display_name(&mapping, &request.slug), + changes, + created: true, + }) +} + +/// The file an existing character keeps: the frontmatter with the patch +/// applied, then the body exactly as it was. +fn patched(contents: &str, request: &Request, address: &str) -> Result { + if request.prose.is_some() { + return Err(format!( + "`prose` is only valid when the character does not exist yet; {address} is \ + already written, and its prose belongs to the player and the Archivist now" + )); + } + let (frontmatter, body) = split(contents).map_err(|problem| format!("{address}: {problem}"))?; + let mut mapping = mapping_of(frontmatter).map_err(|problem| format!("{address}: {problem}"))?; + let changes = apply(&mut mapping, &request.patch); + Ok(Written { + contents: format!("---\n{}---\n{body}", to_yaml(&mapping)), + name: display_name(&mapping, &request.slug), + changes, + created: false, + }) +} + +/// Splits a sheet into its frontmatter YAML and the bytes after the +/// closing fence. +/// +/// The body is a slice of the file, so the rewrite keeps the prose as the +/// player left it, blank lines and final newline alike. +fn split(contents: &str) -> Result<(&str, &str), String> { + let rest = contents + .strip_prefix("---\n") + .ok_or("the sheet does not open with a `---` frontmatter fence")?; + let mut offset = 0; + for line in rest.split_inclusive('\n') { + if line.trim_end() == "---" { + return Ok((&rest[..offset], &rest[offset + line.len()..])); + } + offset += line.len(); + } + Err("the sheet's frontmatter has no closing `---` fence".to_string()) +} + +/// The frontmatter's keys as a mapping. Empty frontmatter is an empty +/// mapping; frontmatter that is not a set of keys is an error, because a +/// patch has nowhere to land in it. +fn mapping_of(frontmatter: &str) -> Result { + match serde_yaml_ng::from_str::(frontmatter) { + Ok(Yaml::Mapping(mapping)) => Ok(mapping), + Ok(Yaml::Null) => Ok(Mapping::new()), + Ok(other) => Err(format!( + "the frontmatter is `{}`, but a sheet's frontmatter must be a set of keys", + display(&other) + )), + Err(error) => Err(format!("the frontmatter is not valid YAML: {error}")), + } +} + +/// Applies `patch` to `mapping` and reports what each key changed from +/// and to. +/// +/// A key the sheet already holds keeps its place in the file, and a new +/// key lands at the end, so a patched sheet reads the way the player left +/// it. +fn apply(mapping: &mut Mapping, patch: &Map) -> Vec { + let mut changes = Vec::new(); + for (key, value) in patch { + let new = serde_yaml_ng::to_value(value).expect("a JSON value is always a YAML value"); + let old = mapping.insert(Yaml::String(key.clone()), new.clone()); + changes.push(Change { + key: key.clone(), + old: old.as_ref().map(display), + new: display(&new), + }); + } + changes +} + +/// The mapping as frontmatter YAML text, keys in the order it holds them. +fn to_yaml(mapping: &Mapping) -> String { + serde_yaml_ng::to_string(mapping).expect("a mapping of frontmatter values serializes") +} + +/// One frontmatter value as a single line of text, so a change reads the +/// same in the transcript and in the log. +fn display(value: &Yaml) -> String { + match value { + Yaml::String(text) => text.clone(), + Yaml::Sequence(items) => items.iter().map(display).collect::>().join(", "), + Yaml::Mapping(pairs) => pairs + .iter() + .map(|(key, value)| format!("{}: {}", display(key), display(value))) + .collect::>() + .join(", "), + other => serde_yaml_ng::to_string(other) + .unwrap_or_default() + .trim() + .to_string(), + } +} + +/// The name the transcript shows: the sheet's own `name`, or the slug +/// when the sheet does not name the character. +fn display_name(mapping: &Mapping, slug: &str) -> String { + match mapping.get("name") { + Some(name) => display(name), + None => slug.to_string(), + } +} + +/// The changes as one plain phrase, like `hp: 22 → 15, ac: 16`. +fn rendered_changes(changes: &[Change]) -> String { + changes + .iter() + .map(Change::render) + .collect::>() + .join(", ") +} + +/// The transcript line: the marker, the character, and the changes as +/// plain text, then the event rendered as inline markdown, so a wikilink +/// in it shows its bold display form the same way narration does. +fn transcript_line(name: &str, changes: &str, event: &str) -> Text<'static> { + let mut spans = vec![Span::raw(format!("{SHEET_MARKER} {name} — {changes}, "))]; + spans.extend( + markdown::line(event) + .lines + .into_iter() + .next() + .unwrap_or_default() + .spans, + ); + Text::from(Line::from(spans)) +} + +/// The tool result text telling the DM what was written and where. +fn for_model(address: &str, written: &Written, changes: &str) -> String { + match written.created { + true => format!( + "Created {address}: {changes}. Written to the character's log, and {} takes the stage.", + written.name + ), + false => format!("Patched {address}: {changes}. Written to the character's log."), + } +} + +impl Tool for SheetTool { + fn name(&self) -> &'static str { + "sheet" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "sheet", + "description": SHEET_MD.trim_end(), + "parameters": { + "type": "object", + "properties": { + "character": { + "type": "string", + "description": "The character's slug, the directory under `characters/`, like `sister-maren`.", + }, + "sheet": { + "type": "object", + "description": "The frontmatter keys to write, with absolute values like `{\"hp\": 15}`, never a change like `-7`. On an existing character, the keys named here are the only ones that change.", + }, + "event": { + "type": "string", + "description": "A one-line summary of what happened, written beside the change in the character's own log.", + }, + "prose": { + "type": "string", + "description": "The character's opening prose. Required when the character does not exist yet, and rejected once she does.", + }, + }, + "required": ["character", "sheet", "event"], + }, + }, + }) + } + + fn call(&mut self, args: &Value, visibility: Visibility) -> Result { + let request = execute(args)?; + if visibility != Visibility::Public { + return Err( + "a sheet change is always public; the player sees every change to their own \ + character, so call `sheet` with `visibility` `public`" + .to_string(), + ); + } + + let path = self.campaign.character_sheet(&request.slug); + let address = address(&path, self.campaign.world()); + let time = self.campaign.current_time()?; + let written = match read_sheet(&path, &address)? { + Some(contents) => patched(&contents, &request, &address)?, + None => born(&request)?, + }; + write_sheet(&path, &written.contents, &address)?; + + let changes = rendered_changes(&written.changes); + self.campaign.log_character_event( + &request.slug, + time, + &format!("{changes}, {}", request.event), + )?; + if written.created { + self.campaign.take_stage(&request.slug, time)?; + } + + let line = transcript_line(&written.name, &changes, &request.event); + Ok(ToolReply { + for_model: for_model(&address, &written, &changes), + public: line.clone(), + screened: line, + cap: Visibility::Public, + }) + } +} + +#[cfg(test)] +#[path = "sheet_tests.rs"] +mod tests; diff --git a/src/dm/tools/sheet_tests.rs b/src/dm/tools/sheet_tests.rs new file mode 100644 index 0000000..0eaf7f0 --- /dev/null +++ b/src/dm/tools/sheet_tests.rs @@ -0,0 +1,727 @@ +//! Tests for `sheet.rs`, split out to keep the production file under the +//! project's file-length guideline. + +use super::*; +use crate::campaign::Campaign; +use ratatui::style::Modifier; +use serde_json::json; +use std::fs; +use tempfile::TempDir; + +/// A sheet tool backed by a fresh campaign in a temp directory. +fn campaign_tool() -> (SheetTool, TempDir) { + let dir = TempDir::new().unwrap(); + let campaign = Campaign::open(dir.path()).unwrap(); + (SheetTool::new(campaign), dir) +} + +/// Writes `contents` as `slug`'s sheet, so the character already exists. +fn existing(dir: &TempDir, slug: &str, contents: &str) { + let sheet = dir.path().join("characters").join(slug); + fs::create_dir_all(&sheet).unwrap(); + fs::write(sheet.join("entry.md"), contents).unwrap(); +} + +/// The sheet file `slug` holds now. +fn sheet_text(dir: &TempDir, slug: &str) -> String { + fs::read_to_string(dir.path().join("characters").join(slug).join("entry.md")).unwrap() +} + +/// `slug`'s own log for day one. +fn character_log(dir: &TempDir, slug: &str) -> String { + fs::read_to_string(dir.path().join("characters").join(slug).join("log/0001.md")).unwrap() +} + +/// A sheet a patch can land on: two keys and a paragraph of prose. +const MAREN: &str = + "---\nname: Sister Maren\nhp: 22\nac: 16\n---\n\nA cleric of the drowned coast.\n"; + +// --- Creation ------------------------------------------------------------- + +#[test] +fn creating_a_character_writes_the_sheet_the_log_and_the_stage_line() { + let (mut tool, dir) = campaign_tool(); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"name": "Sister Maren", "hp": 22}, + "event": "She takes her vows.", + "prose": "A cleric of the drowned coast.", + }), + Visibility::Public, + ) + .unwrap(); + + assert_eq!( + sheet_text(&dir, "sister-maren"), + "---\nhp: 22\nname: Sister Maren\n---\n\nA cleric of the drowned coast.\n" + ); + assert_eq!( + character_log(&dir, "sister-maren"), + "#d1-0000 - hp: 22, name: Sister Maren, She takes her vows.\n" + ); + assert_eq!( + fs::read_to_string(dir.path().join("campaign-log/0001.md")).unwrap(), + "#d1-0000 - [[characters/sister-maren]] takes the stage.\n" + ); + assert!( + reply + .for_model + .contains("Created characters/sister-maren/entry.md") + ); + assert!(reply.for_model.contains("Sister Maren takes the stage")); +} + +#[test] +fn a_created_character_shows_each_key_and_its_value() { + let (mut tool, _dir) = campaign_tool(); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"name": "Sister Maren", "hp": 22}, + "event": "She takes her vows.", + "prose": "A cleric.", + }), + Visibility::Public, + ) + .unwrap(); + + assert_eq!( + reply.public, + Text::from(Line::from(vec![ + Span::raw("📋 Sister Maren — hp: 22, name: Sister Maren, "), + Span::raw("She takes her vows."), + ])) + ); +} + +#[test] +fn creating_a_character_without_prose_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 22}, + "event": "She takes her vows.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`prose` is required")); +} + +// --- Patching ------------------------------------------------------------- + +#[test] +fn a_patch_keeps_the_other_keys_the_key_order_and_the_prose() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + tool.call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap(); + + assert_eq!( + sheet_text(&dir, "sister-maren"), + "---\nname: Sister Maren\nhp: 15\nac: 16\n---\n\nA cleric of the drowned coast.\n" + ); +} + +#[test] +fn a_patched_value_shows_what_it_was_and_what_it_is() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap(); + + assert_eq!( + reply.public, + Text::from(Line::from(vec![ + Span::raw("📋 Sister Maren — hp: 22 → 15, "), + Span::raw("The ghoul's claws find her shoulder."), + ])) + ); + assert_eq!( + character_log(&dir, "sister-maren"), + "#d1-0000 - hp: 22 → 15, The ghoul's claws find her shoulder.\n" + ); + assert!( + reply + .for_model + .contains("Patched characters/sister-maren/entry.md") + ); +} + +#[test] +fn a_key_the_sheet_did_not_hold_shows_its_value_alone() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"conditions": ["prone", "poisoned"]}, + "event": "The gas takes her legs.", + }), + Visibility::Public, + ) + .unwrap(); + + assert!( + reply + .public + .to_string() + .starts_with("📋 Sister Maren — conditions: prone, poisoned, ") + ); + assert_eq!( + sheet_text(&dir, "sister-maren"), + "---\nname: Sister Maren\nhp: 22\nac: 16\nconditions:\n- prone\n- poisoned\n---\n\nA cleric of the drowned coast.\n" + ); +} + +#[test] +fn a_mapping_value_reads_on_one_line() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"slots": {"1": 3, "2": 1}}, + "event": "She prepares her spells.", + }), + Visibility::Public, + ) + .unwrap(); + + assert!(reply.public.to_string().contains("slots: 1: 3, 2: 1")); +} + +#[test] +fn a_patch_writes_no_stage_line() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + tool.call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap(); + + assert!(!dir.path().join("campaign-log/0001.md").exists()); +} + +#[test] +fn a_patch_lands_in_a_sheet_with_empty_frontmatter() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", "---\n---\n\nA cleric.\n"); + + tool.call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 22}, + "event": "She takes her vows.", + }), + Visibility::Public, + ) + .unwrap(); + + assert_eq!( + sheet_text(&dir, "sister-maren"), + "---\nhp: 22\n---\n\nA cleric.\n" + ); +} + +#[test] +fn the_slug_stands_in_for_a_sheet_that_names_nobody() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", "---\nhp: 22\n---\n\nA cleric.\n"); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap(); + + assert!(reply.public.to_string().starts_with("📋 sister-maren — ")); +} + +#[test] +fn prose_for_a_character_who_already_exists_is_rejected() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + "prose": "A cleric who has seen worse.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`prose` is only valid")); + assert!(error.contains("characters/sister-maren/entry.md")); +} + +// --- The event's markdown ------------------------------------------------- + +#[test] +fn a_wikilink_in_the_event_shows_its_display_text_in_bold() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "[[people/angus-pettigrew|Old Pettigrew]] misses the parry.", + }), + Visibility::Public, + ) + .unwrap(); + + let wiki_span = reply.public.lines[0] + .spans + .iter() + .find(|span| span.content == "Old Pettigrew") + .expect("the wikilink's display text renders as its own span"); + assert!(wiki_span.style.add_modifier.contains(Modifier::BOLD)); +} + +#[test] +fn the_player_sees_the_same_line_at_every_visibility_the_reply_allows() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let reply = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap(); + + assert_eq!(reply.public, reply.screened); + assert_eq!(reply.cap, Visibility::Public); +} + +// --- Broken sheets -------------------------------------------------------- + +#[test] +fn a_sheet_with_no_opening_fence_is_reported_by_name() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", "A cleric with no frontmatter.\n"); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.starts_with("characters/sister-maren/entry.md: ")); + assert!(error.contains("does not open with a `---` frontmatter fence")); +} + +#[test] +fn a_sheet_with_no_closing_fence_is_reported_by_name() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", "---\nhp: 22\n"); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.starts_with("characters/sister-maren/entry.md: ")); + assert!(error.contains("no closing `---` fence")); +} + +#[test] +fn a_sheet_whose_frontmatter_is_not_yaml_is_reported_by_name() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", "---\nhp: [22\n---\n\nA cleric.\n"); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.starts_with("characters/sister-maren/entry.md: ")); + assert!(error.contains("not valid YAML")); +} + +#[test] +fn a_sheet_whose_frontmatter_is_not_a_set_of_keys_is_reported_by_name() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", "---\n- hp\n- ac\n---\n\nA cleric.\n"); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("must be a set of keys")); + assert!(error.contains("hp, ac")); +} + +#[test] +fn a_sheet_that_cannot_be_read_is_reported_by_name() { + let (mut tool, dir) = campaign_tool(); + fs::create_dir_all(dir.path().join("characters/sister-maren/entry.md")).unwrap(); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("cannot read the sheet")); +} + +#[test] +fn a_sheet_that_cannot_be_written_is_reported_by_name() { + use std::os::unix::fs::PermissionsExt; + let (mut tool, dir) = campaign_tool(); + let character = dir.path().join("characters/sister-maren"); + fs::create_dir_all(&character).unwrap(); + fs::set_permissions(&character, PermissionsExt::from_mode(0o500)).unwrap(); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 22}, + "event": "She takes her vows.", + "prose": "A cleric.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("cannot write the sheet")); +} + +#[test] +fn a_character_log_that_cannot_be_written_is_reported() { + use std::os::unix::fs::PermissionsExt; + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + let character = dir.path().join("characters/sister-maren"); + fs::set_permissions(&character, PermissionsExt::from_mode(0o500)).unwrap(); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Public, + ) + .unwrap_err(); + + fs::set_permissions(&character, PermissionsExt::from_mode(0o755)).unwrap(); + assert!(error.contains("cannot create")); +} + +// --- Arguments ------------------------------------------------------------ + +#[test] +fn a_missing_character_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({"sheet": {"hp": 15}, "event": "A wound."}), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`character` is required")); +} + +#[test] +fn a_character_that_is_a_path_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ + "character": "characters/sister-maren", + "sheet": {"hp": 15}, + "event": "A wound.", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("one slug")); +} + +#[test] +fn a_non_string_character_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({"character": 5, "sheet": {"hp": 15}, "event": "A wound."}), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`character` was `5`")); +} + +#[test] +fn a_missing_sheet_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({"character": "sister-maren", "event": "A wound."}), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`sheet` is required")); +} + +#[test] +fn an_empty_sheet_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({"character": "sister-maren", "sheet": {}, "event": "A wound."}), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("at least one key")); +} + +#[test] +fn a_non_object_sheet_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({"character": "sister-maren", "sheet": "hp: 15", "event": "A wound."}), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`sheet` was `\"hp: 15\"`")); +} + +#[test] +fn a_missing_event_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({"character": "sister-maren", "sheet": {"hp": 15}}), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`event` is required")); +} + +#[test] +fn an_empty_event_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({"character": "sister-maren", "sheet": {"hp": 15}, "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!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "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!({"character": "sister-maren", "sheet": {"hp": 15}, "event": 5}), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`event` was `5`")); +} + +#[test] +fn an_empty_prose_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "A wound.", + "prose": " ", + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`prose` must not be empty")); +} + +#[test] +fn a_non_string_prose_is_rejected() { + let (mut tool, _dir) = campaign_tool(); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "A wound.", + "prose": 5, + }), + Visibility::Public, + ) + .unwrap_err(); + + assert!(error.contains("`prose` was `5`")); +} + +#[test] +fn a_screened_sheet_change_is_rejected() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Screened, + ) + .unwrap_err(); + + assert!(error.contains("always public")); +} + +#[test] +fn a_secret_sheet_change_is_rejected() { + let (mut tool, dir) = campaign_tool(); + existing(&dir, "sister-maren", MAREN); + + let error = tool + .call( + &json!({ + "character": "sister-maren", + "sheet": {"hp": 15}, + "event": "The ghoul's claws find her shoulder.", + }), + Visibility::Secret, + ) + .unwrap_err(); + + assert!(error.contains("always public")); +} + +#[test] +fn the_definition_names_the_tool() { + let (tool, _dir) = campaign_tool(); + + assert_eq!(tool.definition()["function"]["name"], "sheet"); +}