//! 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_for, dm_with_campaign, fake_server, sent_messages, sse_response, }; use super::{Campaign, Config, Dm}; use crate::bus::Speaker; 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(Speaker::Player, "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(Speaker::Player, "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(Speaker::Player, "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(Speaker::Player, "I look around.").unwrap(); dm.turn(Speaker::Player, "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(Speaker::Player, "I look around.").unwrap(); fs::write(world.path().join("session.md"), "The party turned back.\n").unwrap(); dm.turn(Speaker::Player, "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(Speaker::Player, "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(Speaker::Player, "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(Speaker::Player, "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(Speaker::Player, "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."); } /// An address no server listens on. These tests build a `Dm` and read /// its trigger without ever taking a turn. const UNUSED: &str = "http://127.0.0.1:0"; #[test] fn a_resumed_session_offers_the_recap_trigger() { let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); let dm = dm_with_campaign(UNUSED.to_string(), &world); assert!(dm.recap_trigger().unwrap().contains("Previously, on")); } #[test] fn a_fresh_campaign_offers_no_recap_trigger() { let world = TempDir::new().unwrap(); let dm = dm_with_campaign(UNUSED.to_string(), &world); assert_eq!(dm.recap_trigger(), None); } #[test] fn a_dm_with_no_campaign_offers_no_recap_trigger() { let dm = dm_for(UNUSED.to_string()); assert_eq!(dm.recap_trigger(), None); } #[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(), reasoning_effort: None, worlds_root: None, }, 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")); }