diff --git a/plans/0011-session-state.md b/plans/0011-session-state.md index 0464404..32c5d83 100644 --- a/plans/0011-session-state.md +++ b/plans/0011-session-state.md @@ -176,7 +176,7 @@ engine can re-scan it on restart. - [x] 1. The wikilink scanner as a listener on the session-scoped bus — extract `[[...]]` patterns from narration deltas, resolve canonical names -- [ ] 2. Auto-establishment — create stub entity files when a wikilink does +- [x] 2. Auto-establishment — create stub entity files when a wikilink does not resolve - [ ] 3. Context injection — push referenced entities' `## Is` sections into the next turn's prompt diff --git a/src/campaign/mod.rs b/src/campaign/mod.rs index 963c84f..aed0d33 100644 --- a/src/campaign/mod.rs +++ b/src/campaign/mod.rs @@ -19,13 +19,14 @@ pub use log::CampaignLog; pub use recall::{RecallHit, Source, search}; pub use transcript::Transcript; -use std::path::Path; +use std::path::{Path, PathBuf}; /// A world's record of time and events: the campaign log under /// `campaign-log/`, DM-only and forward-only, and the transcript under /// `transcript/`, shared with the player. #[derive(Debug, Clone)] pub struct Campaign { + world: PathBuf, log: CampaignLog, transcript: Transcript, } @@ -34,6 +35,7 @@ impl Campaign { /// Opens (or creates) the campaign under world root `world`. pub fn open(world: &Path) -> Result { let campaign = Self { + world: world.to_path_buf(), log: CampaignLog::new(world.join("campaign-log")), transcript: Transcript::new(world.join("transcript")), }; @@ -42,6 +44,11 @@ impl Campaign { Ok(campaign) } + /// The world root this campaign records under. + pub fn world(&self) -> &Path { + &self.world + } + /// The current game clock: the last `#dX-HHMM` anchor in the campaign /// log, or the campaign's starting moment, `#d1-0000`, when no event /// has been marked yet. diff --git a/src/dm/mod.rs b/src/dm/mod.rs index eeff05c..6343ec6 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -199,8 +199,11 @@ 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 + .as_ref() + .map(|campaign| campaign.world().join("entities")); let mut bus = TurnBus::new(); - bus.add(Box::new(Scanner::new(Arc::clone(&scan)))); + bus.add(Box::new(Scanner::new(Arc::clone(&scan), entities_dir))); 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 416005c..3733746 100644 --- a/src/entities/entities_tests.rs +++ b/src/entities/entities_tests.rs @@ -3,8 +3,9 @@ use std::sync::{Arc, Mutex}; -use super::{Resolution, ScanState, Scanner, extract_wikilinks}; +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. @@ -99,7 +100,7 @@ fn registering_the_same_name_twice_is_a_no_op() { fn the_scanner_accumulates_references_and_promotes_them_on_done() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); - let mut scanner = Scanner::new(Arc::clone(&state)); + let mut scanner = Scanner::new(Arc::clone(&state), None); assert_eq!( scanner.delta("You see [[Vera Blackwater]] watching you."), @@ -112,23 +113,28 @@ fn the_scanner_accumulates_references_and_promotes_them_on_done() { } #[test] -fn an_unresolved_reference_is_left_out_of_the_set() { +fn a_new_name_is_auto_established_alongside_a_resolved_one() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); - let mut scanner = Scanner::new(Arc::clone(&state)); + let mut scanner = Scanner::new(Arc::clone(&state), None); scanner.turn_start("I sleep."); scanner.delta("[[Vera]] appears. [[Vera Blackwater]] is here."); scanner.done("[Vera] appears. Vera Blackwater is here."); - assert_eq!(completed_names(&state), vec!["Vera Blackwater".to_string()]); + // `[[Vera]]` matches no known entity, so it becomes a stub of its + // own; `[[Vera Blackwater]]` is already known. Both are in context. + assert_eq!( + completed_names(&state), + vec!["Vera".to_string(), "Vera Blackwater".to_string()] + ); } #[test] fn a_turn_that_never_finishes_leaves_the_completed_set_alone() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); - let mut scanner = Scanner::new(Arc::clone(&state)); + let mut scanner = Scanner::new(Arc::clone(&state), None); // A clean first turn promotes Vera. scanner.turn_start("I sleep."); @@ -147,7 +153,7 @@ fn a_turn_that_never_finishes_leaves_the_completed_set_alone() { fn a_turn_start_resets_the_accumulation_for_the_next_turn() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); - let mut scanner = Scanner::new(Arc::clone(&state)); + let mut scanner = Scanner::new(Arc::clone(&state), None); scanner.turn_start("I sleep."); scanner.delta("[[Vera Blackwater]] is here."); @@ -164,7 +170,7 @@ fn a_turn_start_resets_the_accumulation_for_the_next_turn() { fn a_wikilink_split_across_deltas_is_still_resolved() { let state = Arc::new(Mutex::new(ScanState::default())); state.lock().unwrap().register("Vera Blackwater"); - let mut scanner = Scanner::new(Arc::clone(&state)); + let mut scanner = Scanner::new(Arc::clone(&state), None); scanner.turn_start("I listen."); scanner.delta("She is [[Vera "); @@ -175,3 +181,70 @@ fn a_wikilink_split_across_deltas_is_still_resolved() { // delta, catches a link the model split across two deltas. assert_eq!(completed_names(&state), vec!["Vera Blackwater".to_string()]); } + +// --------------------------------------------------------------------------- +// auto-establishment +// --------------------------------------------------------------------------- + +#[test] +fn an_unseen_name_writes_a_stub_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())); + + 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"); + assert_eq!( + std::fs::read_to_string(&stub_path).unwrap(), + stub_body("Vera Blackwater") + ); + assert_eq!(completed_names(&state), vec!["Vera Blackwater".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())); + + // A fresh session does not load disk stubs yet, so the name resolves + // as unknown and auto-establishes, which must not overwrite the file. + scanner.turn_start("I enter."); + scanner.delta("[[Vera Blackwater]] is here."); + + assert_eq!( + std::fs::read_to_string(&stub_path).unwrap(), + "# Vera Blackwater\n\n## Is\n\nThe old assassin.\n" + ); +} + +#[test] +fn a_new_name_with_no_world_holds_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_names(&state), + vec!["the Rusty Anchor".to_string()] + ); +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 5ffbea5..3ae9d3d 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -13,6 +13,8 @@ //! this one. use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use pulldown_cmark::{Event, LinkType, Options, Parser, Tag}; @@ -99,12 +101,17 @@ impl ScanState { /// prompt should read. pub struct Scanner { state: Arc>, + entities_dir: Option, } impl Scanner { - /// A scanner sharing `state` with the `Dm` that reads it. - pub fn new(state: Arc>) -> Self { - Self { state } + /// 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, + } } } @@ -120,8 +127,22 @@ impl Listener for Scanner { state.narration.push_str(text); state.accumulating.clear(); for canonical in extract_wikilinks(&state.narration) { - if let Resolution::Found(found) = state.resolve(&canonical) { - state.accumulating.insert(found); + match state.resolve(&canonical) { + Resolution::Found(found) => { + state.accumulating.insert(found); + } + Resolution::None => { + // A name no entity 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.accumulating.insert(canonical); + } } } Flow::Continue @@ -133,6 +154,24 @@ 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)); + } +} + +/// 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 { + format!("# {name}\n\n## Is\n\n\n## Was\n\n") +} + /// The canonical names a piece of narration references, in the order the /// markdown parser emits them. ///