diff --git a/src/dm/dm_session_start_tests.rs b/src/dm/dm_session_start_tests.rs new file mode 100644 index 0000000..0415198 --- /dev/null +++ b/src/dm/dm_session_start_tests.rs @@ -0,0 +1,277 @@ +//! Tests for session start: the tail of a world's transcript replayed +//! into a new session's history, and the session brief that says where +//! the story stands. + +use std::fs; +use std::sync::Arc; +use std::sync::mpsc::Receiver; + +use tempfile::TempDir; + +use super::tests::{CapturedRequest, dm_with_campaign, fake_server, sent_messages, sse_response}; +use super::{Campaign, Config, Dm}; +use crate::knowledge::fixtures; + +/// A world whose `transcript/0001.md` holds `body` under one game-time +/// heading, as a finished session leaves it. +fn world_with_transcript(body: &str) -> TempDir { + let world = TempDir::new().unwrap(); + fs::create_dir_all(world.path().join("transcript")).unwrap(); + fs::write( + world.path().join("transcript/0001.md"), + format!("# Day 1\n\n## #d1-0000\n{body}"), + ) + .unwrap(); + world +} + +/// Writes a note at `relative` under `world` with `body` as its prose. +fn write_note(world: &TempDir, relative: &str, body: &str) { + let file = world.path().join(relative); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write( + file, + format!("---\nkind: person\naliases: []\n---\n\n{body}\n"), + ) + .unwrap(); +} + +/// The system prompt of the next request the fake server captured. +fn next_system_prompt(requests: &Receiver) -> String { + sent_messages(&requests.recv().unwrap())[0]["content"] + .as_str() + .unwrap() + .to_string() +} + +/// A transcript body of `count` player lines, each answered by one line +/// of narration. +fn exchanges(count: usize) -> String { + (1..=count) + .map(|number| format!("player> line {number}\n\nreply {number}\n")) + .collect::>() + .join("\n") +} + +#[test] +fn a_written_transcript_replays_into_the_first_requests_messages() { + let world = world_with_transcript( + "player> I enter the tavern.\n\nYou meet Vera at the bar.\n\n\ + player> I sit down.\n\nShe pours you a drink.\n", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I drink.").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"], "I enter the tavern."); + assert_eq!(messages[2]["role"], "assistant"); + assert_eq!(messages[2]["content"], "You meet Vera at the bar."); + assert_eq!(messages[3]["role"], "user"); + assert_eq!(messages[3]["content"], "I sit down."); + assert_eq!(messages[4]["role"], "assistant"); + assert_eq!(messages[4]["content"], "She pours you a drink."); + assert_eq!(messages[5]["content"], "I drink."); +} + +#[test] +fn a_transcript_longer_than_the_limit_replays_only_its_tail() { + let world = world_with_transcript(&exchanges(super::session_start::REPLAYED_EXCHANGES + 3)); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("line 16").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + // The system message, twelve exchanges of two messages each, and the + // line the player just typed. + assert_eq!( + messages.len(), + super::session_start::REPLAYED_EXCHANGES * 2 + 2 + ); + assert_eq!(messages[1]["content"], "line 4"); +} + +#[test] +fn the_first_prompt_carries_the_notes_of_the_last_replayed_narration() { + let world = world_with_transcript( + "player> I enter the tavern.\n\nYou meet [[people/Joseph Black]] by the fire.\n\n\ + player> I sit down.\n\n[[Vera Blackwater]] pours you a drink.\n", + ); + write_note( + &world, + "entities/Vera Blackwater.md", + "Vera keeps the anchor.", + ); + write_note( + &world, + "people/Joseph Black.md", + "Joseph owes the wrong men.", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I drink.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + assert!(prompt.contains("Vera keeps the anchor.")); + assert!(!prompt.contains("Joseph owes the wrong men.")); +} + +#[test] +fn a_session_brief_injects_its_body_and_its_linked_notes() { + let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); + write_note( + &world, + "people/Joseph Black.md", + "Joseph owes the wrong men.", + ); + fs::write( + world.path().join("session.md"), + "The party is mid-ambush. [[people/Joseph Black]] has the ledger.\n", + ) + .unwrap(); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + dm.turn("I keep looking.").unwrap(); + + server.join().unwrap(); + let first = next_system_prompt(&requests); + assert!(first.contains("The party is mid-ambush.")); + assert!(first.contains("### people/Joseph Black")); + assert!(first.contains("Joseph owes the wrong men.")); + // The brief is not a one-time seed: it rides on every turn's prompt, + // however far the conversation has moved past it. + assert!(next_system_prompt(&requests).contains("The party is mid-ambush.")); +} + +#[test] +fn a_brief_edited_between_turns_reaches_the_next_prompt() { + let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); + fs::write(world.path().join("session.md"), "The party rides north.\n").unwrap(); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + fs::write(world.path().join("session.md"), "The party turned back.\n").unwrap(); + dm.turn("I keep looking.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let second = next_system_prompt(&requests); + assert!(second.contains("The party turned back.")); + assert!(!second.contains("The party rides north.")); +} + +#[test] +fn an_empty_brief_injects_nothing() { + let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); + fs::write(world.path().join("session.md"), "---\nkind: brief\n---\n\n").unwrap(); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + assert!(!next_system_prompt(&requests).contains("Where the story stands")); +} + +#[test] +fn an_unresolvable_link_in_replayed_narration_establishes_nothing() { + let world = world_with_transcript( + "player> I look around.\n\n[[Nobody At All]] watches from the corner.\n", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I stare back.").unwrap(); + + server.join().unwrap(); + assert!(!world.path().join("entities/Nobody At All.md").exists()); + assert!(!next_system_prompt(&requests).contains("### Nobody At All")); +} + +/// The milestone's whole story in one test: a world whose last session +/// ended naming Vera, a brief written after it that links her, and a +/// restart that opens with all of it in hand. +#[test] +fn a_restart_opens_with_the_last_turns_the_brief_and_the_note_they_share() { + let world = world_with_transcript( + "player> I enter the tavern.\n\nThe fire is low.\n\n\ + player> I look for the innkeeper.\n\n\ + [[Vera Blackwater]] watches the door without appearing to.\n", + ); + write_note( + &world, + "entities/Vera Blackwater.md", + "Vera keeps the Rusty Anchor and a knife under its bar.", + ); + fs::write( + world.path().join("session.md"), + "The party is at the Rusty Anchor. [[Vera Blackwater|Vera]] has not \ + answered for the burned warehouse.\n", + ) + .unwrap(); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I ask her about the warehouse.").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + assert_eq!(messages[1]["content"], "I enter the tavern."); + assert_eq!(messages[4]["role"], "assistant"); + let prompt = messages[0]["content"].as_str().unwrap(); + assert!(prompt.contains("has not answered for the burned warehouse.")); + assert!(prompt.contains("### entities/Vera Blackwater")); + assert!(prompt.contains("Vera keeps the Rusty Anchor and a knife under its bar.")); +} + +#[test] +fn an_empty_world_replays_nothing_and_still_turns() { + let world = TempDir::new().unwrap(); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I open the door.").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1]["content"], "I open the door."); +} + +#[test] +fn a_transcript_that_cannot_be_read_fails_the_dm_at_startup() { + let world = TempDir::new().unwrap(); + fs::create_dir_all(world.path().join("transcript")).unwrap(); + fs::write(world.path().join("transcript/0001.md"), [0xFF, 0xFE]).unwrap(); + + let error = Dm::new( + Config { + api_base: "http://127.0.0.1:0".to_string(), + api_key: "sk-test".to_string(), + model: "gpt-4o-mini".to_string(), + }, + Arc::new(fixtures::mount(&[])), + &[], + Some(Campaign::open(world.path()).unwrap()), + ) + .err() + .expect("a transcript that cannot be read must fail the dm"); + + assert!(error.contains("0001.md")); +} diff --git a/src/dm/mod.rs b/src/dm/mod.rs index ee7800f..8ba8f8f 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -2,7 +2,7 @@ //! round loop that runs one player turn. use std::fmt; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use rand::rngs::StdRng; @@ -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, note_body, seed_resolver}; +use crate::entities::{ScanState, Scanner, seed_resolver}; use crate::knowledge::Mount; use tools::Tool; use tools::Toolbox; @@ -25,7 +25,9 @@ use tools::mark::MarkTool; use tools::read::ReadTool; use tools::recall::RecallTool; +mod prompt; mod report; +mod session_start; pub mod tools; /// Told to the model in place of its tools on a withheld round: it has @@ -33,10 +35,6 @@ pub mod tools; /// return next turn. const TOOLS_WITHHELD: &str = include_str!("tools-withheld.md"); -/// The preamble ahead of the entity notes appended to the system prompt: -/// the notes may trail the conversation, and the conversation wins. -const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); - /// How many consecutive tool-only rounds, rounds that called tools and /// narrated nothing, are allowed before the tools are withheld for one /// round. @@ -114,8 +112,9 @@ pub enum TurnDelta { /// A dungeon master session: a chat client, the tools it can call /// 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. +/// and the history of the conversation so far, which opens with the tail +/// of the last session's transcript when the world has one. The campaign +/// it records to lives on the session-scoped bus as a listener, not here. pub struct Dm { client: Client, toolbox: Toolbox, @@ -130,9 +129,10 @@ pub struct Dm { /// on [`Self::bus`]. The `Dm` reads the completed set here to build /// the next turn's prompt. pub scan: Arc>, - /// The world root notes live under, the same one [`Self::bus`]'s - /// scanner writes stubs to. `None` when there is no campaign, in - /// which case a turn's prompt carries no entity notes at all. + /// The world root the notes and the session brief live under, the + /// same one [`Self::bus`]'s scanner writes stubs to. `None` when + /// there is no campaign, in which case a turn's prompt carries + /// neither a brief nor any entity notes. entity_root: Option, } @@ -188,7 +188,14 @@ impl Dm { /// with a seeded dice tool or a fake one. `campaign` is where /// narration is recorded; a `None` records nothing. /// - /// Fails when `context` cannot compose its first prompt. + /// With a campaign, the session picks up where the last one left off: + /// every note on disk registers with the resolver, the tail of the + /// transcript replays into the history behind the system message, and + /// the entities that tail and the session brief name are the ones the + /// first turn's prompt carries. See [`session_start`]. + /// + /// Fails when `context` cannot compose its first prompt, or when the + /// campaign's transcript cannot be read. pub fn with_toolbox( config: Config, toolbox: Toolbox, @@ -200,7 +207,7 @@ impl Dm { api_key: config.api_key, model: config.model, }; - let history = vec![system_message(&context.prompt()?)]; + let mut 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 @@ -212,11 +219,16 @@ impl Dm { let entity_root = campaign .as_ref() .map(|campaign| campaign.world().to_path_buf()); - if let Some(root) = &entity_root { + if let Some(campaign) = &campaign { + let mut scan = scan.lock().unwrap(); // 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()); + seed_resolver(campaign.world(), &mut scan); + // The last session's tail becomes this session's opening + // history, and whatever its last narration and the session + // brief name becomes the first turn's entity notes. + history.extend(session_start::resume(campaign, &mut scan)?); } let mut bus = TurnBus::new(); bus.add(Box::new(Scanner::new( @@ -299,11 +311,11 @@ impl Dm { // edit to a fragment reaches this turn. The context entries come // from the mount, which stays as it was scanned at startup. let prompt = self.context.prompt().map_err(TurnError::Context)?; - // The entities the previous turn referenced carry their notes into - // this turn's prompt; the scan lock is held only long enough to - // read the completed set. + // The session brief and the notes of the entities the previous + // turn referenced ride on the end of the prompt; the scan lock is + // held only long enough to read the completed set. let scan = self.scan.lock().unwrap(); - let prompt = append_entity_notes(prompt, self.entity_root.as_deref(), &scan); + let prompt = prompt::compose(prompt, self.entity_root.as_deref(), &scan); drop(scan); self.history[0] = system_message(&prompt); @@ -409,39 +421,6 @@ impl Dm { } } -/// Appends the notes of `scan`'s completed entities to `prompt`, so the -/// next turn keeps straight whatever the DM named last turn. Each note -/// is read fresh from `root`, and an entity with no note, or an empty -/// one, contributes nothing: it was established with no world, or it has -/// none to contribute. Nothing is appended, not even -/// [`ENTITY_NOTES_PREAMBLE`], when `root` is `None` or no completed -/// entity has a note. -fn append_entity_notes(prompt: String, root: Option<&Path>, scan: &ScanState) -> String { - let Some(root) = root else { - return prompt; - }; - let mut notes = String::new(); - for entity in scan.completed() { - let Some(body) = note_body(root, &entity.address) else { - continue; - }; - if !notes.is_empty() { - notes.push_str("\n\n"); - } - notes.push_str("### "); - notes.push_str(&entity.address); - notes.push('\n'); - notes.push_str(&body); - } - if notes.is_empty() { - return prompt; - } - format!( - "{prompt}\n\n---\n\n{}\n\n{notes}", - ENTITY_NOTES_PREAMBLE.trim() - ) -} - /// A `Role::System` message with `content`. fn system_message(content: &str) -> Message { Message { @@ -483,3 +462,7 @@ mod tool_round_tests; #[cfg(test)] #[path = "dm_entity_notes_tests.rs"] mod entity_notes_tests; + +#[cfg(test)] +#[path = "dm_session_start_tests.rs"] +mod session_start_tests; diff --git a/src/dm/prompt.rs b/src/dm/prompt.rs new file mode 100644 index 0000000..9b2039a --- /dev/null +++ b/src/dm/prompt.rs @@ -0,0 +1,78 @@ +//! The turn's system prompt: what the context stack composes, plus the +//! session state that rides on the end of it. +//! +//! Two sections follow the context stack's own prompt. The session brief +//! says where the story stood when the last session ended. The entity +//! notes say what the world knows about whatever the DM named last turn. +//! Both are read fresh from disk every turn, so an edit between two turns +//! reaches the second one. + +use std::path::Path; + +use crate::entities::{ScanState, brief_body, note_body}; + +/// The preamble ahead of the session brief: this is where the story +/// stood after the last session, and the conversation wins once it moves +/// past that. +const SESSION_BRIEF_PREAMBLE: &str = include_str!("session-brief.md"); + +/// The preamble ahead of the entity notes: the notes may trail the +/// conversation, and the conversation wins. +const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); + +/// `prompt` with the session's own state appended: the session brief +/// first, then the notes of `scan`'s completed entities. +/// +/// Both come from the world at `root`. A `None` root is a session with +/// no world, which has neither a brief nor notes to read, so its prompt +/// is the context stack's alone. +pub(super) fn compose(prompt: String, root: Option<&Path>, scan: &ScanState) -> String { + let Some(root) = root else { + return prompt; + }; + let prompt = append_session_brief(prompt, root); + append_entity_notes(prompt, root, scan) +} + +/// Appends the world's `session.md` to `prompt`, so the DM knows where +/// the story stands however far back the replayed history reaches. A +/// world with no brief, or an empty one, contributes nothing, not even +/// [`SESSION_BRIEF_PREAMBLE`]. +fn append_session_brief(prompt: String, root: &Path) -> String { + match brief_body(root) { + Some(body) => section(prompt, SESSION_BRIEF_PREAMBLE, &body), + None => prompt, + } +} + +/// Appends the notes of `scan`'s completed entities to `prompt`, so the +/// next turn keeps straight whatever the DM named last turn. Each note is +/// read fresh from `root`, and an entity with no note, or an empty one, +/// contributes nothing: it was established with no world, or it has none +/// to contribute. Nothing is appended, not even +/// [`ENTITY_NOTES_PREAMBLE`], when no completed entity has a note. +fn append_entity_notes(prompt: String, root: &Path, scan: &ScanState) -> String { + let mut notes = String::new(); + for entity in scan.completed() { + let Some(body) = note_body(root, &entity.address) else { + continue; + }; + if !notes.is_empty() { + notes.push_str("\n\n"); + } + notes.push_str("### "); + notes.push_str(&entity.address); + notes.push('\n'); + notes.push_str(&body); + } + if notes.is_empty() { + return prompt; + } + section(prompt, ENTITY_NOTES_PREAMBLE, ¬es) +} + +/// `prompt` with one more section on the end: a rule, then `preamble`, +/// then `body`. +fn section(prompt: String, preamble: &str, body: &str) -> String { + format!("{prompt}\n\n---\n\n{}\n\n{body}", preamble.trim()) +} diff --git a/src/dm/session-brief.md b/src/dm/session-brief.md new file mode 100644 index 0000000..d7c6ac2 --- /dev/null +++ b/src/dm/session-brief.md @@ -0,0 +1,3 @@ +## Where the story stands + +This is the brief for the story so far, written after the last session ended: who is where, what is mid-flight, and what is unresolved. It describes the moment this session starts from, not what happens next. Once the conversation moves past it, the conversation wins. diff --git a/src/dm/session_start.rs b/src/dm/session_start.rs new file mode 100644 index 0000000..2a2d99c --- /dev/null +++ b/src/dm/session_start.rs @@ -0,0 +1,134 @@ +//! Session start: the tail of the transcript, read back as the +//! conversation it was written from. +//! +//! A session that opens on a world with a record does not start blank. +//! [`resume`] reads the transcript's last exchanges into the message +//! history, and marks the entities the last narration and the session +//! brief name, so the first turn's prompt carries their notes. +//! +//! Nothing here writes. A wikilink that resolves to nothing establishes +//! no stub, and the transcript itself is only read: what the record says +//! is what the last session put there. + +use crate::campaign::Campaign; +use crate::chat::{Message, Role}; +use crate::entities::{ScanState, brief_body}; + +/// The prefix the transcript stamps on what the player typed. +const PLAYER_PREFIX: &str = "player> "; + +/// How many exchanges, a player line and the narration that answered it, +/// replay into a new session's history. +/// +/// The knob that trades how far back the DM can see against the tokens +/// every turn of the session then pays to resend. The rest of the +/// record is still reachable through `recall`, and the session brief +/// carries the arc the replayed turns fall short of. +pub(super) const REPLAYED_EXCHANGES: usize = 12; + +/// Picks `campaign` up where the last session left off, and returns the +/// messages to append to the history after the system message, oldest +/// first. +/// +/// The entities the last replayed narration names join `scan`'s +/// completed set, so the first turn's prompt carries their notes. Only +/// the last narration counts: an entity that fell out of the story +/// several turns ago falls out of the prompt too, which is the same rule +/// a running session follows turn to turn. The session brief is read the +/// same way, and carries whatever the replayed turns are too short to +/// reach. +/// +/// Fails when the transcript cannot be read. A session that silently +/// started blank over an unreadable record would look like amnesia. +pub(super) fn resume(campaign: &Campaign, scan: &mut ScanState) -> Result, String> { + let sections = campaign.transcript_entries()?; + let bodies: Vec<&str> = sections.iter().map(|entry| entry.body.as_str()).collect(); + let messages = tail(reconstruct(&bodies.join("\n\n"))); + + if let Some(narration) = messages + .iter() + .rev() + .find(|message| matches!(message.role, Role::Assistant)) + { + scan.seed_completed(&narration.content); + } + if let Some(brief) = brief_body(campaign.world()) { + scan.seed_completed(&brief); + } + Ok(messages) +} + +/// The conversation that produced `text`, the transcript's section +/// bodies run together. +/// +/// A line that starts with `player> ` is what the player typed, and +/// consecutive ones join into one user message. Every other line is what +/// the DM put on the screen, narration and the event lines from its marks +/// alike, and they run together into one assistant message until the next +/// player line. A blank line between two lines of one speaker stays as a +/// paragraph break, and a longer run of them collapses to one. +/// +/// A transcript that opens with narration, from a mark before the player +/// said anything, opens the conversation with an assistant message. +fn reconstruct(text: &str) -> Vec { + let mut messages: Vec = Vec::new(); + let mut speaking: Option = None; + let mut content = String::new(); + let mut blank = false; + + for line in text.lines() { + if line.trim().is_empty() { + blank = true; + continue; + } + let (role, said) = match line.strip_prefix(PLAYER_PREFIX) { + Some(typed) => (Role::User, typed), + None => (Role::Assistant, line), + }; + if speaking.as_ref() == Some(&role) { + content.push_str(if blank { "\n\n" } else { "\n" }); + } else { + if let Some(previous) = speaking { + messages.push(message(previous, std::mem::take(&mut content))); + } + speaking = Some(role); + } + content.push_str(said); + blank = false; + } + if let Some(previous) = speaking { + messages.push(message(previous, content)); + } + messages +} + +/// The last [`REPLAYED_EXCHANGES`] exchanges of `messages`, counting one +/// exchange from each player line. Narration older than the first +/// replayed player line goes with it, so the tail starts on something the +/// player said. +fn tail(mut messages: Vec) -> Vec { + let starts: Vec = messages + .iter() + .enumerate() + .filter(|(_, message)| matches!(message.role, Role::User)) + .map(|(index, _)| index) + .collect(); + if starts.len() <= REPLAYED_EXCHANGES { + return messages; + } + messages.split_off(starts[starts.len() - REPLAYED_EXCHANGES]) +} + +/// A plain message with `role` and `content` and no tool fields. +fn message(role: Role, content: String) -> Message { + Message { + role, + content, + tool_calls: None, + tool_call_id: None, + } +} + +#[cfg(test)] +#[path = "session_start_tests.rs"] +mod tests; diff --git a/src/dm/session_start_tests.rs b/src/dm/session_start_tests.rs new file mode 100644 index 0000000..445a945 --- /dev/null +++ b/src/dm/session_start_tests.rs @@ -0,0 +1,135 @@ +//! Tests for reconstructing a conversation from a transcript, the odd +//! shapes a day file can hold: an event line between two narration +//! paragraphs, two player lines in a row, and narration before the +//! player has said anything. + +use super::*; + +/// The `(role, content)` of each reconstructed message, flat enough to +/// compare in one assertion. +fn conversation(text: &str) -> Vec<(Role, String)> { + reconstruct(text) + .into_iter() + .map(|message| (message.role, message.content)) + .collect() +} + +/// A transcript of `exchanges` player lines, each answered by one line of +/// narration, laid out the way `Transcript::append` writes them. +fn exchanges(count: usize) -> String { + (1..=count) + .map(|number| format!("player> line {number}\n\nreply {number}\n")) + .collect::>() + .join("\n") +} + +#[test] +fn one_exchange_reconstructs_as_a_user_message_then_an_assistant_message() { + assert_eq!( + conversation("player> I open the door.\n\nYou see a hall."), + vec![ + (Role::User, "I open the door.".to_string()), + (Role::Assistant, "You see a hall.".to_string()), + ] + ); +} + +#[test] +fn an_event_line_between_narration_paragraphs_joins_one_assistant_message() { + assert_eq!( + conversation( + "player> I wait.\n\nThe bell rings.\n\nThe watch changes.\n\nYou are alone again." + ), + vec![ + (Role::User, "I wait.".to_string()), + ( + Role::Assistant, + "The bell rings.\n\nThe watch changes.\n\nYou are alone again.".to_string() + ), + ] + ); +} + +#[test] +fn consecutive_player_lines_join_one_user_message() { + assert_eq!( + conversation("player> I wait.\n\nplayer> I wait longer.\n\nDawn comes."), + vec![ + (Role::User, "I wait.\n\nI wait longer.".to_string()), + (Role::Assistant, "Dawn comes.".to_string()), + ] + ); +} + +#[test] +fn narration_before_any_player_line_opens_the_conversation() { + assert_eq!( + conversation("The caravan sets out.\n\nplayer> I ride ahead."), + vec![ + (Role::Assistant, "The caravan sets out.".to_string()), + (Role::User, "I ride ahead.".to_string()), + ] + ); +} + +#[test] +fn a_run_of_blank_lines_collapses_to_one_paragraph_break() { + assert_eq!( + conversation("player> I look.\n\n\n\nA door.\n\n\n\nA window."), + vec![ + (Role::User, "I look.".to_string()), + (Role::Assistant, "A door.\n\nA window.".to_string()), + ] + ); +} + +#[test] +fn narration_wrapped_over_two_lines_keeps_its_single_break() { + assert_eq!( + conversation("player> I look.\n\nA door stands open,\nand a lamp burns past it."), + vec![ + (Role::User, "I look.".to_string()), + ( + Role::Assistant, + "A door stands open,\nand a lamp burns past it.".to_string() + ), + ] + ); +} + +#[test] +fn an_empty_transcript_reconstructs_no_messages() { + assert_eq!(conversation(""), vec![]); +} + +#[test] +fn fewer_exchanges_than_the_limit_all_replay() { + let messages = tail(reconstruct(&exchanges(REPLAYED_EXCHANGES))); + + assert_eq!(messages.len(), REPLAYED_EXCHANGES * 2); + assert_eq!(messages[0].content, "line 1"); +} + +#[test] +fn more_exchanges_than_the_limit_replay_only_the_last_of_them() { + let messages = tail(reconstruct(&exchanges(REPLAYED_EXCHANGES + 3))); + + assert_eq!(messages.len(), REPLAYED_EXCHANGES * 2); + assert_eq!(messages[0].content, "line 4"); + assert_eq!( + messages.last().unwrap().content, + format!("reply {}", REPLAYED_EXCHANGES + 3) + ); +} + +#[test] +fn narration_older_than_the_first_replayed_player_line_is_dropped_with_it() { + let text = format!( + "The caravan sets out.\n\n{}", + exchanges(REPLAYED_EXCHANGES + 1) + ); + + let messages = tail(reconstruct(&text)); + + assert_eq!(messages[0].content, "line 2"); +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 63f0c39..5cbe830 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -13,11 +13,19 @@ //! 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. [`note_body`] reads an entity's note fresh from disk, for the -//! `Dm` to inject into the next turn's prompt. +//! home for a bare name is the `entities/` bucket. [`note_body`] reads +//! an entity's note fresh from disk, and [`brief_body`] reads the +//! session brief the same way, for the `Dm` to inject into the next +//! turn's prompt. +//! +//! A session that opens on a world with a record starts with entities +//! already in play. [`seed_resolver`] walks the world root and registers +//! every note already on disk, so a bare name cannot re-establish an +//! entity that already exists. [`ScanState::seed_completed`] then takes +//! the replayed narration and the session brief and marks whatever they +//! name as the entities the first turn's prompt carries. Neither writes +//! anything: a reference that resolves to nothing is skipped rather than +//! established. use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -156,6 +164,23 @@ impl ScanState { .iter() .filter_map(|address| self.entities.get(&address.to_lowercase())) } + + /// Puts the entities `text`'s wikilinks resolve to into the + /// completed set, so the next prompt carries their notes. This is + /// how a session starts with entities already in play: the last + /// replayed narration and the session brief go through here before + /// the first turn. + /// + /// A reference that resolves to nothing is skipped, and no stub is + /// established for it. Session start reads the record, and reading + /// the record never writes to the world. + pub(crate) fn seed_completed(&mut self, text: &str) { + for address in extract_wikilinks(text) { + if let Resolution::Found(found) = self.resolve(&address) { + self.completed.insert(found); + } + } + } } /// The final segment of an address, the entity's name. @@ -276,14 +301,27 @@ fn stub_path(root: &Path, address: &str) -> PathBuf { root.join(relative).with_extension("md") } -/// The address's note body, read fresh from disk under `root`, with its -/// frontmatter stripped and its whitespace trimmed. `None` when the note -/// file does not exist or its body is empty: an entity established with -/// no world has no note, and its name already lives in the conversation -/// history. A file with no frontmatter delimiter is read whole, since a -/// hand-written note may skip the frontmatter entirely. +/// The address's note body, read fresh from disk under `root`. `None` +/// when the note file does not exist or its body is empty: an entity +/// established with no world has no note, and its name already lives in +/// the conversation history. pub(crate) fn note_body(root: &Path, address: &str) -> Option { - let contents = fs::read_to_string(stub_path(root, address)).ok()?; + body_of(&stub_path(root, address)) +} + +/// The session brief's body, `session.md` at `root`, read fresh from +/// disk. `None` when the world has no brief yet or the brief is empty. +pub(crate) fn brief_body(root: &Path) -> Option { + body_of(&root.join(SESSION_BRIEF)) +} + +/// The prose of the markdown file at `path`, with its frontmatter +/// stripped and its whitespace trimmed. `None` when the file cannot be +/// read or its body is empty. A file with no frontmatter delimiter is +/// read whole, since a hand-written note or brief may skip the +/// frontmatter entirely. +fn body_of(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; let body = match split_frontmatter(&contents) { Ok((_frontmatter, body)) => body, Err(_) => contents,