diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 74349f0..bd20c6d 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -14,7 +14,7 @@ use crate::campaign::Campaign; use crate::chat::{ChatError, Client, Message, Role, StreamItem}; use crate::config::Config; use crate::context::ContextStack; -use crate::entities::{ScanState, Scanner}; +use crate::entities::{ScanState, Scanner, seed_resolver}; use crate::knowledge::Mount; use tools::Tool; use tools::Toolbox; @@ -204,6 +204,12 @@ impl Dm { let entity_root = campaign .as_ref() .map(|campaign| campaign.world().to_path_buf()); + if let Some(root) = &entity_root { + // Every note already on disk registers before the first turn, + // so a bare wikilink resolves to it instead of establishing a + // duplicate stub. + seed_resolver(root, &mut scan.lock().unwrap()); + } let mut bus = TurnBus::new(); bus.add(Box::new(Scanner::new(Arc::clone(&scan), entity_root))); if let Some(campaign) = campaign.clone() { diff --git a/src/entities/entities_mentions_tests.rs b/src/entities/entities_mentions_tests.rs deleted file mode 100644 index df4b252..0000000 --- a/src/entities/entities_mentions_tests.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Tests for the mention back-reference: the way an auto-established stub -//! gains a `## Mentions` section pointing back at the campaign transcript. - -use std::sync::{Arc, Mutex}; - -use tempfile::TempDir; - -use super::{MENTION_SNIPPET_CHARS, ScanState, Scanner, append_mention, snippet_around}; -use crate::bus::Listener; -use crate::campaign::{CampaignLog, GameTime}; - -/// 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.address.clone()) - .collect() -} - -// --------------------------------------------------------------------------- -// the mention itself -// --------------------------------------------------------------------------- - -#[test] -fn a_new_stub_gets_a_mention_with_the_anchor_and_snippet() { - let world = TempDir::new().unwrap(); - // Seed a game time so the mention points at the right transcript - // moment rather than falling back to midnight. - let log = CampaignLog::new(world.path().join("campaign-log")); - log.append(GameTime::parse("#d2-0830").unwrap(), "the party arrives") - .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("You meet [[Vera Blackwater]], the innkeeper."); - scanner.done("You meet Vera Blackwater, the innkeeper."); - - let file = world.path().join("entities/Vera Blackwater.md"); - let content = std::fs::read_to_string(&file).unwrap(); - assert!(content.contains("## Mentions")); - assert!(content.contains("- mentioned at #d2-0830: You meet Vera Blackwater, the innkeeper.")); - assert_eq!( - completed_addresses(&state), - vec!["Vera Blackwater".to_string()] - ); -} - -#[test] -fn a_fresh_world_with_no_anchor_falls_back_to_start_midnight() { - 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("The [[Rusty Anchor]] looms ahead."); - scanner.done("The Rusty Anchor looms ahead."); - - let file = world.path().join("entities/Rusty Anchor.md"); - assert!( - std::fs::read_to_string(&file) - .unwrap() - .contains("- mentioned at #d1-0000: The Rusty Anchor looms ahead.") - ); -} - -#[test] -fn a_scoped_stub_gets_its_mention_under_the_same_path() { - 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."); - - let file = world.path().join("people/Joseph Black.md"); - let content = std::fs::read_to_string(&file).unwrap(); - assert!(content.contains("## Mentions")); - assert!(content.contains("Joseph Black")); - assert!(!world.path().join("entities/Joseph Black.md").exists()); -} - -#[test] -fn a_second_turn_reusing_a_known_name_appends_no_second_mention() { - 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("You meet [[Vera Blackwater]], the innkeeper."); - scanner.done("You meet Vera Blackwater, the innkeeper."); - - let file = world.path().join("entities/Vera Blackwater.md"); - let first = std::fs::read_to_string(&file).unwrap(); - assert_eq!(first.matches("## Mentions").count(), 1); - - // Turn two re-references the now-known entity; it resolves instead of - // establishing, so nothing is appended. - scanner.turn_start("I stay."); - scanner.delta("[[Vera Blackwater]] serves drinks."); - scanner.done("Vera Blackwater serves drinks."); - - assert_eq!( - std::fs::read_to_string(&file) - .unwrap() - .matches("## Mentions") - .count(), - 1 - ); -} - -#[test] -fn a_turn_with_no_new_entities_appends_nothing_to_an_existing_stub() { - let world = TempDir::new().unwrap(); - let log = CampaignLog::new(world.path().join("campaign-log")); - log.append(GameTime::parse("#d1-1200").unwrap(), "arrival") - .unwrap(); - // An entity the session already knows, so a fresh reference resolves - // instead of establishing. - let stub = world.path().join("entities/Vera Blackwater.md"); - std::fs::create_dir_all(stub.parent().unwrap()).unwrap(); - let before = "# Vera Blackwater\n\n## Is\n\nThe old assassin.\n"; - std::fs::write(&stub, before).unwrap(); - - let state = Arc::new(Mutex::new(ScanState::default())); - state.lock().unwrap().register("Vera Blackwater"); - 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."); - scanner.done("Vera Blackwater is here."); - - assert_eq!(std::fs::read_to_string(&stub).unwrap(), before); -} - -#[test] -fn without_a_world_an_established_name_stays_in_memory_only() { - let state = Arc::new(Mutex::new(ScanState::default())); - let mut scanner = Scanner::new(Arc::clone(&state), None); - - scanner.turn_start("I enter."); - scanner.delta("[[the Rusty Anchor]] looms ahead."); - scanner.done("the Rusty Anchor looms ahead."); - - assert_eq!( - completed_addresses(&state), - vec!["the Rusty Anchor".to_string()] - ); -} - -// --------------------------------------------------------------------------- -// the snippet helper -// --------------------------------------------------------------------------- - -#[test] -fn snippet_around_strips_delimiters_and_keeps_the_name() { - assert_eq!( - snippet_around("you meet [[Vera Blackwater]] the", "Vera Blackwater"), - "you meet Vera Blackwater the" - ); -} - -#[test] -fn snippet_around_returns_empty_when_the_name_is_absent() { - assert_eq!(snippet_around("you meet Vera Blackwater", "Nobody"), ""); -} - -#[test] -fn snippet_around_trims_to_word_boundaries_and_strips_delimiters() { - let pad = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor "; - let trail = "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis \ -nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; - let narration = format!("{pad}[[Vera Blackwater]] {trail}"); - let snippet = snippet_around(&narration, "Vera Blackwater"); - - assert!(snippet.contains("Vera Blackwater")); - assert!(!snippet.contains("[[")); - assert!(!snippet.starts_with(|character: char| character.is_whitespace())); - assert!(!snippet.ends_with(|character: char| character.is_whitespace())); - assert!(snippet.len() <= MENTION_SNIPPET_CHARS + 20); -} - -// --------------------------------------------------------------------------- -// the append guard -// --------------------------------------------------------------------------- - -#[test] -fn a_stub_already_carrying_mentions_is_not_appended_to_again() { - let world = TempDir::new().unwrap(); - let file = world.path().join("entities/Vera Blackwater.md"); - std::fs::create_dir_all(file.parent().unwrap()).unwrap(); - let before = "# Vera Blackwater\n\n## Is\n\nThe old assassin.\n\n## Mentions\n\n- mentioned at #d1-0000: earlier\n"; - std::fs::write(&file, before).unwrap(); - - append_mention( - world.path(), - "Vera Blackwater", - GameTime::parse("#d2-0830").unwrap(), - "You meet [[Vera Blackwater]] again.", - ); - - assert_eq!(std::fs::read_to_string(&file).unwrap(), before); -} - -#[test] -fn append_mention_leaves_a_missing_stub_alone() { - let world = TempDir::new().unwrap(); - append_mention( - world.path(), - "people/Jane Doe", - GameTime::parse("#d1-0830").unwrap(), - "[[Jane Doe]] steps in.", - ); - assert!(!world.path().join("people/Jane Doe.md").exists()); -} diff --git a/src/entities/entities_seeding_tests.rs b/src/entities/entities_seeding_tests.rs new file mode 100644 index 0000000..b3e4dec --- /dev/null +++ b/src/entities/entities_seeding_tests.rs @@ -0,0 +1,98 @@ +//! Tests for [`super::seed_resolver`], which loads every note already on +//! disk into the resolver at startup. + +use std::fs; + +use tempfile::TempDir; + +use super::{Resolution, ScanState, seed_resolver}; + +#[test] +fn a_note_registers_by_its_path_relative_to_the_world_root() { + let world = TempDir::new().unwrap(); + let file = world.path().join("people/Joseph Black.md"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, "---\nkind: person\naliases: []\n---\n\nA guard.\n").unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!( + state.resolve("people/Joseph Black"), + Resolution::Found("people/Joseph Black".to_string()) + ); +} + +#[test] +fn a_notes_aliases_come_from_its_frontmatter() { + let world = TempDir::new().unwrap(); + let file = world.path().join("entities/Vera Blackwater.md"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write( + &file, + "---\nkind: person\naliases: [Vera, the innkeeper]\n---\n\nRuns the inn.\n", + ) + .unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!( + state.resolve("the innkeeper"), + Resolution::Found("entities/Vera Blackwater".to_string()) + ); +} + +#[test] +fn seeding_skips_the_transcript_and_campaign_log_directories() { + let world = TempDir::new().unwrap(); + let transcript = world.path().join("transcript/day-1.md"); + let log = world.path().join("campaign-log/day-1.md"); + fs::create_dir_all(transcript.parent().unwrap()).unwrap(); + fs::create_dir_all(log.parent().unwrap()).unwrap(); + fs::write(&transcript, "not a note").unwrap(); + fs::write(&log, "not a note").unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!(state.resolve("transcript/day-1"), Resolution::None); + assert_eq!(state.resolve("campaign-log/day-1"), Resolution::None); +} + +#[test] +fn seeding_skips_the_session_brief() { + let world = TempDir::new().unwrap(); + fs::write(world.path().join("session.md"), "the previously-on brief").unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!(state.resolve("session"), Resolution::None); +} + +#[test] +fn a_note_with_broken_frontmatter_still_registers_with_no_aliases() { + let world = TempDir::new().unwrap(); + let file = world.path().join("entities/Hand Written.md"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, "Just a hand-written note, no frontmatter at all.\n").unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!( + state.resolve("entities/Hand Written"), + Resolution::Found("entities/Hand Written".to_string()) + ); +} + +#[test] +fn a_world_root_that_does_not_exist_registers_nothing() { + let world = TempDir::new().unwrap(); + + let mut state = ScanState::default(); + seed_resolver(&world.path().join("not-here"), &mut state); + + assert_eq!(state.resolve("entities/Anyone"), Resolution::None); +} diff --git a/src/entities/entities_stub_tests.rs b/src/entities/entities_stub_tests.rs new file mode 100644 index 0000000..7715d12 --- /dev/null +++ b/src/entities/entities_stub_tests.rs @@ -0,0 +1,124 @@ +//! Tests for a fresh entity stub's shape: frontmatter plus the seed line +//! that carries the narration which established it. + +use std::sync::{Arc, Mutex}; + +use tempfile::TempDir; + +use super::{SEED_SNIPPET_CHARS, ScanState, Scanner, snippet_around, stub_body}; +use crate::bus::Listener; +use crate::campaign::{CampaignLog, GameTime, START}; + +// --------------------------------------------------------------------------- +// stub_body +// --------------------------------------------------------------------------- + +#[test] +fn a_bare_address_gets_kind_unknown() { + assert_eq!( + stub_body("Vera Blackwater", START, "You meet Vera Blackwater."), + "---\nkind: unknown\naliases: []\nstub: true\n---\n\n\ + First seen #d1-0000: \"You meet Vera Blackwater.\"\n" + ); +} + +#[test] +fn a_scoped_address_declares_its_kind_inline() { + assert_eq!( + stub_body("people/Joseph Black", START, "Joseph Black walks in."), + "---\nkind: people\naliases: []\nstub: true\n---\n\n\ + First seen #d1-0000: \"Joseph Black walks in.\"\n" + ); +} + +#[test] +fn a_nested_scope_declares_its_whole_path_as_the_kind() { + assert_eq!( + stub_body( + "guards/night/Joseph Black", + START, + "Joseph Black steps from the shadows." + ), + "---\nkind: guards/night\naliases: []\nstub: true\n---\n\n\ + First seen #d1-0000: \"Joseph Black steps from the shadows.\"\n" + ); +} + +// --------------------------------------------------------------------------- +// writing the stub, once, at done +// --------------------------------------------------------------------------- + +#[test] +fn a_new_stub_carries_the_turn_time_and_snippet() { + let world = TempDir::new().unwrap(); + let log = CampaignLog::new(world.path().join("campaign-log")); + log.append(GameTime::parse("#d2-0830").unwrap(), "the party arrives") + .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("You meet [[Vera Blackwater]], the innkeeper."); + scanner.done("You meet Vera Blackwater, the innkeeper."); + + let file = world.path().join("entities/Vera Blackwater.md"); + assert_eq!( + std::fs::read_to_string(&file).unwrap(), + stub_body( + "Vera Blackwater", + GameTime::parse("#d2-0830").unwrap(), + "You meet [[Vera Blackwater]], the innkeeper." + ) + ); +} + +#[test] +fn a_fresh_world_with_no_anchor_falls_back_to_start_midnight() { + 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("The [[Rusty Anchor]] looms ahead."); + scanner.done("The Rusty Anchor looms ahead."); + + let file = world.path().join("entities/Rusty Anchor.md"); + assert!( + std::fs::read_to_string(&file) + .unwrap() + .contains("First seen #d1-0000:") + ); +} + +// --------------------------------------------------------------------------- +// the snippet helper +// --------------------------------------------------------------------------- + +#[test] +fn snippet_around_strips_delimiters_and_keeps_the_name() { + assert_eq!( + snippet_around("you meet [[Vera Blackwater]] the", "Vera Blackwater"), + "you meet Vera Blackwater the" + ); +} + +#[test] +fn snippet_around_returns_empty_when_the_name_is_absent() { + assert_eq!(snippet_around("you meet Vera Blackwater", "Nobody"), ""); +} + +#[test] +fn snippet_around_trims_to_word_boundaries_and_strips_delimiters() { + let pad = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor "; + let trail = "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis \ +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + let narration = format!("{pad}[[Vera Blackwater]] {trail}"); + let snippet = snippet_around(&narration, "Vera Blackwater"); + + assert!(snippet.contains("Vera Blackwater")); + assert!(!snippet.contains("[[")); + assert!(!snippet.starts_with(|character: char| character.is_whitespace())); + assert!(!snippet.ends_with(|character: char| character.is_whitespace())); + assert!(snippet.len() <= SEED_SNIPPET_CHARS + 20); +} diff --git a/src/entities/entities_tests.rs b/src/entities/entities_tests.rs index ad0fede..1455a55 100644 --- a/src/entities/entities_tests.rs +++ b/src/entities/entities_tests.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex}; use super::{Resolution, ScanState, Scanner, extract_wikilinks, stub_body}; use crate::bus::{Flow, Listener}; +use crate::campaign::START; use tempfile::TempDir; /// The completed entity addresses, read while the lock guard is held so @@ -122,6 +123,49 @@ fn registering_the_same_address_twice_is_a_no_op() { ); } +#[test] +fn an_alias_resolves_to_its_entity() { + let mut state = ScanState::default(); + state.register_with_aliases("Vera Blackwater", vec!["the old assassin".to_string()]); + assert_eq!( + state.resolve("the old assassin"), + Resolution::Found("Vera Blackwater".to_string()) + ); +} + +#[test] +fn an_alias_resolves_ignoring_case() { + let mut state = ScanState::default(); + state.register_with_aliases("Vera Blackwater", vec!["The Innkeeper".to_string()]); + assert_eq!( + state.resolve("the innkeeper"), + Resolution::Found("Vera Blackwater".to_string()) + ); +} + +#[test] +fn an_alias_shared_by_two_entities_is_ambiguous() { + let mut state = ScanState::default(); + state.register_with_aliases("people/Vera Blackwater", vec!["the innkeeper".to_string()]); + state.register_with_aliases("people/Marta Coalstack", vec!["the innkeeper".to_string()]); + assert_eq!(state.resolve("the innkeeper"), Resolution::Ambiguous); +} + +#[test] +fn a_scoped_alias_resolves_a_retired_address() { + // A retired scoped address, kept as an alias after a move, still + // resolves, even though it looks like a scoped reference itself. + let mut state = ScanState::default(); + state.register_with_aliases( + "people/Vera Blackwater", + vec!["entities/Vera Blackwater".to_string()], + ); + assert_eq!( + state.resolve("entities/Vera Blackwater"), + Resolution::Found("people/Vera Blackwater".to_string()) + ); +} + // --------------------------------------------------------------------------- // the scanner as a listener // --------------------------------------------------------------------------- @@ -236,11 +280,11 @@ fn an_unseen_bare_name_writes_a_stub_under_entities_and_lands_in_context() { scanner.done("You meet Vera Blackwater, the innkeeper."); let stub = world.path().join("entities/Vera Blackwater.md"); - let content = std::fs::read_to_string(&stub).unwrap(); - // The blank stub is written, then done() stamps it with a back - // reference to the transcript's start moment (no log exists yet). - assert!(content.starts_with(&stub_body("Vera Blackwater"))); - assert!(content.contains("## Mentions")); + let narration = "You meet [[Vera Blackwater]], the innkeeper."; + assert_eq!( + std::fs::read_to_string(&stub).unwrap(), + stub_body("Vera Blackwater", START, narration) + ); assert_eq!( completed_addresses(&state), vec!["Vera Blackwater".to_string()] @@ -259,9 +303,11 @@ fn an_unseen_scoped_name_writes_a_stub_under_its_own_scope() { // The scoped stub lands under people/, not under entities/. let stub = world.path().join("people/Joseph Black.md"); - let content = std::fs::read_to_string(&stub).unwrap(); - assert!(content.starts_with(&stub_body("people/Joseph Black"))); - assert!(content.contains("## Mentions")); + let narration = "A guard steps forward: [[people/Joseph Black]]."; + assert_eq!( + std::fs::read_to_string(&stub).unwrap(), + stub_body("people/Joseph Black", START, narration) + ); assert!(!world.path().join("entities/Joseph Black.md").exists()); assert_eq!( completed_addresses(&state), @@ -272,11 +318,12 @@ fn an_unseen_scoped_name_writes_a_stub_under_its_own_scope() { #[test] fn a_stub_already_on_disk_is_left_alone() { let world = TempDir::new().unwrap(); - // An established entity from an earlier session is never clobbered - // back into an empty stub. + // A note from an earlier session is never clobbered back into a + // fresh stub. 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(); + let before = "---\nkind: person\naliases: []\n---\n\nThe old assassin.\n"; + std::fs::write(&stub, before).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. @@ -285,11 +332,9 @@ fn a_stub_already_on_disk_is_left_alone() { scanner.turn_start("I enter."); scanner.delta("[[Vera Blackwater]] is here."); + scanner.done("Vera Blackwater is here."); - assert_eq!( - std::fs::read_to_string(&stub).unwrap(), - "# Vera Blackwater\n\n## Is\n\nThe old assassin.\n" - ); + assert_eq!(std::fs::read_to_string(&stub).unwrap(), before); } #[test] @@ -326,3 +371,39 @@ fn a_new_name_with_no_world_holds_in_memory_only() { vec!["the Rusty Anchor".to_string()] ); } + +#[test] +fn a_stub_is_written_at_done_not_at_delta() { + 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("You meet [[Vera Blackwater]]."); + + let stub = world.path().join("entities/Vera Blackwater.md"); + assert!(!stub.exists()); + + scanner.done("You meet Vera Blackwater."); + + assert!(stub.exists()); +} + +#[test] +fn a_cancelled_turn_writes_no_stub() { + 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("You meet [[Vera Blackwater]]."); + scanner.cancelled("You meet Vera Blackwater."); + + assert!(!world.path().join("entities/Vera Blackwater.md").exists()); + // The address still resolves in memory, so a second reference this + // session does not establish a duplicate. + assert_eq!( + state.lock().unwrap().resolve("Vera Blackwater"), + Resolution::Found("Vera Blackwater".to_string()) + ); +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 01a2bee..3f7ee12 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -9,33 +9,49 @@ //! 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. +//! matches by name across whatever scope holds it. Resolution also +//! checks an entity's aliases, so a retired address or an alternate name +//! still finds its way home. 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. [`seed_resolver`] +//! walks the world root at startup and registers every note already on +//! disk, so a bare name cannot re-establish an entity that already +//! exists. Reading an entity's note body into the next prompt is a +//! later phase. use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use pulldown_cmark::{Event, LinkType, Options, Parser, Tag}; +use serde_yaml_ng::Value; use crate::bus::{Flow, Listener}; use crate::campaign::{CampaignLog, GameTime, START}; +use crate::knowledge::read_frontmatter; -/// The width of the narration window a stub's mention snippet carries -/// around the entity's name. Big enough to read the scene, small enough -/// to stay a footnote. -const MENTION_SNIPPET_CHARS: usize = 120; +/// The width of the narration window a stub's seed line carries around +/// the entity's name. Big enough to read the scene, small enough to stay +/// a footnote. +const SEED_SNIPPET_CHARS: usize = 120; + +/// The record's own directories: never a source of notes, so the +/// resolver seeding walk never enters them. +const RECORD_DIRS: [&str; 2] = ["transcript", "campaign-log"]; + +/// The session brief: a note like any other, but scanned through its own +/// path at session start rather than picked up by the resolver seeding +/// walk. +const SESSION_BRIEF: &str = "session.md"; /// An entity the session knows. Its `address` is the key it resolves -/// against, and a later phase adds the sections that describe it. +/// against, and `aliases` are the other names, including retired +/// addresses, that also resolve to it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entity { pub address: String, + pub aliases: Vec, } /// How a wikilink resolved against the known entities. @@ -66,35 +82,60 @@ pub struct ScanState { /// The entities the most recently finished turn referenced, read into /// the next turn's prompt. completed: BTreeSet, - /// The addresses auto-established this turn, stamped with a back - /// reference to the transcript when the turn finishes. Cleared each - /// turn so a second turn that reuses an established name appends - /// nothing new. + /// The addresses auto-established this turn, whose stubs are written + /// to disk when the turn finishes. Cleared each turn so a second turn + /// that reuses an established name writes nothing new. established: BTreeSet, } impl ScanState { - /// Registers an entity by `address` so future wikilinks resolve to - /// it. Registering an address that is already known is a no-op. + /// Registers an entity by `address`, with no aliases, 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.register_with_aliases(address, Vec::new()); + } + + /// Registers an entity by `address` and `aliases` so future + /// wikilinks resolve to it by either. Registering an address that is + /// already known is a no-op; its aliases are not merged in. + pub fn register_with_aliases(&mut self, address: &str, aliases: Vec) { self.entities .entry(address.to_lowercase()) .or_insert_with(|| Entity { address: address.to_string(), + aliases, }); } + /// Resolves `address` against the known entities: first an exact + /// address match, then an alias match, and, for a bare address only, + /// a match against a known entity's final name segment. A scoped + /// address that misses the first two is simply not here, since its + /// scope already picks out exactly one entity. fn resolve(&self, address: &str) -> Resolution { - if let Some(entity) = self.entities.get(&address.to_lowercase()) { + let needle = address.to_lowercase(); + if let Some(entity) = self.entities.get(&needle) { 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. + let alias_matches: Vec<&Entity> = self + .entities + .values() + .filter(|entity| { + entity + .aliases + .iter() + .any(|alias| alias.to_lowercase() == needle) + }) + .collect(); + match alias_matches.len() { + 1 => return Resolution::Found(alias_matches[0].address.clone()), + 0 => {} + _ => return Resolution::Ambiguous, + } if address.contains('/') { return Resolution::None; } - let needle = address.to_lowercase(); let matches: Vec<&Entity> = self .entities .values() @@ -170,15 +211,12 @@ impl Listener for Scanner { } Resolution::None => { // 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. + // it so it resolves from here on, and remember it as + // established this turn so `done` writes its stub. + // Registering even without a world keeps the reference + // in this turn's context. state.register(&address); state.established.insert(address.clone()); - if let Some(root) = &self.root { - write_stub(root, &address); - } state.accumulating.insert(address); } } @@ -195,39 +233,39 @@ impl Listener for Scanner { if state.established.is_empty() { return; } - // Stamp each newly established stub with the campaign's current - // moment, so the entity's mention points back at the transcript; - // a world with no log yet starts at midnight of day one. + // Stubs are written here, once, with the whole turn's narration in + // hand: a turn that never reaches `done` — cancelled, failed, or + // panicked — writes nothing to disk. Each stub's seed line points + // back at the campaign's current moment; a world with no log yet + // starts at midnight of day one. let time = CampaignLog::new(root.join("campaign-log")) .last_anchor() .ok() .flatten() .unwrap_or(START); for address in &state.established { - append_mention(root, address, time, &state.narration); + write_stub(root, address, time, &state.narration); } } } -/// Writes a blank stub entity file for `address` under `root`, leaving a +/// Writes one established stub for `address` under `root`, 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(root: &Path, address: &str) { +fn write_stub(root: &Path, address: &str, time: GameTime, narration: &str) { let file = stub_path(root, address); 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)); + let _ = fs::write(file, stub_body(address, time, narration)); } /// The file a stub for `address` lives at under `root`. A scoped address /// keeps its scope, `people/`, while a bare one lands under `entities/`, -/// the home for things whose kind is not yet known. Both [`write_stub`] -/// and [`append_mention`] resolve the path here so they can never place -/// a mention on a different file than the one they wrote. +/// the home for things whose kind is not yet known. fn stub_path(root: &Path, address: &str) -> PathBuf { let relative = if address.contains('/') { String::from(address) @@ -237,24 +275,41 @@ fn stub_path(root: &Path, address: &str) -> PathBuf { root.join(relative).with_extension("md") } -/// 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 { +/// The body of a fresh entity stub: frontmatter for the machines, and the +/// narration that summoned it as a seed line the Archivist rewrites into +/// a real briefing. +/// +/// A scoped address declares its kind inline, so `people/Joseph Black`'s +/// stub carries `kind: people`; a bare address carries `kind: unknown`. +/// Every stub carries `stub: true` and starts with no aliases. +fn stub_body(address: &str, time: GameTime, narration: &str) -> String { + let kind = stub_kind(address); let name = name_suffix(address); - format!("# {name}\n\n## Is\n\n\n## Was\n\n") + let snippet = snippet_around(narration, name); + format!("---\nkind: {kind}\naliases: []\nstub: true\n---\n\nFirst seen {time}: \"{snippet}\"\n") +} + +/// The kind a fresh stub's address declares: the scope path as written, +/// everything before the address's final segment, or `unknown` for a +/// bare address with no scope. +fn stub_kind(address: &str) -> &str { + match address.rsplit_once('/') { + Some((scope, _name)) => scope, + None => "unknown", + } } /// The narration window around `name`, trimmed to word boundaries and -/// stripped of wikilink delimiters, for a stub's mention. An empty string -/// when `name` is not in `narration`. +/// stripped of wikilink delimiters, for a stub's seed line. An empty +/// string when `name` is not in `narration`. fn snippet_around(narration: &str, name: &str) -> String { let lower = narration.to_lowercase(); let needle = name.to_lowercase(); let Some(position) = lower.find(&needle) else { return String::new(); }; - let start = position.saturating_sub(MENTION_SNIPPET_CHARS / 2); - let end = (position + needle.len() + MENTION_SNIPPET_CHARS / 2).min(narration.len()); + let start = position.saturating_sub(SEED_SNIPPET_CHARS / 2); + let end = (position + needle.len() + SEED_SNIPPET_CHARS / 2).min(narration.len()); let start = narration[..start] .rfind(|character: char| character.is_whitespace()) .map(|index| index + 1) @@ -266,25 +321,75 @@ fn snippet_around(narration: &str, name: &str) -> String { narration[start..end].replace("[[", "").replace("]]", "") } -/// Adds a back reference to one stub, connecting it to the campaign -/// transcript: a `## Mentions` section recording the game time and the -/// narration that named it. A stub that already carries `## Mentions`, -/// from an earlier session, or whose file is missing is left alone. -fn append_mention(root: &Path, address: &str, time: GameTime, narration: &str) { - let file = stub_path(root, address); - let Ok(existing) = fs::read_to_string(&file) else { - return; +/// Registers every note under `root` into `state`, so a bare wikilink +/// resolves to the entity already on disk instead of establishing a +/// second stub for it. An address is the note's path relative to `root`, +/// `/`-separated, with the `.md` extension stripped; its aliases come +/// from the note's frontmatter. +/// +/// Skips the record's own directories, [`RECORD_DIRS`], and the session +/// brief, [`SESSION_BRIEF`]. A note whose frontmatter is missing or does +/// not parse still registers, by address alone with no aliases, so a +/// hand-written note never blocks startup. +pub(crate) fn seed_resolver(root: &Path, state: &mut ScanState) { + for file in find_notes(root) { + let relative = file + .strip_prefix(root) + .expect("find_notes only returns paths under root") + .with_extension(""); + let address = relative + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + let aliases = read_frontmatter(&file) + .ok() + .map(|value| aliases_from_frontmatter(&value)) + .unwrap_or_default(); + state.register_with_aliases(&address, aliases); + } +} + +/// The `aliases` frontmatter `value` declares, or none when the field is +/// absent or is not a sequence of strings. +fn aliases_from_frontmatter(value: &Value) -> Vec { + value + .as_mapping() + .and_then(|mapping| mapping.get("aliases")) + .and_then(Value::as_sequence) + .map(|sequence| { + sequence + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Every `.md` file under `root`, at any depth, except [`RECORD_DIRS`] +/// and [`SESSION_BRIEF`]. +fn find_notes(root: &Path) -> Vec { + let mut found = Vec::new(); + let Ok(read_dir) = fs::read_dir(root) else { + return found; }; - if existing.contains("## Mentions") { - return; + for entry in read_dir.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if path.is_dir() { + if RECORD_DIRS.contains(&name.as_ref()) { + continue; + } + found.extend(find_notes(&path)); + } else if name != SESSION_BRIEF + && path.extension().and_then(|ext| ext.to_str()) == Some("md") + { + found.push(path); + } } - let name = name_suffix(address); - let snippet = snippet_around(narration, name); - let mention = format!("\n## Mentions\n\n- mentioned at {time}: {snippet}\n\n"); - let _ = fs::OpenOptions::new() - .append(true) - .open(&file) - .and_then(|mut f| f.write_all(mention.as_bytes())); + found } /// The addresses a piece of narration references, in the order the @@ -316,5 +421,9 @@ pub fn extract_wikilinks(text: &str) -> Vec { mod tests; #[cfg(test)] -#[path = "entities_mentions_tests.rs"] -mod mentions_tests; +#[path = "entities_stub_tests.rs"] +mod stub_tests; + +#[cfg(test)] +#[path = "entities_seeding_tests.rs"] +mod seeding_tests;