From 103c3bada1bea1b374b7140796c0d2e666f355f5 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Fri, 31 Jul 2026 16:51:00 -0400 Subject: [PATCH] Give the DM lookup and read tools over the knowledge mount The payoff of plan 0007: the DM can search the knowledge tree by case-insensitive keywords, optionally scoped to a subtree, and fetch any entry verbatim by address, including [[wikilink]] addresses found inside other entries. The session opens one mount over the SRD layer and both tools share it through an Arc. Lookup results list matches and inline full entries under the search budget; misses coach the model toward stems and scopes instead of letting it conclude a rule does not exist. The system prompt tells the DM to look rules up rather than guess. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CUXjWo1zGFhdJUig1hcQGf --- src/cli.rs | 7 +- src/dm/dm_tests.rs | 15 +- src/dm/mod.rs | 22 +- src/dm/system-prompt.md | 2 + src/dm/tools/lookup.md | 9 + src/dm/tools/lookup.rs | 233 ++++++++++++++++++++ src/dm/tools/lookup_tests.rs | 403 +++++++++++++++++++++++++++++++++++ src/dm/tools/mod.rs | 2 + src/dm/tools/read.md | 5 + src/dm/tools/read.rs | 127 +++++++++++ src/dm/tools/read_tests.rs | 177 +++++++++++++++ src/knowledge/mod.rs | 2 +- src/play/terminal.rs | 15 +- src/play/worker.rs | 14 +- 14 files changed, 1011 insertions(+), 22 deletions(-) create mode 100644 src/dm/tools/lookup.md create mode 100644 src/dm/tools/lookup.rs create mode 100644 src/dm/tools/lookup_tests.rs create mode 100644 src/dm/tools/read.md create mode 100644 src/dm/tools/read.rs create mode 100644 src/dm/tools/read_tests.rs 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] -- 2.51.2