Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313//! 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<CapturedRequest>) -> 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::<Vec<_>>() .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"));}