From 10a51ba665fb0d48643305a5e462b6f8ce772238 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Mon, 3 Aug 2026 14:28:56 -0400 Subject: [PATCH] Stamp auto-established stubs with a back reference to the transcript A stub entity page had no way to reach the scene that brought it into being. When a stub is auto-established this turn, the scanner now reads the campaign clock and appends a Mentions section to the stub, pointing at the game time the narration lands under and carrying a window of the surrounding narration with its wikilink delimiters stripped. The mention is stamped at the end of the turn, when the transcript records the narration, so the anchor points at where the text actually lives. Only a stub established this turn is stamped, and a stub already carrying a Mentions section is left alone, so a session that re-establishes a known name does not pile up duplicate pointers. --- src/entities/entities_mentions_tests.rs | 219 ++++++++++++++++++++++++ src/entities/entities_tests.rs | 16 +- src/entities/mod.rs | 107 ++++++++++-- 3 files changed, 322 insertions(+), 20 deletions(-) create mode 100644 src/entities/entities_mentions_tests.rs 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; -- 2.51.2