diff --git a/plans/0011-session-state.md b/plans/0011-session-state.md index 4469818..0464404 100644 --- a/plans/0011-session-state.md +++ b/plans/0011-session-state.md @@ -43,9 +43,11 @@ wants, will) into the next turn's prompt. The alias direction matters: the part before the pipe is always the canonical address, so there is no ambiguity about which entity to pull. -If the DM writes `Vera` without a pipe, the engine does a best-match -resolution. If exactly one entity matches, it resolves. If more than one -matches, the engine reports the ambiguity and injects nothing. +If the DM writes `Vera` without a pipe, the engine tries `Vera` as a +canonical name. For now a reference resolves only when it exactly matches +a known canonical name; a best-match that tolerates abbreviated names, +and the ambiguity it must guard against, is deferred until stubs are +more common. ## Auto-establishment of stubs @@ -76,15 +78,58 @@ knows, wants, will, which also moves the entity from the flat `entities/` namespace into the typed directory (`npcs/`, `locations/`, `items/`, etc.). +`Campaign` discards its world root when it opens, exposing only its +`campaign-log/` and `transcript/` subdirectories, so the `Dm` cannot +reach the `entities/` namespace through it. Step 2 must thread the world +root into the `Dm` — either store the path beside the campaign or give +`Campaign` a `world()` accessor — so establishing a stub knows where to +write. + A stub that is never established costs nothing. A future planner agent can sweep stubs between sessions and establish the ones that matter. +## The bus is session-scoped + +The TurnBus used to be rebuilt on every turn: the worker constructed a +fresh bus and re-registered the screen and campaign listeners each +iteration. That worked while no listener needed to remember anything. A +scanner that accumulates entities turn to turn has nowhere to live on a +bus that is thrown away every turn. + +The bus's lifetime becomes the session's. The `Dm` owns it, alongside +the context stack and the message history, so one object holds all +session state. The worker builds the channel-backed screen and campaign +listeners once, hands them in, and loops calling `dm.turn(input)`; the +registration block leaves the per-turn loop entirely. The campaign +listener's per-turn input becomes a `set_input` call at turn-start +instead of a constructor argument, and the cancel flag is read as before. + +Owning the bus in the `Dm` is what lets the scanner feed the next turn's +prompt directly: `turn` resets the scanner's per-turn accumulation, +streams through its own bus, and at the next turn-start reads the +accumulated set into the assembled prompt. No shared mutable state, no +synchronization between the worker and the `Dm`. + +A turn can panic, and the worker catches it with `catch_unwind` and +`AssertUnwindSafe`, leaving the `Dm` in whatever state the panic found it +in. The existing code tolerates a poisoned history only because the +system message at its head is recomposed from the context stack next +turn. The scanner must satisfy the same contract: `turn` resets its +accumulation before any fallible work, and only streams deltas into it +after that reset succeeds. That means the scanner keeps two buffers: an +accumulating one that deltas fill and a completed one that the next turn's +prompt reads. Reset empties the accumulating buffer, deltas stream into +it, and `done` promotes it to completed. Reset comes first, accumulate +happens as narration streams, and the read that feeds the next prompt +pulls a set completed before the turn that produced it — so a panic can +never leave last turn's references inside this turn's reading. + ## The wikilink scanner -The scanner is a listener on the TurnBus. Every delta of the DM's -narration passes through it. It extracts wikilinks, resolves them, and -accumulates the set of entities referenced this turn. Before the next -turn, those entities' `## Is` sections are pushed into the prompt. +The scanner is a listener on the session-scoped bus. Every delta of the +DM's narration passes through it. It extracts wikilinks, resolves them, +and accumulates the set of entities referenced this turn. Before the +next turn, those entities' `## Is` sections are pushed into the prompt. Entities referenced this turn stay in context for the next turn. An entity that falls out of the narration falls out of the prompt. The DM's @@ -126,15 +171,18 @@ engine can re-scan it on restart. ## Implementation order -1. The wikilink scanner as a TurnBus listener — extract `[[...]]` - patterns from narration deltas, resolve canonical names -2. Auto-establishment — create stub entity files when a wikilink does +- [x] 0. The session-scoped bus — move the TurnBus into the `Dm`, build the + listeners once, drive per-turn input through `set_input` +- [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 not resolve -3. Context injection — push referenced entities' `## Is` sections into +- [ ] 3. Context injection — push referenced entities' `## Is` sections into the next turn's prompt -4. Wikilink rendering — format as bold in the terminal, preserve raw +- [ ] 4. Wikilink rendering — format as bold in the terminal, preserve raw syntax in the transcript -5. Restart mechanism — replay last N turns of the transcript, scan +- [ ] 5. Restart mechanism — replay last N turns of the transcript, scan wikilinks, inject context ## Relationship to the Python prototype diff --git a/src/bus.rs b/src/bus.rs index 8f9e585..f55011f 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -11,6 +11,7 @@ use crate::campaign::Campaign; use crate::play::worker::TurnEvent; /// What one listener saw and wants to happen next. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Flow { Continue, Break, @@ -18,7 +19,15 @@ pub enum Flow { /// A thing that reacts to turn events as they happen. /// Every method has a default no-op implementation. -pub trait Listener { +/// +/// `Send` is required so the bus can ride on the worker thread inside the +/// `Dm`, which moves there for its turns. +pub trait Listener: Send { + /// Called before the turn's first delta, with the player's line. A + /// listener that needs per-turn state, like the campaign listener's + /// recording of which line started the turn, sets it here. It is + /// setup, not a cancellable point, so it returns nothing. + fn turn_start(&mut self, _input: &str) {} fn delta(&mut self, _text: &str) -> Flow { Flow::Continue } @@ -45,6 +54,12 @@ impl TurnBus { self.listeners.push(listener); } + pub fn turn_start(&mut self, input: &str) { + for listener in &mut self.listeners { + listener.turn_start(input); + } + } + pub fn delta(&mut self, text: &str) -> ControlFlow<()> { for listener in &mut self.listeners { if matches!(listener.delta(text), Flow::Break) { @@ -138,16 +153,20 @@ pub struct CampaignListener { } impl CampaignListener { - pub fn new(campaign: Option, input: String) -> Self { + pub fn new(campaign: Option) -> Self { Self { campaign, - input, + input: String::new(), narration: String::new(), } } } impl Listener for CampaignListener { + fn turn_start(&mut self, input: &str) { + self.input = input.to_string(); + } + fn delta(&mut self, text: &str) -> Flow { self.narration.push_str(text); Flow::Continue diff --git a/src/bus_tests.rs b/src/bus_tests.rs index c703a63..2ab2307 100644 --- a/src/bus_tests.rs +++ b/src/bus_tests.rs @@ -288,7 +288,7 @@ fn tool_with_display_sends_tool_event() { #[test] fn campaign_listener_accumulates_narration_text() { let mut bus = TurnBus::new(); - let listener = CampaignListener::new(None, "I sleep.".to_string()); + let listener = CampaignListener::new(None); bus.add(Box::new(listener)); let _ = bus.delta("You "); @@ -305,7 +305,7 @@ fn campaign_listener_done_falls_back_to_reply_when_narration_empty() { // record. With no campaign attached, the method just returns // without panicking. let mut bus = TurnBus::new(); - let listener = CampaignListener::new(None, "I sleep.".to_string()); + let listener = CampaignListener::new(None); bus.add(Box::new(listener)); bus.done("You wake."); @@ -314,7 +314,7 @@ fn campaign_listener_done_falls_back_to_reply_when_narration_empty() { #[test] fn campaign_listener_cancelled_is_a_noop() { let mut bus = TurnBus::new(); - let listener = CampaignListener::new(None, "I sleep.".to_string()); + let listener = CampaignListener::new(None); bus.add(Box::new(listener)); bus.cancelled("partial reply"); @@ -323,7 +323,7 @@ fn campaign_listener_cancelled_is_a_noop() { #[test] fn campaign_listener_failed_is_a_noop() { let mut bus = TurnBus::new(); - let listener = CampaignListener::new(None, "I sleep.".to_string()); + let listener = CampaignListener::new(None); bus.add(Box::new(listener)); bus.failed("something broke"); diff --git a/src/dm/dm_tests.rs b/src/dm/dm_tests.rs index 5f1e263..6abc8a8 100644 --- a/src/dm/dm_tests.rs +++ b/src/dm/dm_tests.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use super::*; -use crate::bus::{CampaignListener, Flow, Listener, TurnBus}; +use crate::bus::{Flow, Listener}; use crate::knowledge::fixtures; use tempfile::TempDir; @@ -48,7 +48,7 @@ fn dm_with_campaign(api_base: String, world: &TempDir) -> Dm { } // --------------------------------------------------------------------------- -// Test helpers: a collecting listener and bus factories +// Test helpers: a collecting listener // --------------------------------------------------------------------------- /// A listener that records events for assertions. @@ -111,24 +111,12 @@ impl Listener for TestCollector { } } -/// Creates a `TurnBus` with a `TestCollector` and returns both. -pub(super) fn collector_bus() -> (TurnBus, TestCollector) { +/// Attaches a recording listener to the DM's session-scoped bus and +/// returns it for assertions. +pub(super) fn attach_collector(dm: &mut Dm) -> TestCollector { let collector = TestCollector::new(); - let mut bus = TurnBus::new(); - bus.add(Box::new(collector.clone())); - (bus, collector) -} - -/// Creates a `TurnBus` with a `CampaignListener` for the given DM. -pub(super) fn campaign_bus(dm: &Dm, input: &str) -> TurnBus { - let mut bus = TurnBus::new(); - if let Some(campaign) = dm.campaign().cloned() { - bus.add(Box::new(CampaignListener::new( - Some(campaign), - input.to_string(), - ))); - } - bus + dm.bus.add(Box::new(collector.clone())); + collector } /// Parses a captured request body as JSON and returns its `messages` @@ -150,8 +138,8 @@ fn a_turn_streams_deltas_in_order_and_returns_the_assembled_reply() { let (url, _requests, server) = fake_server(vec![sse_response(body)]); let mut dm = dm_for(url); - let (mut bus, collector) = collector_bus(); - dm.turn("I open the door.", &mut bus).unwrap(); + let collector = attach_collector(&mut dm); + dm.turn("I open the door.").unwrap(); assert_eq!( collector.deltas(), @@ -166,7 +154,7 @@ fn the_request_body_has_the_system_prompt_first_and_the_input_last() { let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); let mut dm = dm_for(url); - dm.turn("I open the door.", &mut TurnBus::new()).unwrap(); + dm.turn("I open the door.").unwrap(); server.join().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); @@ -187,8 +175,7 @@ fn a_turn_with_a_campaign_logs_its_narration_to_the_transcript() { let world = TempDir::new().unwrap(); let mut dm = dm_with_campaign(url, &world); - let mut bus = campaign_bus(&dm, "I sleep."); - dm.turn("I sleep.", &mut bus).unwrap(); + dm.turn("I sleep.").unwrap(); server.join().unwrap(); // No mark has set the clock, so the narration carries the campaign's @@ -213,8 +200,8 @@ fn a_second_turn_sends_the_first_turns_messages_in_the_history() { ]); let mut dm = dm_for(url); - dm.turn("I open the door.", &mut TurnBus::new()).unwrap(); - dm.turn("I step inside.", &mut TurnBus::new()).unwrap(); + dm.turn("I open the door.").unwrap(); + dm.turn("I step inside.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -237,11 +224,11 @@ fn a_failed_turn_leaves_the_history_unchanged() { ]); let mut dm = dm_for(url); - let error = dm.turn("a doomed input", &mut TurnBus::new()); + let error = dm.turn("a doomed input"); assert!(error.is_err()); - dm.turn("a fresh input", &mut TurnBus::new()).unwrap(); + dm.turn("a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -268,11 +255,11 @@ fn an_error_mid_stream_leaves_the_history_unchanged() { let (url, requests, server) = fake_server(vec![broken, sse_response("data: [DONE]\n\n")]); let mut dm = dm_for(url); - let error = dm.turn("a doomed input", &mut TurnBus::new()); + let error = dm.turn("a doomed input"); assert!(error.is_err()); - dm.turn("a fresh input", &mut TurnBus::new()).unwrap(); + dm.turn("a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -290,11 +277,11 @@ fn an_empty_reply_appends_and_returns_the_empty_string() { ]); let mut dm = dm_for(url); - let (mut bus, collector) = collector_bus(); - dm.turn("silence", &mut bus).unwrap(); + let collector = attach_collector(&mut dm); + dm.turn("silence").unwrap(); assert_eq!(collector.get_done(), Some(String::new())); - dm.turn("again", &mut TurnBus::new()).unwrap(); + dm.turn("again").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -314,11 +301,11 @@ fn breaking_on_the_first_delta_returns_cancelled_with_the_partial_text() { let (url, _requests, server) = fake_server(vec![sse_response(body)]); let mut dm = dm_for(url); - let (mut bus, collector) = collector_bus(); + let collector = attach_collector(&mut dm); // Set break-on-delta so the first delta cancels the turn - bus.add(Box::new(TestCollector::new().break_on_delta())); + dm.bus.add(Box::new(TestCollector::new().break_on_delta())); - dm.turn("I open the door.", &mut bus).unwrap(); + dm.turn("I open the door.").unwrap(); // The first listener collected the delta, the second broke on it. // The turn cancelled with the partial text seen so far. @@ -337,13 +324,13 @@ fn a_cancelled_turns_history_is_unchanged_for_the_next_request() { ]); let mut dm = dm_for(url); - let (mut bus, collector) = collector_bus(); - bus.add(Box::new(TestCollector::new().break_on_delta())); - dm.turn("a doomed input", &mut bus).unwrap(); + let collector = attach_collector(&mut dm); + dm.bus.add(Box::new(TestCollector::new().break_on_delta())); + dm.turn("a doomed input").unwrap(); assert!(collector.get_cancelled().is_some()); - dm.turn("a fresh input", &mut TurnBus::new()).unwrap(); + dm.turn("a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -391,9 +378,9 @@ fn each_turn_composes_the_system_prompt_again_from_the_layers_on_disk() { ]); let mut dm = dm_over(url, &[layer.path()]).unwrap(); - dm.turn("I open the door.", &mut TurnBus::new()).unwrap(); + dm.turn("I open the door.").unwrap(); layer.write("system.md", "The second draft."); - dm.turn("I step inside.", &mut TurnBus::new()).unwrap(); + dm.turn("I step inside.").unwrap(); server.join().unwrap(); let first = sent_messages(&requests.recv().unwrap()); @@ -419,9 +406,7 @@ fn a_layer_that_goes_unreadable_mid_session_ends_the_turn_as_an_error() { let mut dm = dm_over("http://127.0.0.1:0".to_string(), &[layer.path()]).unwrap(); fs::write(layer.path().join("system.md"), [0xFF, 0xFE]).unwrap(); - let error = dm - .turn("I open the door.", &mut TurnBus::new()) - .unwrap_err(); + let error = dm.turn("I open the door.").unwrap_err(); assert!(error.to_string().contains("system.md")); } diff --git a/src/dm/dm_tool_round_tests.rs b/src/dm/dm_tool_round_tests.rs index 76d84e2..858f20c 100644 --- a/src/dm/dm_tool_round_tests.rs +++ b/src/dm/dm_tool_round_tests.rs @@ -3,12 +3,12 @@ //! under the project's file-length guideline. Shares that sibling's //! fake-server harness rather than keeping its own copy. -use super::tests::{CapturedRequest, collector_bus, fake_server, sent_messages, sse_response}; +use super::tests::{CapturedRequest, attach_collector, fake_server, sent_messages, sse_response}; use super::*; use std::sync::Mutex; -use crate::bus::{CampaignListener, Flow, Listener, TurnBus}; +use crate::bus::{Flow, Listener}; use crate::campaign::GameTime; use crate::context::ContextStack; use crate::knowledge::fixtures; @@ -136,8 +136,8 @@ fn a_tool_round_then_a_reply_the_second_request_carries_the_tool_result() { ]); let mut dm = dm_with_seeded_dice(url, 0); - let (mut bus, collector) = collector_bus(); - dm.turn("I search for traps.", &mut bus).unwrap(); + let collector = attach_collector(&mut dm); + dm.turn("I search for traps.").unwrap(); assert_eq!( collector.get_done(), @@ -205,14 +205,7 @@ fn a_post_turn_transcript_write_failure_appends_a_warning_instead_of_failing_the // ignored by CampaignListener (the old code appended a warning to the // reply). The turn should still succeed; the warning is no longer // part of the turn's output. - let mut bus = TurnBus::new(); - if let Some(campaign) = dm.campaign().cloned() { - bus.add(Box::new(CampaignListener::new( - Some(campaign), - "I sleep.".to_string(), - ))); - } - dm.turn("I sleep.", &mut bus).unwrap(); + dm.turn("I sleep.").unwrap(); server.join().unwrap(); // Turn succeeded without any warning in the reply. @@ -227,8 +220,8 @@ fn a_completed_multi_round_turns_history_carries_every_round_message_in_order() ]); let mut dm = dm_with_seeded_dice(url, 0); - dm.turn("I search for traps.", &mut TurnBus::new()).unwrap(); - dm.turn("I move on.", &mut TurnBus::new()).unwrap(); + dm.turn("I search for traps.").unwrap(); + dm.turn("I move on.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -257,7 +250,6 @@ fn a_secret_tool_call_emits_no_tool_event() { let mut dm = dm_with_seeded_dice(url, 0); let count = Arc::new(Mutex::new(0usize)); { - let mut bus = TurnBus::new(); struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { @@ -265,8 +257,8 @@ fn a_secret_tool_call_emits_no_tool_event() { Flow::Continue } } - bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I check for danger.", &mut bus).unwrap(); + dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); + dm.turn("I check for danger.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); @@ -287,7 +279,6 @@ fn bad_notation_retries_invisibly_with_no_tool_event() { let mut dm = dm_with_seeded_dice(url, 0); let count = Arc::new(Mutex::new(0usize)); { - let mut bus = TurnBus::new(); struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { @@ -295,8 +286,8 @@ fn bad_notation_retries_invisibly_with_no_tool_event() { Flow::Continue } } - bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I attack.", &mut bus).unwrap(); + dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); + dm.turn("I attack.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); @@ -315,7 +306,7 @@ fn bad_json_arguments_become_a_tool_result_naming_the_problem() { ]); let mut dm = dm_with_seeded_dice(url, 0); - dm.turn("I roll.", &mut TurnBus::new()).unwrap(); + dm.turn("I roll.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -344,7 +335,6 @@ fn a_multi_call_round_dispatches_in_order() { let count = Arc::new(Mutex::new(0usize)); { - let mut bus = TurnBus::new(); struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { @@ -352,8 +342,8 @@ fn a_multi_call_round_dispatches_in_order() { Flow::Continue } } - bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I roll twice.", &mut bus).unwrap(); + dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); + dm.turn("I roll twice.").unwrap(); } assert_eq!(*count.lock().unwrap(), 2); @@ -379,7 +369,6 @@ fn a_completed_calls_tool_event_arrives_before_the_stream_finishes_generating_th let event_kinds: Arc>> = Arc::new(Mutex::new(Vec::new())); { - let mut bus = TurnBus::new(); struct EventTracker(Arc>>); impl Listener for EventTracker { fn delta(&mut self, _text: &str) -> Flow { @@ -391,8 +380,8 @@ fn a_completed_calls_tool_event_arrives_before_the_stream_finishes_generating_th Flow::Continue } } - bus.add(Box::new(EventTracker(Arc::clone(&event_kinds)))); - dm.turn("I search and act.", &mut bus).unwrap(); + dm.bus.add(Box::new(EventTracker(Arc::clone(&event_kinds)))); + dm.turn("I search and act.").unwrap(); } // Call 1 completes, and dispatches, the moment call 2's fragment @@ -427,8 +416,8 @@ fn a_narrated_round_resets_the_tool_only_counter() { let (url, requests, server) = fake_server(responses); let mut dm = dm_with_seeded_dice(url, 0); - let (mut bus, collector) = collector_bus(); - dm.turn("I keep watch.", &mut bus).unwrap(); + let collector = attach_collector(&mut dm); + dm.turn("I keep watch.").unwrap(); assert_eq!( collector.get_done(), @@ -456,8 +445,8 @@ fn the_request_after_max_tool_only_rounds_omits_tools_and_carries_the_note() { let (url, requests, server) = fake_server(responses); let mut dm = dm_with_seeded_dice(url, 0); - let (mut bus, collector) = collector_bus(); - dm.turn("I keep searching.", &mut bus).unwrap(); + let collector = attach_collector(&mut dm); + dm.turn("I keep searching.").unwrap(); assert_eq!(collector.get_done(), Some("You come up empty.".to_string())); server.join().unwrap(); @@ -494,11 +483,10 @@ fn a_cancel_on_a_tool_event_leaves_history_untouched() { crate::bus::Flow::Continue } } - let mut bus = TurnBus::new(); - bus.add(Box::new(BreakOnTool)); - dm.turn("I pull the lever.", &mut bus).unwrap(); + dm.bus.add(Box::new(BreakOnTool)); + dm.turn("I pull the lever.").unwrap(); - dm.turn("a fresh input", &mut TurnBus::new()).unwrap(); + dm.turn("a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -523,7 +511,6 @@ fn a_withheld_rounds_tool_calls_are_not_dispatched_and_the_turn_ends() { let mut dm = dm_with_seeded_dice(url, 0); let count = Arc::new(Mutex::new(0usize)); { - let mut bus = TurnBus::new(); struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { @@ -531,13 +518,13 @@ fn a_withheld_rounds_tool_calls_are_not_dispatched_and_the_turn_ends() { Flow::Continue } } - bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I keep rolling.", &mut bus).unwrap(); + dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); + dm.turn("I keep rolling.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); - dm.turn("a fresh input", &mut TurnBus::new()).unwrap(); + dm.turn("a fresh input").unwrap(); server.join().unwrap(); for _ in 0..(MAX_TOOL_ONLY_ROUNDS + 1) { @@ -577,8 +564,8 @@ fn a_turn_that_narrates_every_round_is_withheld_at_the_round_cap() { let (url, requests, server) = fake_server(responses); let mut dm = dm_with_seeded_dice(url, 0); - let (mut bus, collector) = collector_bus(); - dm.turn("I keep watch all night.", &mut bus).unwrap(); + let collector = attach_collector(&mut dm); + dm.turn("I keep watch all night.").unwrap(); assert_eq!( collector.get_done(), diff --git a/src/dm/mod.rs b/src/dm/mod.rs index c7d1d08..eeff05c 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -3,17 +3,18 @@ use std::fmt; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use rand::rngs::StdRng; use ratatui::text::Text; use serde_json::Value; -use crate::bus::TurnBus; +use crate::bus::{CampaignListener, TurnBus}; 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::knowledge::Mount; use tools::Tool; use tools::Toolbox; @@ -108,15 +109,23 @@ pub enum TurnDelta { } /// A dungeon master session: a chat client, the tools it can call -/// mid-turn, the campaign it records to, the context stack that builds -/// the system prompt each turn, and the history of the conversation so -/// far. +/// mid-turn, the context stack that builds the system prompt each turn, +/// and the history of the conversation so far. The campaign it records to +/// lives on the session-scoped bus as a listener, not here. pub struct Dm { client: Client, toolbox: Toolbox, - campaign: Option, context: Arc, history: Vec, + /// The session-scoped turn bus, built once and owned here so a + /// listener that accumulates across turns, like the wikilink + /// scanner, has a stable home. The worker and tests add their + /// listeners (the screen, a recorder) to it directly. + pub bus: TurnBus, + /// The scanner's session state, shared with the [`Scanner`] listener + /// on [`Self::bus`]. The `Dm` reads the completed set here to build + /// the next turn's prompt. + pub scan: Arc>, } impl Dm { @@ -184,20 +193,28 @@ impl Dm { model: config.model, }; let history = vec![system_message(&context.prompt()?)]; + + // The session-scoped bus carries the wikilink scanner always, and + // the campaign listener when a campaign is present. The screen + // 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 mut bus = TurnBus::new(); + bus.add(Box::new(Scanner::new(Arc::clone(&scan)))); + if let Some(campaign) = campaign.clone() { + bus.add(Box::new(CampaignListener::new(Some(campaign)))); + } + Ok(Self { client, toolbox, - campaign, context, history, + bus, + scan, }) } - /// The campaign this DM records to, if any. - pub fn campaign(&self) -> Option<&Campaign> { - self.campaign.as_ref() - } - /// The `/context` report: the system prompt as the context stack /// composes it now, and every tool the model can call, rendered for /// a player who asks what the DM was told. @@ -210,7 +227,7 @@ impl Dm { } /// Runs a turn from `input` as a loop of rounds, streaming narration - /// and tool events through the given `bus` as they arrive. + /// and tool events through the session-scoped bus as they arrive. /// /// Each round sends the history, `input`, and the rounds so far in /// this turn, then streams the response. A tool call dispatches @@ -245,7 +262,16 @@ impl Dm { /// Composing the system prompt happens first of all, so a layer /// whose `system.md` went unreadable mid-session ends the turn as an /// error with the history untouched. - pub fn turn(&mut self, input: &str, bus: &mut TurnBus) -> Result<(), TurnError> { + pub fn turn(&mut self, input: &str) -> Result<(), TurnError> { + // The listeners reset their per-turn state here, before any + // fallible work (prompt composition, the chat request). A panicked + // turn is caught by the worker, so nothing after this line is + // guaranteed to finish; the scanner's completed set is only + // promoted on `done`, and the campaign's per-turn input only + // records on `done`, so an unfinished turn leaves both as they + // were. + self.bus.turn_start(input); + // Each layer's system.md is read from disk again here, so an // edit to a fragment reaches this turn. The context entries come // from the mount, which stays as it was scanned at startup. @@ -292,8 +318,8 @@ impl Dm { StreamItem::Text(text) => { narration.push_str(&text); turn_narration.push_str(&text); - if bus.delta(&text).is_break() { - bus.cancelled(&narration); + if self.bus.delta(&text).is_break() { + self.bus.cancelled(&narration); return Ok(()); } } @@ -318,9 +344,9 @@ impl Dm { let outcome = self.toolbox.call(&call.function.name, &args); if let Some(display) = &outcome.display - && bus.tool(Some(display)).is_break() + && self.bus.tool(Some(display)).is_break() { - bus.cancelled(&narration); + self.bus.cancelled(&narration); return Ok(()); } tool_results.push(tool_message(&call.id, outcome.for_model)); @@ -332,7 +358,7 @@ impl Dm { self.history.push(user_message); self.history.extend(round_messages); self.history.push(assistant_message(narration.clone())); - bus.done(&narration); + self.bus.done(&narration); return Ok(()); } diff --git a/src/entities/entities_tests.rs b/src/entities/entities_tests.rs new file mode 100644 index 0000000..416005c --- /dev/null +++ b/src/entities/entities_tests.rs @@ -0,0 +1,177 @@ +//! Tests for the wikilink scanner and its resolution against the session's +//! known entities. + +use std::sync::{Arc, Mutex}; + +use super::{Resolution, ScanState, Scanner, extract_wikilinks}; +use crate::bus::{Flow, Listener}; + +/// 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 { + let state = state.lock().unwrap(); + state + .completed() + .map(|entity| entity.name.clone()) + .collect() +} + +// --------------------------------------------------------------------------- +// extraction +// --------------------------------------------------------------------------- + +#[test] +fn extract_finds_each_wikilinks_canonical_name() { + // `[...]` are literal text to a markdown parser, so only the + // double-bracket links count. + assert_eq!( + extract_wikilinks("You meet [[Vera Blackwater]]. She is at [[the Rusty Anchor|the inn]]."), + vec![ + "Vera Blackwater".to_string(), + "the Rusty Anchor".to_string() + ] + ); +} + +#[test] +fn extract_ignores_reference_and_image_links() { + assert_eq!( + extract_wikilinks("A [plain](https://example.com) link and ![](img.png) and `code`."), + Vec::::new() + ); +} + +#[test] +fn extract_returns_nothing_for_plain_text() { + assert_eq!( + extract_wikilinks("Just narration, no links at all."), + Vec::::new() + ); +} + +// --------------------------------------------------------------------------- +// resolution +// --------------------------------------------------------------------------- + +#[test] +fn an_exact_canonical_name_resolves_ignore_case() { + let mut state = ScanState::default(); + state.register("Vera Blackwater"); + assert_eq!( + state.resolve("vera blackwater"), + Resolution::Found("Vera Blackwater".to_string()) + ); +} + +#[test] +fn a_bare_name_that_is_not_a_canonical_name_is_none() { + 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); +} + +#[test] +fn a_ref_to_an_unknown_entity_is_none() { + let mut state = ScanState::default(); + state.register("Vera Blackwater"); + assert_eq!(state.resolve("The Rusty Anchor"), Resolution::None); +} + +#[test] +fn registering_the_same_name_twice_is_a_no_op() { + let mut state = ScanState::default(); + state.register("Vera Blackwater"); + state.register("Vera Blackwater"); + assert_eq!(state.completed().count(), 0); + assert_eq!( + state.resolve("Vera Blackwater"), + Resolution::Found("Vera Blackwater".to_string()) + ); +} + +// --------------------------------------------------------------------------- +// the scanner as a listener +// --------------------------------------------------------------------------- + +#[test] +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)); + + assert_eq!( + scanner.delta("You see [[Vera Blackwater]] watching you."), + Flow::Continue + ); + 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()]); +} + +#[test] +fn an_unresolved_reference_is_left_out_of_the_set() { + let state = Arc::new(Mutex::new(ScanState::default())); + state.lock().unwrap().register("Vera Blackwater"); + let mut scanner = Scanner::new(Arc::clone(&state)); + + 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()]); +} + +#[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)); + + // A clean first turn promotes Vera. + scanner.turn_start("I sleep."); + scanner.delta("[[Vera Blackwater]] is here."); + scanner.done("Vera Blackwater is here."); + + // A second turn starts, streams toward a reference, then is cancelled + // before done: completed is untouched. + scanner.turn_start("I search."); + scanner.delta("[[Vera Blackwater]]"); + + assert_eq!(completed_names(&state), vec!["Vera Blackwater".to_string()]); +} + +#[test] +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)); + + scanner.turn_start("I sleep."); + scanner.delta("[[Vera Blackwater]] is here."); + + // The next turn starts; the reference from the prior turn is gone from + // accumulation, though nothing promoted it. + scanner.turn_start("I wake."); + scanner.done("You wake."); + + assert_eq!(state.lock().unwrap().completed().count(), 0); +} + +#[test] +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)); + + scanner.turn_start("I listen."); + scanner.delta("She is [[Vera "); + scanner.delta("Blackwater]]."); + scanner.done("She is Vera Blackwater."); + + // 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()]); +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs new file mode 100644 index 0000000..5ffbea5 --- /dev/null +++ b/src/entities/mod.rs @@ -0,0 +1,162 @@ +//! Session entities: who and what the DM is talking about right now. +//! +//! The DM names entities with wikilinks in its narration, and the engine +//! turns those references into the next turn's prompt. [`Scanner`] is a +//! bus listener that watches narration deltas, extracts the wikilinks, +//! 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. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; + +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. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entity { + pub name: 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. + Found(String), + /// No known entity matched; a later phase establishes a stub. + None, +} + +/// The scanner's session state, shared between the [`Scanner`] on the bus +/// and the `Dm`, which reads the completed set to build the next turn's +/// prompt. The `Dm` moves onto the worker thread, so the state rides in +/// an `Arc>` rather than a shared reference. +#[derive(Debug, Default)] +pub struct ScanState { + /// The session's known entities, by lowercased canonical name. + entities: BTreeMap, + /// This turn's narration so far, re-scanned on every delta + /// [`Scanner`] receives. + narration: String, + /// The entities referenced this turn, filled as narration streams. + accumulating: BTreeSet, + /// The entities the most recently finished turn referenced, read into + /// the next turn's prompt. + completed: BTreeSet, +} + +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) { + self.entities + .entry(name.to_lowercase()) + .or_insert_with(|| Entity { + name: name.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, + } + } + + /// The entities the most recently finished turn referenced, in name + /// order. + pub fn completed(&self) -> impl Iterator { + self.completed + .iter() + .filter_map(|name| self.entities.get(&name.to_lowercase())) + } +} + +/// A bus listener that turns narration into the references the next turn +/// carries. +/// +/// `turn` calls [`Listener::turn_start`] first, which clears this turn's +/// accumulation and narration before any fallible work — the same +/// poison-tolerance the `Dm` applies to its history. Every narration +/// delta appends to the turn's text and re-resolves all its wikilinks, so +/// a link the model split across two deltas is still caught. [`done`] +/// promotes the accumulation to the completed set, and a turn that never +/// reaches `done` — cancelled, failed, or panicked — leaves the completed +/// set from the last clean turn untouched, which is exactly what the next +/// prompt should read. +pub struct Scanner { + state: Arc>, +} + +impl Scanner { + /// A scanner sharing `state` with the `Dm` that reads it. + pub fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl Listener for Scanner { + fn turn_start(&mut self, _input: &str) { + let mut state = self.state.lock().unwrap(); + state.accumulating.clear(); + state.narration.clear(); + } + + fn delta(&mut self, text: &str) -> Flow { + let mut state = self.state.lock().unwrap(); + 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); + } + } + Flow::Continue + } + + fn done(&mut self, _reply: &str) { + let mut state = self.state.lock().unwrap(); + state.completed = std::mem::take(&mut state.accumulating); + } +} + +/// The canonical names 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`. 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(); + for event in parser { + if let Event::Start(Tag::Link { + link_type: LinkType::WikiLink { .. }, + dest_url, + .. + }) = event + { + names.push(dest_url.into_string()); + } + } + names +} + +#[cfg(test)] +#[path = "entities_tests.rs"] +mod tests; diff --git a/src/lib.rs b/src/lib.rs index ae8dad4..004c8e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod context; pub mod dice; pub mod dm; +pub mod entities; pub mod knowledge; pub mod markdown; pub mod play; diff --git a/src/play/worker.rs b/src/play/worker.rs index 7b12800..93e6abb 100644 --- a/src/play/worker.rs +++ b/src/play/worker.rs @@ -9,7 +9,7 @@ use std::sync::mpsc::{self, Receiver, Sender}; use ratatui::text::Text; -use crate::bus::{CampaignListener, ScreenListener, TurnBus}; +use crate::bus::ScreenListener; use crate::dm::{Dm, TurnError}; /// The name the worker thread runs under, which the play loop's panic @@ -92,22 +92,18 @@ fn run( replies: &Sender, cancel: &Arc, ) { + // The screen listener is registered once here, before the per-turn + // loop, so a test that drives `run` directly, wanting it to return + // deterministically, gets a screen too. Everything else the turn + // needs already sits on the Dm's session-scoped bus. + dm.bus.add(Box::new(ScreenListener::new( + replies.clone(), + Arc::clone(cancel), + ))); + for input in requests { cancel.store(false, Ordering::Relaxed); - - let mut bus = TurnBus::new(); - bus.add(Box::new(ScreenListener::new( - replies.clone(), - Arc::clone(cancel), - ))); - if let Some(campaign) = dm.campaign().cloned() { - bus.add(Box::new(CampaignListener::new( - Some(campaign), - input.clone(), - ))); - } - - if let Some(event) = run_turn(&mut || dm.turn(&input, &mut bus)) { + if let Some(event) = run_turn(&mut || dm.turn(&input)) { // On error or panic, the bus did not send the event, so send // it directly. A send failure means the render loop is gone. let _ = replies.send(event);