diff --git a/src/cli.rs b/src/cli.rs index 154826a..52c0bb0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -81,8 +81,9 @@ enum SrdCommand { Verify, } -/// Runs the parsed command, using `srd_sources` for `storied srd fetch` and -/// `layer_root` for `storied srd verify`. +/// Runs the parsed command, using `srd_sources` for `storied srd fetch`, and +/// `layer_root` for `storied srd verify` and as the DM's one knowledge +/// layer in `storied sandbox`. pub fn run(cli: Cli, srd_sources: &SrdSources, layer_root: &Path) -> Result<(), String> { match cli.command { None => { @@ -98,7 +99,7 @@ pub fn run(cli: Cli, srd_sources: &SrdSources, layer_root: &Path) -> Result<(), Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] Some(Command::Sandbox { api_base, model }) => { - crate::play::run(&crate::config::Overrides { api_base, model }) + crate::play::run(&crate::config::Overrides { api_base, model }, layer_root) } } } diff --git a/src/dm/dm_tests.rs b/src/dm/dm_tests.rs index 8958ffc..b61e858 100644 --- a/src/dm/dm_tests.rs +++ b/src/dm/dm_tests.rs @@ -5,9 +5,11 @@ //! copy. use super::*; +use crate::knowledge::fixtures; use std::collections::HashMap; 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; @@ -92,11 +94,14 @@ pub(super) fn sse_response(body: &str) -> Vec { } fn dm_for(api_base: String) -> Dm { - Dm::new(Config { - api_base, - api_key: "sk-test".to_string(), - model: "gpt-4o-mini".to_string(), - }) + Dm::new( + Config { + api_base, + api_key: "sk-test".to_string(), + model: "gpt-4o-mini".to_string(), + }, + Arc::new(fixtures::mount(&[])), + ) } /// Discards every event and keeps streaming. Pass this to `turn` in diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 07a4dd3..83a4f4f 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -2,6 +2,7 @@ //! round loop that runs one player turn. use std::ops::ControlFlow; +use std::sync::Arc; use rand::rngs::StdRng; use ratatui::text::Text; @@ -9,8 +10,11 @@ use serde_json::Value; use crate::chat::{ChatError, Client, Message, Role, StreamItem}; use crate::config::Config; +use crate::knowledge::Mount; use tools::Toolbox; use tools::dice::DiceTool; +use tools::lookup::LookupTool; +use tools::read::ReadTool; pub mod tools; @@ -72,16 +76,26 @@ pub struct Dm { } impl Dm { - /// Builds a `Dm` from `config`, seeding the history with the system - /// prompt and the toolbox with the dice tool. + /// 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`. /// /// The dice tool rolls with a `StdRng` seeded from `rand::make_rng` /// rather than the thread-local `rand::rng()` directly: a `Dm` moves /// onto the worker thread that runs its turns, and the thread-local /// generator does not move between threads. - pub fn new(config: Config) -> Self { + /// + /// 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 { let rng: StdRng = rand::make_rng(); - let toolbox = Toolbox::new(vec![Box::new(DiceTool::new(rng))]); + let toolbox = Toolbox::new(vec![ + Box::new(DiceTool::new(rng)), + Box::new(LookupTool::new(Arc::clone(&mount))), + Box::new(ReadTool::new(mount)), + ]); Self::with_toolbox(config, toolbox) } diff --git a/src/dm/system-prompt.md b/src/dm/system-prompt.md index 8a53acb..04f64e7 100644 --- a/src/dm/system-prompt.md +++ b/src/dm/system-prompt.md @@ -4,4 +4,6 @@ Tools handle the game's mechanical parts, like dice rolls. Roll real dice throug 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. + Narrate between rolls. Don't run a long silent stretch of tool calls with nothing said in between. diff --git a/src/dm/tools/lookup.md b/src/dm/tools/lookup.md new file mode 100644 index 0000000..1ff7534 --- /dev/null +++ b/src/dm/tools/lookup.md @@ -0,0 +1,9 @@ +Search the DM's knowledge tree by keyword. The tree holds game rules under `rules/` and world facts under `lore/`. + +Give one or more `keywords`. An entry matches only when every keyword appears, as a substring, in its name, its address, or its body. Matching ignores case. + +Use `scope` to limit the search to one part of the tree, like `rules/monsters` or `lore`. Leave `scope` out to search the whole tree. + +A search with no matches often means a keyword is too specific. Retry with a shorter stem, like `grappl` for `grapple`, or a different word. + +A result may show an entry's full text, or list its address alone. Use `read` to fetch an entry by address. diff --git a/src/dm/tools/lookup.rs b/src/dm/tools/lookup.rs new file mode 100644 index 0000000..d6dc8a0 --- /dev/null +++ b/src/dm/tools/lookup.rs @@ -0,0 +1,233 @@ +//! The `lookup` tool: searches the DM's knowledge tree by keyword. +//! +//! `execute` turns tool arguments into a `SearchResult` from a shared +//! knowledge mount. `render` turns that result into the lines the +//! transcript shows. `call` runs the two in sequence and composes the +//! tool result text the model reads. + +use std::sync::Arc; + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use serde_json::{Value, json}; + +use crate::knowledge::{Mount, SearchResult, search}; + +use super::{Tool, ToolReply}; + +const LOOKUP_MD: &str = include_str!("lookup.md"); + +/// The book shown at the front of a lookup's line. +const BOOK_MARKER: &str = "📖"; + +/// What the transcript shows for a screened lookup: no matches named, +/// just that the DM consulted the books. +const SCREENED_LINE: &str = "📖 the DM consults the books"; + +/// The `lookup` tool: searches a shared knowledge mount by keyword. +pub struct LookupTool { + mount: Arc, +} + +impl LookupTool { + /// Builds a lookup tool that searches `mount`. + pub fn new(mount: Arc) -> Self { + Self { mount } + } +} + +/// A lookup this tool ran: the keywords given, and the ranked result. +#[derive(Debug)] +struct Outcome { + keywords: Vec, + result: SearchResult, +} + +/// Turns tool arguments into a searched `Outcome`. +/// +/// `keywords` is required and must be a nonempty array of strings; +/// `scope` is optional but must be a string when present. Any other key +/// in `args` is ignored. +fn execute(args: &Value, mount: &Mount) -> Result { + let keywords = parse_keywords(args)?; + let scope = parse_scope(args)?; + let keyword_refs: Vec<&str> = keywords.iter().map(String::as_str).collect(); + let result = search(mount, &keyword_refs, scope.as_deref())?; + Ok(Outcome { keywords, result }) +} + +/// 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 `[\"fireball\"]`" + .to_string(), + ); + } + Some(Value::Array(keywords)) => keywords, + Some(other) => { + return Err(format!( + "`keywords` was `{other}`, but it must be an array of strings like `[\"fireball\"]`" + )); + } + }; + 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 `scope` from `args`: an optional string. +fn parse_scope(args: &Value) -> Result, String> { + match args.get("scope") { + None => Ok(None), + Some(Value::String(scope)) => Ok(Some(scope.clone())), + Some(other) => Err(format!( + "`scope` was `{other}`, but it must be a string like `rules/monsters`" + )), + } +} + +/// 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 where they landed. One +/// match names its address; more than one names the count; none says so. +fn public_line(outcome: &Outcome) -> Text<'static> { + let query = outcome.keywords.join(" "); + let landed = match outcome.result.matches.as_slice() { + [] => "no matches".to_string(), + [only] => only.address.clone(), + matches => format!("{} matches", matches.len()), + }; + Text::from(Line::from(Span::raw(format!( + "{BOOK_MARKER} lookup \"{query}\" → {landed}" + )))) +} + +/// The screened line: the DM consulted the books, 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 listed match's address, name, and kind, +/// how many more matched but are not listed, the full text of each +/// inlined match, and, on zero matches, why and what to try next. +fn for_model(outcome: &Outcome, mount: &Mount) -> String { + let result = &outcome.result; + if result.matches.is_empty() { + return format!( + "No entry matched \"{}\". Matching is a case-insensitive substring over \ + names, addresses, and body text; try a shorter stem, like \"grappl\" for \ + \"grapple\", or a different word.", + outcome.keywords.join(" ") + ); + } + + let mut sections = vec![listing(result)]; + if result.unlisted > 0 { + sections.push(format!( + "{} more entries matched but are not listed. Narrow the search with \ + `scope` or more keywords.", + result.unlisted + )); + } + if let Some(inlined) = inlined_texts(result, mount) { + sections.push(inlined); + } + sections.join("\n\n") +} + +/// One line per listed match: its address, name, and kind, when it has +/// one. +fn listing(result: &SearchResult) -> String { + result + .matches + .iter() + .map(|matched| match &matched.kind { + Some(kind) => format!("- {}: {} ({kind})", matched.address, matched.name), + None => format!("- {}: {}", matched.address, matched.name), + }) + .collect::>() + .join("\n") +} + +/// Every inlined match's full text, delimited and labeled with its +/// address, joined into one block; `None` when nothing inlined. +fn inlined_texts(result: &SearchResult, mount: &Mount) -> Option { + let texts: Vec = result + .matches + .iter() + .filter(|matched| matched.inlined) + .map(|matched| { + let entry = mount + .get(&matched.address) + .expect("a listed match's address is mounted"); + format!( + "--- {} ---\n{}\n--- end {} ---", + matched.address, entry.text, matched.address + ) + }) + .collect(); + (!texts.is_empty()).then(|| texts.join("\n\n")) +} + +impl Tool for LookupTool { + fn name(&self) -> &'static str { + "lookup" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "lookup", + "description": LOOKUP_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 [\"fireball\"] or [\"grapple\", \"escape\"].", + }, + "scope": { + "type": "string", + "description": "Restrict the search to an address prefix, like `rules/monsters` or `lore`.", + }, + }, + "required": ["keywords"], + }, + }, + }) + } + + fn call(&mut self, args: &Value) -> Result { + let outcome = execute(args, &self.mount)?; + let (public, screened) = render(&outcome); + Ok(ToolReply { + for_model: for_model(&outcome, &self.mount), + public, + screened, + }) + } +} + +#[cfg(test)] +#[path = "lookup_tests.rs"] +mod tests; diff --git a/src/dm/tools/lookup_tests.rs b/src/dm/tools/lookup_tests.rs new file mode 100644 index 0000000..fca9fe2 --- /dev/null +++ b/src/dm/tools/lookup_tests.rs @@ -0,0 +1,403 @@ +//! Tests for `lookup.rs`, split out to keep the production file under the +//! project's file-length guideline. + +use super::*; +use crate::knowledge::fixtures::mount as fixture_mount; +use crate::knowledge::{Match, SearchResult}; +use serde_json::json; + +/// A single-entry mount: one goblin, under `rules/monsters`. +fn goblin_mount() -> Mount { + fixture_mount(&[( + "rules/monsters/goblin", + "Goblin", + Some("monster"), + "A small, cunning creature.\n", + )]) +} + +fn tool(mount: Mount) -> LookupTool { + LookupTool::new(Arc::new(mount)) +} + +/// An `Outcome` built directly from `keywords` and `result`, bypassing +/// `execute`, so a test can exercise `for_model` and `render` against a +/// result shape that would take many fixture entries to search for. +fn outcome(keywords: &[&str], result: SearchResult) -> Outcome { + Outcome { + keywords: keywords.iter().map(|keyword| keyword.to_string()).collect(), + result, + } +} + +/// One match at `address`/`name`/`kind`, listed but not inlined. +fn listed_match(address: &str, name: &str, kind: Option<&str>) -> Match { + Match { + address: address.to_string(), + name: name.to_string(), + kind: kind.map(str::to_string), + inlined: false, + } +} + +// --- execute: argument validation ---------------------------------------- + +#[test] +fn missing_keywords_names_the_fix() { + let mount = goblin_mount(); + + let error = execute(&json!({}), &mount).unwrap_err(); + + assert_eq!( + error, + "`keywords` is required; give one or more keywords like `[\"fireball\"]`" + ); +} + +#[test] +fn a_non_array_keywords_names_the_value_and_the_fix() { + let mount = goblin_mount(); + + let error = execute(&json!({ "keywords": "fireball" }), &mount).unwrap_err(); + + assert_eq!( + error, + "`keywords` was `\"fireball\"`, but it must be an array of strings like `[\"fireball\"]`" + ); +} + +#[test] +fn empty_keywords_is_an_error() { + let mount = goblin_mount(); + + let error = execute(&json!({ "keywords": [] }), &mount).unwrap_err(); + + assert_eq!( + error, + "`keywords` must not be empty; give at least one keyword" + ); +} + +#[test] +fn a_non_string_keyword_names_the_value_and_the_fix() { + let mount = goblin_mount(); + + let error = execute(&json!({ "keywords": ["goblin", 5] }), &mount).unwrap_err(); + + assert_eq!( + error, + "`keywords` contains `5`, but every keyword must be a string" + ); +} + +#[test] +fn a_non_string_scope_names_the_value_and_the_fix() { + let mount = goblin_mount(); + + let error = execute(&json!({ "keywords": ["goblin"], "scope": 5 }), &mount).unwrap_err(); + + assert_eq!( + error, + "`scope` was `5`, but it must be a string like `rules/monsters`" + ); +} + +#[test] +fn a_scoped_search_only_matches_within_scope() { + let mount = fixture_mount(&[ + ("rules/monsters/goblin", "Goblin", Some("monster"), "text\n"), + ("lore/people/goblin-king", "Goblin King", None, "text\n"), + ]); + + let found = execute(&json!({ "keywords": ["goblin"], "scope": "rules" }), &mount).unwrap(); + + assert_eq!(found.result.matches.len(), 1); + assert_eq!(found.result.matches[0].address, "rules/monsters/goblin"); +} + +// --- for_model ------------------------------------------------------------- + +#[test] +fn zero_matches_names_the_query_and_explains_matching() { + let mount = goblin_mount(); + let found = outcome( + &["nonexistent"], + SearchResult { + matches: vec![], + unlisted: 0, + }, + ); + + let text = for_model(&found, &mount); + + assert_eq!( + text, + "No entry matched \"nonexistent\". Matching is a case-insensitive substring \ + over names, addresses, and body text; try a shorter stem, like \"grappl\" \ + for \"grapple\", or a different word." + ); +} + +#[test] +fn a_listed_match_shows_its_address_name_and_kind() { + let mount = goblin_mount(); + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![listed_match( + "rules/monsters/goblin", + "Goblin", + Some("monster"), + )], + unlisted: 0, + }, + ); + + let text = for_model(&found, &mount); + + assert_eq!(text, "- rules/monsters/goblin: Goblin (monster)"); +} + +#[test] +fn a_match_with_no_kind_omits_it() { + let mount = goblin_mount(); + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![listed_match("rules/monsters/goblin", "Goblin", None)], + unlisted: 0, + }, + ); + + let text = for_model(&found, &mount); + + assert_eq!(text, "- rules/monsters/goblin: Goblin"); +} + +#[test] +fn unlisted_matches_are_reported_with_narrowing_advice() { + let mount = goblin_mount(); + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![listed_match( + "rules/monsters/goblin", + "Goblin", + Some("monster"), + )], + unlisted: 12, + }, + ); + + let text = for_model(&found, &mount); + + assert!(text.contains("12 more entries matched but are not listed.")); + assert!(text.contains("Narrow the search with `scope` or more keywords.")); +} + +#[test] +fn an_inlined_match_appends_its_full_text_delimited_by_its_address() { + let mount = goblin_mount(); + let mut inlined = listed_match("rules/monsters/goblin", "Goblin", Some("monster")); + inlined.inlined = true; + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![inlined], + unlisted: 0, + }, + ); + + let text = for_model(&found, &mount); + + let entry_text = mount.get("rules/monsters/goblin").unwrap().text.clone(); + assert!(text.contains(&format!( + "--- rules/monsters/goblin ---\n{entry_text}\n--- end rules/monsters/goblin ---" + ))); +} + +#[test] +fn a_list_only_match_appends_no_text() { + let mount = goblin_mount(); + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![listed_match( + "rules/monsters/goblin", + "Goblin", + Some("monster"), + )], + unlisted: 0, + }, + ); + + let text = for_model(&found, &mount); + + assert!(!text.contains("---")); +} + +// --- Rendering --------------------------------------------------------------- + +#[test] +fn a_single_hit_names_its_address() { + let mut inlined = listed_match("rules/monsters/goblin", "Goblin", None); + inlined.inlined = true; + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![inlined], + unlisted: 0, + }, + ); + + let (public, _) = render(&found); + + assert_eq!( + public, + Text::from(Line::from(Span::raw( + "📖 lookup \"goblin\" → rules/monsters/goblin" + ))) + ); +} + +#[test] +fn multiple_hits_name_the_count() { + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![listed_match("a", "A", None), listed_match("b", "B", None)], + unlisted: 0, + }, + ); + + let (public, _) = render(&found); + + assert_eq!( + public, + Text::from(Line::from(Span::raw("📖 lookup \"goblin\" → 2 matches"))) + ); +} + +#[test] +fn zero_hits_says_so() { + let found = outcome( + &["nonexistent"], + SearchResult { + matches: vec![], + unlisted: 0, + }, + ); + + let (public, _) = render(&found); + + assert_eq!( + public, + Text::from(Line::from(Span::raw( + "📖 lookup \"nonexistent\" → no matches" + ))) + ); +} + +#[test] +fn multiple_keywords_join_with_spaces_in_the_query() { + let found = outcome( + &["grapple", "escape"], + SearchResult { + matches: vec![], + unlisted: 0, + }, + ); + + let (public, _) = render(&found); + + assert_eq!( + public, + Text::from(Line::from(Span::raw( + "📖 lookup \"grapple escape\" → no matches" + ))) + ); +} + +#[test] +fn the_screened_line_says_the_dm_consulted_the_books_with_no_details() { + let found = outcome( + &["goblin"], + SearchResult { + matches: vec![], + unlisted: 0, + }, + ); + + let (_, screened) = render(&found); + + assert_eq!( + screened, + Text::from(Line::from(Span::styled(SCREENED_LINE, dim_style()))) + ); +} + +// --- The Tool trait ---------------------------------------------------------- + +#[test] +fn the_tools_name_is_lookup() { + assert_eq!(tool(goblin_mount()).name(), "lookup"); +} + +#[test] +fn the_definition_names_the_function_lookup() { + let definition = tool(goblin_mount()).definition(); + + assert_eq!(definition["function"]["name"], json!("lookup")); +} + +#[test] +fn the_definition_has_a_nonempty_description() { + let definition = tool(goblin_mount()).definition(); + + let description = definition["function"]["description"].as_str().unwrap(); + assert!(!description.is_empty()); +} + +#[test] +fn keywords_is_required_and_scope_is_not() { + let definition = tool(goblin_mount()).definition(); + + assert_eq!( + definition["function"]["parameters"]["required"], + json!(["keywords"]) + ); +} + +#[test] +fn the_definition_declares_no_visibility_parameter() { + let definition = tool(goblin_mount()).definition(); + + assert!(definition["function"]["parameters"]["properties"]["visibility"].is_null()); +} + +#[test] +fn call_composes_execute_and_render_into_a_reply() { + let mut tool = tool(goblin_mount()); + + let reply = tool.call(&json!({ "keywords": ["goblin"] })).unwrap(); + + assert!(reply.for_model.contains("A small, cunning creature.")); + assert_eq!( + reply.public, + Text::from(Line::from(Span::raw( + "📖 lookup \"goblin\" → rules/monsters/goblin" + ))) + ); +} + +#[test] +fn call_propagates_an_execute_error() { + let mut tool = tool(goblin_mount()); + + let error = tool.call(&json!({})).unwrap_err(); + + assert_eq!( + error, + "`keywords` is required; give one or more keywords like `[\"fireball\"]`" + ); +} diff --git a/src/dm/tools/mod.rs b/src/dm/tools/mod.rs index edaae94..e01ccb3 100644 --- a/src/dm/tools/mod.rs +++ b/src/dm/tools/mod.rs @@ -12,6 +12,8 @@ use ratatui::text::Text; use serde_json::{Value, json}; pub mod dice; +pub mod lookup; +pub mod read; const VISIBILITY_DESCRIPTION: &str = include_str!("visibility.md"); diff --git a/src/dm/tools/read.md b/src/dm/tools/read.md new file mode 100644 index 0000000..d713262 --- /dev/null +++ b/src/dm/tools/read.md @@ -0,0 +1,5 @@ +Fetch one entry from the DM's knowledge tree by its address. + +Give the entry's `address`, like `rules/spells/fireball`. This tool accepts a `[[wikilink]]` or a trailing `.md`, and it ignores case. + +An entry's text can hold `[[address]]` references to other entries. This tool resolves those addresses too. diff --git a/src/dm/tools/read.rs b/src/dm/tools/read.rs new file mode 100644 index 0000000..ec18e26 --- /dev/null +++ b/src/dm/tools/read.rs @@ -0,0 +1,127 @@ +//! The `read` tool: fetches one entry from the DM's knowledge tree by +//! address. +//! +//! `execute` normalizes tool arguments into a mounted `Entry`. `render` +//! turns that entry into the lines the transcript shows. `call` runs the +//! two in sequence. + +use std::sync::Arc; + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use serde_json::{Value, json}; + +use crate::knowledge::{Entry, Mount, normalize_address}; + +use super::{Tool, ToolReply}; + +const READ_MD: &str = include_str!("read.md"); + +/// The book shown at the front of a read's line. +const BOOK_MARKER: &str = "📖"; + +/// What the transcript shows for a screened read: no address named, +/// just that the DM consulted the books. +const SCREENED_LINE: &str = "📖 the DM consults the books"; + +/// The `read` tool: fetches one entry from a shared knowledge mount. +pub struct ReadTool { + mount: Arc, +} + +impl ReadTool { + /// Builds a read tool that fetches entries from `mount`. + pub fn new(mount: Arc) -> Self { + Self { mount } + } +} + +/// Turns tool arguments into a mounted `Entry`. +/// +/// `address` is required and must be a string. Normalizes it before +/// looking it up, so a `[[wikilink]]`, a trailing `.md`, stray +/// whitespace, or the wrong case all still resolve. A miss names the +/// normalized address and points at `lookup`. +fn execute(args: &Value, mount: &Mount) -> Result { + let address = parse_address(args)?; + let normalized = normalize_address(&address); + mount.get(&normalized).cloned().ok_or_else(|| { + format!("no entry is mounted at `{normalized}`; use `lookup` to find the right address") + }) +} + +/// Reads `address` from `args`: a required string. +fn parse_address(args: &Value) -> Result { + match args.get("address") { + None => Err( + "`address` is required; give an entry's address like `rules/spells/fireball`" + .to_string(), + ), + Some(Value::String(address)) => Ok(address.clone()), + Some(other) => Err(format!( + "`address` was `{other}`, but it must be a string like `rules/spells/fireball`" + )), + } +} + +/// Renders an `Entry` as the public and screened transcript lines. +fn render(entry: &Entry) -> (Text<'static>, Text<'static>) { + (public_line(entry), screened_line()) +} + +/// The public line: the entry's address, as mounted. +fn public_line(entry: &Entry) -> Text<'static> { + Text::from(Line::from(Span::raw(format!( + "{BOOK_MARKER} {}", + entry.address + )))) +} + +/// The screened line: the DM consulted the books, 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) +} + +impl Tool for ReadTool { + fn name(&self) -> &'static str { + "read" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "read", + "description": READ_MD.trim_end(), + "parameters": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "The entry's address, like `rules/spells/fireball`. Accepts a `[[wikilink]]` or a trailing `.md`.", + }, + }, + "required": ["address"], + }, + }, + }) + } + + fn call(&mut self, args: &Value) -> Result { + let entry = execute(args, &self.mount)?; + let (public, screened) = render(&entry); + Ok(ToolReply { + for_model: entry.text.clone(), + public, + screened, + }) + } +} + +#[cfg(test)] +#[path = "read_tests.rs"] +mod tests; diff --git a/src/dm/tools/read_tests.rs b/src/dm/tools/read_tests.rs new file mode 100644 index 0000000..16a6353 --- /dev/null +++ b/src/dm/tools/read_tests.rs @@ -0,0 +1,177 @@ +//! Tests for `read.rs`, split out to keep the production file under the +//! project's file-length guideline. + +use super::*; +use crate::knowledge::fixtures::mount as fixture_mount; +use serde_json::json; + +/// A single-entry mount: one goblin, under `rules/monsters`. +fn goblin_mount() -> Mount { + fixture_mount(&[( + "rules/monsters/goblin", + "Goblin", + Some("monster"), + "A small, cunning creature.\n", + )]) +} + +fn tool(mount: Mount) -> ReadTool { + ReadTool::new(Arc::new(mount)) +} + +// --- execute: argument validation and normalization ----------------------- + +#[test] +fn missing_address_names_the_fix() { + let mount = goblin_mount(); + + let error = execute(&json!({}), &mount).unwrap_err(); + + assert_eq!( + error, + "`address` is required; give an entry's address like `rules/spells/fireball`" + ); +} + +#[test] +fn a_non_string_address_names_the_value_and_the_fix() { + let mount = goblin_mount(); + + let error = execute(&json!({ "address": 5 }), &mount).unwrap_err(); + + assert_eq!( + error, + "`address` was `5`, but it must be a string like `rules/spells/fireball`" + ); +} + +#[test] +fn a_hit_returns_the_mounted_entry() { + let mount = goblin_mount(); + + let entry = execute(&json!({ "address": "rules/monsters/goblin" }), &mount).unwrap(); + + assert_eq!(entry.address, "rules/monsters/goblin"); + assert!(entry.text.contains("A small, cunning creature.")); +} + +#[test] +fn a_miss_names_the_address_and_points_at_lookup() { + let mount = goblin_mount(); + + let error = execute(&json!({ "address": "rules/monsters/orc" }), &mount).unwrap_err(); + + assert_eq!( + error, + "no entry is mounted at `rules/monsters/orc`; use `lookup` to find the right address" + ); +} + +#[test] +fn brackets_a_trailing_extension_and_case_all_normalize() { + let mount = goblin_mount(); + + let entry = execute( + &json!({ "address": "[[Rules/Monsters/Goblin.md]]" }), + &mount, + ) + .unwrap(); + + assert_eq!(entry.address, "rules/monsters/goblin"); +} + +// --- Rendering --------------------------------------------------------------- + +#[test] +fn the_public_line_names_the_entrys_address() { + let mount = goblin_mount(); + let entry = execute(&json!({ "address": "rules/monsters/goblin" }), &mount).unwrap(); + + let (public, _) = render(&entry); + + assert_eq!( + public, + Text::from(Line::from(Span::raw("📖 rules/monsters/goblin"))) + ); +} + +#[test] +fn the_screened_line_says_the_dm_consulted_the_books_with_no_details() { + let mount = goblin_mount(); + let entry = execute(&json!({ "address": "rules/monsters/goblin" }), &mount).unwrap(); + + let (_, screened) = render(&entry); + + assert_eq!( + screened, + Text::from(Line::from(Span::styled(SCREENED_LINE, dim_style()))) + ); +} + +// --- The Tool trait ---------------------------------------------------------- + +#[test] +fn the_tools_name_is_read() { + assert_eq!(tool(goblin_mount()).name(), "read"); +} + +#[test] +fn the_definition_names_the_function_read() { + let definition = tool(goblin_mount()).definition(); + + assert_eq!(definition["function"]["name"], json!("read")); +} + +#[test] +fn the_definition_has_a_nonempty_description() { + let definition = tool(goblin_mount()).definition(); + + let description = definition["function"]["description"].as_str().unwrap(); + assert!(!description.is_empty()); +} + +#[test] +fn address_is_required() { + let definition = tool(goblin_mount()).definition(); + + assert_eq!( + definition["function"]["parameters"]["required"], + json!(["address"]) + ); +} + +#[test] +fn the_definition_declares_no_visibility_parameter() { + let definition = tool(goblin_mount()).definition(); + + assert!(definition["function"]["parameters"]["properties"]["visibility"].is_null()); +} + +#[test] +fn call_composes_execute_and_render_into_a_reply() { + let mut tool = tool(goblin_mount()); + + let reply = tool + .call(&json!({ "address": "rules/monsters/goblin" })) + .unwrap(); + + assert!(reply.for_model.contains("A small, cunning creature.")); + assert_eq!( + reply.public, + Text::from(Line::from(Span::raw("📖 rules/monsters/goblin"))) + ); +} + +#[test] +fn call_propagates_an_execute_error() { + let mut tool = tool(goblin_mount()); + + let error = tool + .call(&json!({ "address": "rules/monsters/orc" })) + .unwrap_err(); + + assert_eq!( + error, + "no entry is mounted at `rules/monsters/orc`; use `lookup` to find the right address" + ); +} diff --git a/src/knowledge/mod.rs b/src/knowledge/mod.rs index be09a43..b0729be 100644 --- a/src/knowledge/mod.rs +++ b/src/knowledge/mod.rs @@ -19,7 +19,7 @@ mod mount; mod search; #[cfg(test)] -mod fixtures; +pub(crate) mod fixtures; pub use address::normalize_address; pub use entry::Entry; diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 07a567b..5674bc7 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -7,6 +7,8 @@ use std::env; use std::io::{self, Stdout, Write}; +use std::path::Path; +use std::sync::Arc; use std::time::Duration; use crossterm::cursor::MoveTo; @@ -20,6 +22,7 @@ use ratatui::{Terminal, TerminalOptions, Viewport}; use crate::config::{self, Overrides}; use crate::dm::Dm; +use crate::knowledge::Mount; use super::history::History; use super::keys::{self, Key, Keys}; @@ -31,13 +34,17 @@ use super::worker::Worker; /// How long to wait for a key before looking at the worker again. const POLL_INTERVAL: Duration = Duration::from_millis(50); -/// Loads the config, starts the DM, and plays until the player quits. +/// Loads the config, opens the knowledge mount over `layer_root`, starts +/// the DM, and plays until the player quits. /// /// The terminal goes back to how it was before the game, with the /// transcript still on the screen. An init that fails part way through has -/// already put the terminal in raw mode, so that path restores it too. -pub fn run(overrides: &Overrides) -> Result<(), String> { +/// already put the terminal in raw mode, so that path restores it too. A +/// mount that fails to open, the same as a config that fails to load, +/// ends the game before the terminal changes anything. +pub fn run(overrides: &Overrides, layer_root: &Path) -> Result<(), String> { let config = config::load(overrides).map_err(|error| error.to_string())?; + let mount = Arc::new(Mount::open(&[layer_root.to_path_buf()])?); let banner = super::banner(&config); let options = TerminalOptions { viewport: Viewport::Inline(VIEWPORT_HEIGHT), @@ -50,7 +57,7 @@ pub fn run(overrides: &Overrides) -> Result<(), String> { // 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)); + 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) diff --git a/src/play/worker.rs b/src/play/worker.rs index 9f772d2..a9ff75c 100644 --- a/src/play/worker.rs +++ b/src/play/worker.rs @@ -94,6 +94,7 @@ fn run(mut dm: Dm, requests: &Receiver, replies: &Sender, can mod tests { use super::*; use crate::config::Config; + use crate::knowledge::fixtures; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::sync::atomic::Ordering; @@ -203,11 +204,14 @@ mod tests { } fn dm_for(api_base: String) -> Dm { - Dm::new(Config { - api_base, - api_key: "sk-test".to_string(), - model: "a-model".to_string(), - }) + Dm::new( + Config { + api_base, + api_key: "sk-test".to_string(), + model: "a-model".to_string(), + }, + Arc::new(fixtures::mount(&[])), + ) } #[test]