diff --git a/src/dm/base-system.md b/src/dm/base-system.md index d44e202..8b6f047 100644 --- a/src/dm/base-system.md +++ b/src/dm/base-system.md @@ -12,6 +12,26 @@ When you need all entries with a particular frontmatter value — every 9th-leve Knowledge comes in layers, lowest first. The bottom layer is the rules system. The layers above it add module content, the world the DM built, and the player's own tuning. A higher layer's entry replaces a lower one at the same address. Some entries are pushed into this prompt every turn; you always know them without looking them up. +## Storytelling + +You narrate in the second person, in short paragraphs. Your own words are the record of the scene. Whatever you say is happening is happening, and the transcript preserves it. + +Name anything you want kept in your working memory with a wikilink: + +[[Address|display form]] + +Everything you can point to lives in one addressed namespace. A wikilink's inner text is an address. + +An address with a scope is exact and unambiguous. [[people/Joseph Black|the guard]] names the guard precisely. A bare name is a reference by name: [[Joseph Black]] matches a known entity named Joseph Black, whatever scope it lives under. + +If your reference matches nothing, the engine creates a stub there and adds it to your context. A scoped reference like [[people/Joseph Black]] creates the stub under people/. A bare name like [[Joseph Black]] creates it under entities/, the home for things whose kind is not yet known; a later pass can move it to a scope. If a bare name matches more than one entity, the engine says so and injects nothing rather than guess. + +The part after the pipe is what the player sees in the terminal. Use a pipe only when the display text differs from the address. + +Entities you refer to stay in the next turn's context. Stop naming something and it drops out of that context. Link what still matters and stop linking what has passed. Name the Rusty Anchor and Vera Blackwater to keep the tavern and the NPC in your head; stop when the party moves on. + +The campaign already has a clock and tools for the timeline. Use `mark` and `recall` for history. Wikilinks are for what is true right now. Keep the two roles distinct. + ## Visibility Every tool call takes a `visibility` argument. Knowledge entries can also carry a `visibility` field in their frontmatter. That field limits what the player sees when a `lookup` or a `read` finds the entry. diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 6343ec6..74349f0 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -199,11 +199,13 @@ impl Dm { // listener is added by the worker, which owns the channel and the // cancel flag this `Dm` does not see. let scan = Arc::new(Mutex::new(ScanState::default())); - let entities_dir = campaign + // The entity root is the world itself, so a scoped stub can land + // under its own scope directory rather than only under entities/. + let entity_root = campaign .as_ref() - .map(|campaign| campaign.world().join("entities")); + .map(|campaign| campaign.world().to_path_buf()); let mut bus = TurnBus::new(); - bus.add(Box::new(Scanner::new(Arc::clone(&scan), entities_dir))); + bus.add(Box::new(Scanner::new(Arc::clone(&scan), entity_root))); if let Some(campaign) = campaign.clone() { bus.add(Box::new(CampaignListener::new(Some(campaign)))); } diff --git a/src/entities/entities_tests.rs b/src/entities/entities_tests.rs index 3733746..113ca4b 100644 --- a/src/entities/entities_tests.rs +++ b/src/entities/entities_tests.rs @@ -7,13 +7,13 @@ use super::{Resolution, ScanState, Scanner, extract_wikilinks, stub_body}; use crate::bus::{Flow, Listener}; use tempfile::TempDir; -/// The completed entity names, read while the lock guard is held so the -/// borrow does not outlive the guard. -fn completed_names(state: &Arc>) -> Vec { +/// The completed entity addresses, read while the lock guard is held so +/// the borrow does not outlive the guard. +fn completed_addresses(state: &Arc>) -> Vec { let state = state.lock().unwrap(); state .completed() - .map(|entity| entity.name.clone()) + .map(|entity| entity.address.clone()) .collect() } @@ -22,7 +22,7 @@ fn completed_names(state: &Arc>) -> Vec { // --------------------------------------------------------------------------- #[test] -fn extract_finds_each_wikilinks_canonical_name() { +fn extract_finds_each_wikilinks_address() { // `[...]` are literal text to a markdown parser, so only the // double-bracket links count. assert_eq!( @@ -55,7 +55,7 @@ fn extract_returns_nothing_for_plain_text() { // --------------------------------------------------------------------------- #[test] -fn an_exact_canonical_name_resolves_ignore_case() { +fn an_exact_address_resolves_ignore_case() { let mut state = ScanState::default(); state.register("Vera Blackwater"); assert_eq!( @@ -65,23 +65,53 @@ fn an_exact_canonical_name_resolves_ignore_case() { } #[test] -fn a_bare_name_that_is_not_a_canonical_name_is_none() { +fn a_scoped_address_resolves_exactly() { let mut state = ScanState::default(); - state.register("Vera Blackwater"); - // `Vera` is a substring of the name but not a canonical name itself, - // so it does not resolve until best-match is revisited. - assert_eq!(state.resolve("Vera"), Resolution::None); + state.register("people/Joseph Black"); + assert_eq!( + state.resolve("people/Joseph Black"), + Resolution::Found("people/Joseph Black".to_string()) + ); + assert_eq!( + state.resolve("people/joseph black"), + Resolution::Found("people/Joseph Black".to_string()) + ); +} + +#[test] +fn a_bare_name_matches_a_scoped_entity_by_its_name() { + let mut state = ScanState::default(); + state.register("people/Joseph Black"); + assert_eq!( + state.resolve("Joseph Black"), + Resolution::Found("people/Joseph Black".to_string()) + ); +} + +#[test] +fn a_bare_name_that_matches_several_is_ambiguous() { + let mut state = ScanState::default(); + state.register("people/Joseph Black"); + state.register("guards/Joseph Black"); + assert_eq!(state.resolve("Joseph Black"), Resolution::Ambiguous); } #[test] -fn a_ref_to_an_unknown_entity_is_none() { +fn a_scoped_address_that_is_absent_is_none() { + let mut state = ScanState::default(); + state.register("people/Joseph Black"); + assert_eq!(state.resolve("places/the-inn"), Resolution::None); +} + +#[test] +fn a_bare_name_that_matches_nothing_is_none() { let mut state = ScanState::default(); state.register("Vera Blackwater"); - assert_eq!(state.resolve("The Rusty Anchor"), Resolution::None); + assert_eq!(state.resolve("the Rusty Anchor"), Resolution::None); } #[test] -fn registering_the_same_name_twice_is_a_no_op() { +fn registering_the_same_address_twice_is_a_no_op() { let mut state = ScanState::default(); state.register("Vera Blackwater"); state.register("Vera Blackwater"); @@ -109,7 +139,10 @@ fn the_scanner_accumulates_references_and_promotes_them_on_done() { assert_eq!(scanner.delta(" She nods."), Flow::Continue); scanner.done("You see Vera Blackwater watching you. She nods."); - assert_eq!(completed_names(&state), vec!["Vera Blackwater".to_string()]); + assert_eq!( + completed_addresses(&state), + vec!["Vera Blackwater".to_string()] + ); } #[test] @@ -122,10 +155,10 @@ fn a_new_name_is_auto_established_alongside_a_resolved_one() { scanner.delta("[[Vera]] appears. [[Vera Blackwater]] is here."); scanner.done("[Vera] appears. Vera Blackwater is here."); - // `[[Vera]]` matches no known entity, so it becomes a stub of its + // `[[Vera]]` matches no entity named Vera, so it becomes a stub of its // own; `[[Vera Blackwater]]` is already known. Both are in context. assert_eq!( - completed_names(&state), + completed_addresses(&state), vec!["Vera".to_string(), "Vera Blackwater".to_string()] ); } @@ -146,7 +179,10 @@ fn a_turn_that_never_finishes_leaves_the_completed_set_alone() { scanner.turn_start("I search."); scanner.delta("[[Vera Blackwater]]"); - assert_eq!(completed_names(&state), vec!["Vera Blackwater".to_string()]); + assert_eq!( + completed_addresses(&state), + vec!["Vera Blackwater".to_string()] + ); } #[test] @@ -179,7 +215,10 @@ fn a_wikilink_split_across_deltas_is_still_resolved() { // Re-scanning the whole turn's narration, rather than just the latest // delta, catches a link the model split across two deltas. - assert_eq!(completed_names(&state), vec!["Vera Blackwater".to_string()]); + assert_eq!( + completed_addresses(&state), + vec!["Vera Blackwater".to_string()] + ); } // --------------------------------------------------------------------------- @@ -187,53 +226,92 @@ fn a_wikilink_split_across_deltas_is_still_resolved() { // --------------------------------------------------------------------------- #[test] -fn an_unseen_name_writes_a_stub_and_lands_in_context() { +fn an_unseen_bare_name_writes_a_stub_under_entities_and_lands_in_context() { let world = TempDir::new().unwrap(); - let entities_dir = world.path().join("entities"); let state = Arc::new(Mutex::new(ScanState::default())); - let mut scanner = Scanner::new(Arc::clone(&state), Some(entities_dir.clone())); + let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); scanner.turn_start("I enter."); scanner.delta("You meet [[Vera Blackwater]], the innkeeper."); scanner.done("You meet Vera Blackwater, the innkeeper."); - let stub_path = entities_dir.join("Vera Blackwater.md"); + let stub = world.path().join("entities/Vera Blackwater.md"); assert_eq!( - std::fs::read_to_string(&stub_path).unwrap(), + std::fs::read_to_string(&stub).unwrap(), stub_body("Vera Blackwater") ); - assert_eq!(completed_names(&state), vec!["Vera Blackwater".to_string()]); + assert_eq!( + completed_addresses(&state), + vec!["Vera Blackwater".to_string()] + ); +} + +#[test] +fn an_unseen_scoped_name_writes_a_stub_under_its_own_scope() { + let world = TempDir::new().unwrap(); + let state = Arc::new(Mutex::new(ScanState::default())); + let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); + + scanner.turn_start("I enter."); + scanner.delta("A guard steps forward: [[people/Joseph Black]]."); + scanner.done("A guard steps forward: Joseph Black."); + + // The scoped stub lands under people/, not under entities/. + let stub = world.path().join("people/Joseph Black.md"); + assert_eq!( + std::fs::read_to_string(&stub).unwrap(), + stub_body("people/Joseph Black") + ); + assert!(!world.path().join("entities/Joseph Black.md").exists()); + assert_eq!( + completed_addresses(&state), + vec!["people/Joseph Black".to_string()] + ); } #[test] fn a_stub_already_on_disk_is_left_alone() { let world = TempDir::new().unwrap(); - let entities_dir = world.path().join("entities"); - std::fs::create_dir_all(&entities_dir).unwrap(); - let stub_path = entities_dir.join("Vera Blackwater.md"); // An established entity from an earlier session is never clobbered // back into an empty stub. - std::fs::write( - &stub_path, - "# Vera Blackwater\n\n## Is\n\nThe old assassin.\n", - ) - .unwrap(); - - let state = Arc::new(Mutex::new(ScanState::default())); - state.lock().unwrap().register("Vera Blackwater"); - let mut scanner = Scanner::new(Arc::clone(&state), Some(entities_dir.clone())); + let stub = world.path().join("entities/Vera Blackwater.md"); + std::fs::create_dir_all(stub.parent().unwrap()).unwrap(); + std::fs::write(&stub, "# Vera Blackwater\n\n## Is\n\nThe old assassin.\n").unwrap(); // A fresh session does not load disk stubs yet, so the name resolves // as unknown and auto-establishes, which must not overwrite the file. + let state = Arc::new(Mutex::new(ScanState::default())); + let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); + scanner.turn_start("I enter."); scanner.delta("[[Vera Blackwater]] is here."); assert_eq!( - std::fs::read_to_string(&stub_path).unwrap(), + std::fs::read_to_string(&stub).unwrap(), "# Vera Blackwater\n\n## Is\n\nThe old assassin.\n" ); } +#[test] +fn an_ambiguous_bare_name_injects_nothing_and_writes_no_stub() { + let world = TempDir::new().unwrap(); + let state = Arc::new(Mutex::new(ScanState::default())); + { + let mut state = state.lock().unwrap(); + state.register("people/Joseph Black"); + state.register("guards/Joseph Black"); + } + let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); + + scanner.turn_start("I enter."); + scanner.delta("[[Joseph Black]] walks in."); + scanner.done("Joseph Black walks in."); + + // Nothing resolves, no stub is written, nothing is injected. + assert_eq!(completed_addresses(&state), Vec::::new()); + assert!(!world.path().join("entities/Joseph Black.md").exists()); +} + #[test] fn a_new_name_with_no_world_holds_in_memory_only() { let state = Arc::new(Mutex::new(ScanState::default())); @@ -244,7 +322,7 @@ fn a_new_name_with_no_world_holds_in_memory_only() { scanner.done("the Rusty Anchor looms ahead."); assert_eq!( - completed_names(&state), + completed_addresses(&state), vec!["the Rusty Anchor".to_string()] ); } diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 3ae9d3d..af5a803 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -6,11 +6,14 @@ //! and resolves them against the session's known entities. The entities //! a finished turn referenced are the ones the next turn carries. //! -//! This phase covers the reference side: pulling wikilinks out of the -//! narration and resolving them against entities the session already -//! knows. Creating stub entities when a wikilink names something new, and -//! reading an entity's `## Is` body into the prompt, are the phases after -//! this one. +//! Entities share one addressed namespace with the rules and lore. A +//! wikilink's inner text is an address: `[[people/Joseph Black]]` is a +//! scoped address and resolves exactly, while a bare `[[Joseph Black]]` +//! matches by name across whatever scope holds it. When a reference +//! matches nothing, the engine auto-establishes a stub at that address, +//! and the kind-unknown home for a bare name is the `entities/` bucket. +//! Reading an entity's `## Is` body into the next prompt is a later +//! phase. use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -21,20 +24,22 @@ use pulldown_cmark::{Event, LinkType, Options, Parser, Tag}; use crate::bus::{Flow, Listener}; -/// An entity the session knows. Its canonical `name` is the address -/// wikilinks resolve against; a later phase adds the sections that -/// describe it. +/// An entity the session knows. Its `address` is the key it resolves +/// against, and a later phase adds the sections that describe it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entity { - pub name: String, + pub address: String, } /// How a wikilink resolved against the known entities. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Resolution { - /// The wikilink named exactly one known entity, by canonical name. + /// The reference named exactly one known entity, by address. Found(String), - /// No known entity matched; a later phase establishes a stub. + /// A bare reference matched more than one known entity; nothing is + /// injected rather than guess. + Ambiguous, + /// No known entity matched; a stub is auto-established. None, } @@ -44,7 +49,7 @@ pub enum Resolution { /// an `Arc>` rather than a shared reference. #[derive(Debug, Default)] pub struct ScanState { - /// The session's known entities, by lowercased canonical name. + /// The session's known entities, by lowercased address. entities: BTreeMap, /// This turn's narration so far, re-scanned on every delta /// [`Scanner`] receives. @@ -57,36 +62,55 @@ pub struct ScanState { } impl ScanState { - /// Registers an entity by canonical `name` so future wikilinks - /// resolve to it. Registering a name that is already known is a - /// no-op. - pub fn register(&mut self, name: &str) { + /// Registers an entity by `address` so future wikilinks resolve to + /// it. Registering an address that is already known is a no-op. + pub fn register(&mut self, address: &str) { self.entities - .entry(name.to_lowercase()) + .entry(address.to_lowercase()) .or_insert_with(|| Entity { - name: name.to_string(), + address: address.to_string(), }); } - fn resolve(&self, canonical: &str) -> Resolution { - // A reference resolves only when it exactly matches a known - // canonical name. Best-match for abbreviated names is deferred; - // it pulls in the ambiguity rules that go with it. - match self.entities.get(&canonical.to_lowercase()) { - Some(entity) => Resolution::Found(entity.name.clone()), - None => Resolution::None, + fn resolve(&self, address: &str) -> Resolution { + if let Some(entity) = self.entities.get(&address.to_lowercase()) { + return Resolution::Found(entity.address.clone()); + } + // A scoped reference carries its own scope, so a miss means it is + // simply not here. Only a bare reference, which has no scope, is + // a name to match against the known entities. + if address.contains('/') { + return Resolution::None; + } + let needle = address.to_lowercase(); + let matches: Vec<&Entity> = self + .entities + .values() + .filter(|entity| name_suffix(&entity.address).to_lowercase() == needle) + .collect(); + match matches.len() { + 1 => Resolution::Found(matches[0].address.clone()), + 0 => Resolution::None, + _ => Resolution::Ambiguous, } } - /// The entities the most recently finished turn referenced, in name - /// order. + /// The entities the most recently finished turn referenced, in + /// address order. pub fn completed(&self) -> impl Iterator { self.completed .iter() - .filter_map(|name| self.entities.get(&name.to_lowercase())) + .filter_map(|address| self.entities.get(&address.to_lowercase())) } } +/// The final segment of an address, the entity's name. +/// `people/Joseph Black` names `Joseph Black`; a bare `Joseph Black` +/// names itself. +fn name_suffix(address: &str) -> &str { + address.rsplit('/').next().unwrap_or(address) +} + /// A bus listener that turns narration into the references the next turn /// carries. /// @@ -101,17 +125,14 @@ impl ScanState { /// prompt should read. pub struct Scanner { state: Arc>, - entities_dir: Option, + root: Option, } impl Scanner { - /// A scanner sharing `state` with the `Dm` that reads it, writing - /// new entity stubs under `entities_dir` when a world is present. - pub fn new(state: Arc>, entities_dir: Option) -> Self { - Self { - state, - entities_dir, - } + /// A scanner sharing `state` with the `Dm` that reads it, writing new + /// entity stubs under `root` when a world is present. + pub fn new(state: Arc>, root: Option) -> Self { + Self { state, root } } } @@ -126,22 +147,25 @@ impl Listener for Scanner { let mut state = self.state.lock().unwrap(); state.narration.push_str(text); state.accumulating.clear(); - for canonical in extract_wikilinks(&state.narration) { - match state.resolve(&canonical) { + for address in extract_wikilinks(&state.narration) { + match state.resolve(&address) { Resolution::Found(found) => { state.accumulating.insert(found); } + Resolution::Ambiguous => { + // Several entities share the name; nothing is injected. + } Resolution::None => { - // A name no entity matches is a new entity: register a - // stub so it resolves from here on, and write it to + // An address nothing matches is a new entity: register + // a stub so it resolves from here on, and write it to // disk when a world is present. Registering even // without a world keeps the reference in this turn's // context. - state.register(&canonical); - if let Some(dir) = &self.entities_dir { - write_stub(dir, &canonical); + state.register(&address); + if let Some(root) = &self.root { + write_stub(root, &address); } - state.accumulating.insert(canonical); + state.accumulating.insert(address); } } } @@ -154,35 +178,47 @@ impl Listener for Scanner { } } -/// Writes a stub entity file for `name` under `dir`, leaving a stub that -/// is already on disk alone. A write failure is ignored: a stub that -/// cannot be recorded is not worth failing a turn over, and the entity is -/// already registered in memory for this session. -fn write_stub(dir: &Path, name: &str) { - let _ = fs::create_dir_all(dir); - let file = dir.join(format!("{name}.md")); - if !file.exists() { - let _ = fs::write(file, stub_body(name)); +/// Writes a stub entity file for `address` under `root`, leaving a stub +/// that is already on disk alone. +/// +/// A scoped address keeps its scope: `people/Joseph Black` lands under +/// `people/`. A bare address lands under `entities/`, the home for +/// things whose kind is not yet known. A write failure is ignored: a +/// stub that cannot be recorded is not worth failing a turn over, and the +/// entity is already registered in memory for this session. +fn write_stub(root: &Path, address: &str) { + let relative = if address.contains('/') { + String::from(address) + } else { + format!("entities/{address}") + }; + let file = root.join(relative).with_extension("md"); + if file.exists() { + return; } + let parent = file.parent().expect("a stub file has a parent directory"); + let _ = fs::create_dir_all(parent); + let _ = fs::write(file, stub_body(address)); } -/// The body of a fresh entity stub: a name and two empty sections for -/// what it is and what it was, which a later phase fills in. -fn stub_body(name: &str) -> String { +/// The body of a fresh entity stub: the entity's name and two empty +/// sections for what it is and what it was, which a later phase fills in. +fn stub_body(address: &str) -> String { + let name = name_suffix(address); format!("# {name}\n\n## Is\n\n\n## Was\n\n") } -/// The canonical names a piece of narration references, in the order the +/// The addresses a piece of narration references, in the order the /// markdown parser emits them. /// /// A wikilink's destination is the text before any pipe, so -/// `[[Vera Blackwater|the old assassin]]` resolves against +/// `[[Vera Blackwater|the old assassin]]` resolves against the address /// `Vera Blackwater`. Narration arrives in deltas of arbitrary size; /// [`Scanner`] re-scans the whole turn so an incomplete link still /// resolves once its final half arrives. pub fn extract_wikilinks(text: &str) -> Vec { let parser = Parser::new_ext(text, Options::ENABLE_WIKILINKS); - let mut names = Vec::new(); + let mut addresses = Vec::new(); for event in parser { if let Event::Start(Tag::Link { link_type: LinkType::WikiLink { .. }, @@ -190,10 +226,10 @@ pub fn extract_wikilinks(text: &str) -> Vec { .. }) = event { - names.push(dest_url.into_string()); + addresses.push(dest_url.into_string()); } } - names + addresses } #[cfg(test)]