diff --git a/src/entities/entities_mentions_tests.rs b/src/entities/entities_mentions_tests.rs new file mode 100644 index 0000000..df4b252 --- /dev/null +++ b/src/entities/entities_mentions_tests.rs @@ -0,0 +1,219 @@ +//! 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_tests.rs b/src/entities/entities_tests.rs index 113ca4b..ad0fede 100644 --- a/src/entities/entities_tests.rs +++ b/src/entities/entities_tests.rs @@ -236,10 +236,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"); - assert_eq!( - std::fs::read_to_string(&stub).unwrap(), - stub_body("Vera Blackwater") - ); + 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")); assert_eq!( completed_addresses(&state), vec!["Vera Blackwater".to_string()] @@ -258,10 +259,9 @@ 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"); - assert_eq!( - std::fs::read_to_string(&stub).unwrap(), - stub_body("people/Joseph Black") - ); + let content = std::fs::read_to_string(&stub).unwrap(); + assert!(content.starts_with(&stub_body("people/Joseph Black"))); + assert!(content.contains("## Mentions")); assert!(!world.path().join("entities/Joseph Black.md").exists()); assert_eq!( completed_addresses(&state), diff --git a/src/entities/mod.rs b/src/entities/mod.rs index af5a803..01a2bee 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -17,12 +17,19 @@ 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 crate::bus::{Flow, Listener}; +use crate::campaign::{CampaignLog, GameTime, START}; + +/// 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; /// An entity the session knows. Its `address` is the key it resolves /// against, and a later phase adds the sections that describe it. @@ -59,6 +66,11 @@ 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. + established: BTreeSet, } impl ScanState { @@ -141,6 +153,7 @@ impl Listener for Scanner { let mut state = self.state.lock().unwrap(); state.accumulating.clear(); state.narration.clear(); + state.established.clear(); } fn delta(&mut self, text: &str) -> Flow { @@ -162,6 +175,7 @@ impl Listener for Scanner { // 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); } @@ -175,24 +189,32 @@ impl Listener for Scanner { fn done(&mut self, _reply: &str) { let mut state = self.state.lock().unwrap(); state.completed = std::mem::take(&mut state.accumulating); + let Some(root) = &self.root else { + return; + }; + 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. + 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); + } } } -/// 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 +/// Writes a blank stub entity file 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) { - let relative = if address.contains('/') { - String::from(address) - } else { - format!("entities/{address}") - }; - let file = root.join(relative).with_extension("md"); + let file = stub_path(root, address); if file.exists() { return; } @@ -201,6 +223,20 @@ fn write_stub(root: &Path, address: &str) { let _ = fs::write(file, stub_body(address)); } +/// 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. +fn stub_path(root: &Path, address: &str) -> PathBuf { + let relative = if address.contains('/') { + String::from(address) + } else { + format!("entities/{address}") + }; + 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 { @@ -208,6 +244,49 @@ fn stub_body(address: &str) -> String { format!("# {name}\n\n## Is\n\n\n## Was\n\n") } +/// 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`. +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 = narration[..start] + .rfind(|character: char| character.is_whitespace()) + .map(|index| index + 1) + .unwrap_or(start); + let end = narration[end..] + .find(|character: char| character.is_whitespace()) + .map(|index| end + index) + .unwrap_or(narration.len()); + 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; + }; + if existing.contains("## Mentions") { + return; + } + 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())); +} + /// The addresses a piece of narration references, in the order the /// markdown parser emits them. /// @@ -235,3 +314,7 @@ pub fn extract_wikilinks(text: &str) -> Vec { #[cfg(test)] #[path = "entities_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "entities_mentions_tests.rs"] +mod mentions_tests;